From 65fa8aa90476db7d1d803574bafd712c7d2a10d6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 20 Dec 2021 10:36:56 +0100 Subject: [PATCH 001/229] added validation of installed third party libraries before build --- setup.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index cd3ed4f82c..92cc76dc7a 100644 --- a/setup.py +++ b/setup.py @@ -3,15 +3,65 @@ import os import sys import re +import platform +import distutils.spawn from pathlib import Path from cx_Freeze import setup, Executable from sphinx.setup_command import BuildDoc -version = {} - openpype_root = Path(os.path.dirname(__file__)) + +def validate_thirdparty_binaries(): + """Check existence of thirdpart executables.""" + low_platform = platform.system().lower() + binary_vendors_dir = os.path.join( + openpype_root, + "vendor", + "bin" + ) + + error_msg = ( + "Missing binary dependency {}. Please fetch thirdparty dependencies." + ) + # Validate existence of FFmpeg + ffmpeg_dir = os.path.join(binary_vendors_dir, "ffmpeg", low_platform) + if low_platform == "windows": + ffmpeg_dir = os.path.join(ffmpeg_dir, "bin") + ffmpeg_executable = os.path.join(ffmpeg_dir, "ffmpeg") + ffmpeg_result = distutils.spawn.find_executable(ffmpeg_executable) + if ffmpeg_result is None: + raise RuntimeError(error_msg.format("FFmpeg")) + + # Validate existence of OpenImageIO (not on MacOs) + oiio_tool_path = None + if low_platform == "linux": + oiio_tool_path = os.path.join( + binary_vendors_dir, + "oiio", + low_platform, + "bin", + "oiiotool" + ) + elif low_platform == "windows": + oiio_tool_path = os.path.join( + binary_vendors_dir, + "oiio", + low_platform, + "oiiotool" + ) + oiio_result = None + if oiio_tool_path is not None: + oiio_result = distutils.spawn.find_executable(oiio_tool_path) + if oiio_result is None: + raise RuntimeError(error_msg.format("OpenImageIO")) + + +validate_thirdparty_binaries() + +version = {} + with open(openpype_root / "openpype" / "version.py") as fp: exec(fp.read(), version) From b30629866abeb5f5e86ab88dda36bdbf8f2a4cb2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 14:23:37 +0100 Subject: [PATCH 002/229] Improve lib.polyConstraint performance when Select tool is not the active tool context --- openpype/hosts/maya/api/lib.py | 57 +++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..af9a16b291 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -745,6 +745,33 @@ def namespaced(namespace, new=True): cmds.namespace(set=original) +@contextlib.contextmanager +def maintained_selection_api(): + """Maintain selection using the Maya Python API. + + Warning: This is *not* added to the undo stack. + + """ + original = om.MGlobal.getActiveSelectionList() + try: + yield + finally: + om.MGlobal.setActiveSelectionList(original) + + +@contextlib.contextmanager +def tool(context): + """Set a tool context during the context manager. + + """ + original = cmds.currentCtx() + try: + cmds.setToolTo(context) + yield + finally: + cmds.setToolTo(original) + + def polyConstraint(components, *args, **kwargs): """Return the list of *components* with the constraints applied. @@ -763,17 +790,25 @@ def polyConstraint(components, *args, **kwargs): kwargs.pop('mode', None) with no_undo(flush=False): - with maya.maintained_selection(): - # Apply constraint using mode=2 (current and next) so - # it applies to the selection made before it; because just - # a `maya.cmds.select()` call will not trigger the constraint. - with reset_polySelectConstraint(): - cmds.select(components, r=1, noExpand=True) - cmds.polySelectConstraint(*args, mode=2, **kwargs) - result = cmds.ls(selection=True) - cmds.select(clear=True) - - return result + # Reverting selection to the original selection using + # `maya.cmds.select` can be slow in rare cases where previously + # `maya.cmds.polySelectConstraint` had set constrain to "All and Next" + # and the "Random" setting was activated. To work around this we + # revert to the original selection using the Maya API. This is safe + # since we're not generating any undo change anyway. + with tool("selectSuperContext"): + # Selection can be very slow when in a manipulator mode. + # So we force the selection context which is fast. + with maintained_selection_api(): + # Apply constraint using mode=2 (current and next) so + # it applies to the selection made before it; because just + # a `maya.cmds.select()` call will not trigger the constraint. + with reset_polySelectConstraint(): + cmds.select(components, r=1, noExpand=True) + return cmds.polySelectConstraint(*args, + mode=2, + returnSelection=True, + **kwargs) @contextlib.contextmanager From 86771ae01dfbdaca373cc65f05066ec86a7a48cb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 14:34:42 +0100 Subject: [PATCH 003/229] Optimization: Improve speed slightly more (somehow this is faster in most cases) --- openpype/hosts/maya/api/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index af9a16b291..bd83b13b06 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -805,10 +805,10 @@ def polyConstraint(components, *args, **kwargs): # a `maya.cmds.select()` call will not trigger the constraint. with reset_polySelectConstraint(): cmds.select(components, r=1, noExpand=True) - return cmds.polySelectConstraint(*args, - mode=2, - returnSelection=True, - **kwargs) + cmds.polySelectConstraint(*args, mode=2, **kwargs) + result = cmds.ls(selection=True) + cmds.select(clear=True) + return result @contextlib.contextmanager From 61b7b5eeee74ab17a592678bd3c411ff073e4fe3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 16:34:19 +0100 Subject: [PATCH 004/229] Fix #2449 - Remove unique name counter --- .../hosts/houdini/plugins/load/load_alembic.py | 13 +++---------- .../hosts/houdini/plugins/load/load_camera.py | 17 ++++------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/load_alembic.py b/openpype/hosts/houdini/plugins/load/load_alembic.py index cd0f0f0d2d..df66d56008 100644 --- a/openpype/hosts/houdini/plugins/load/load_alembic.py +++ b/openpype/hosts/houdini/plugins/load/load_alembic.py @@ -1,6 +1,6 @@ from avalon import api -from avalon.houdini import pipeline, lib +from avalon.houdini import pipeline class AbcLoader(api.Loader): @@ -25,16 +25,9 @@ class AbcLoader(api.Loader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 + # Define node name namespace = namespace if namespace else context["asset"]["name"] - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) - - children = lib.children_as_string(hou.node("/obj")) - while node_name in children: - counter += 1 - node_name = "{0}_{1:03d}".format(formatted, counter) + node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node container = obj.createNode("geo", node_name=node_name) diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 83246b7d97..8b98b7c05e 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -1,5 +1,5 @@ from avalon import api -from avalon.houdini import pipeline, lib +from avalon.houdini import pipeline ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' @@ -97,18 +97,9 @@ class CameraLoader(api.Loader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 - asset_name = context["asset"]["name"] - - namespace = namespace or asset_name - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) - - children = lib.children_as_string(hou.node("/obj")) - while node_name in children: - counter += 1 - node_name = "{0}_{1:03d}".format(formatted, counter) + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name # Create a archive node container = self.create_and_connect(obj, "alembicarchive", node_name) From bfc6ad0b655869cb00c154b0a2d62d68d8acd20b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:17:56 +0100 Subject: [PATCH 005/229] Fix #2453 Refactor missing _get_reference_node method --- openpype/hosts/maya/plugins/load/load_look.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_look.py b/openpype/hosts/maya/plugins/load/load_look.py index fca612eff4..8e14778fd2 100644 --- a/openpype/hosts/maya/plugins/load/load_look.py +++ b/openpype/hosts/maya/plugins/load/load_look.py @@ -8,6 +8,8 @@ from collections import defaultdict from openpype.widgets.message_window import ScrollMessageBox from Qt import QtWidgets +from openpype.hosts.maya.api.plugin import get_reference_node + class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Specific loader for lookdev""" @@ -70,7 +72,7 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # Get reference node from container members members = cmds.sets(node, query=True, nodesOnly=True) - reference_node = self._get_reference_node(members) + reference_node = get_reference_node(members, log=self.log) shader_nodes = cmds.ls(members, type='shadingEngine') orig_nodes = set(self._get_nodes_with_shader(shader_nodes)) From 1e49b7c87c4d8adad4c6b468ee9adbb8c391af4a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:45:51 +0100 Subject: [PATCH 006/229] Do not keep fixed geometry vertices selected/active after repair --- .../hosts/maya/plugins/publish/validate_shape_zero.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index 2c594ef5f3..acc42f073a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -4,6 +4,8 @@ import pyblish.api import openpype.api import openpype.hosts.maya.api.action +from avalon.maya import maintained_selection + class ValidateShapeZero(pyblish.api.Validator): """shape can't have any values @@ -47,8 +49,12 @@ class ValidateShapeZero(pyblish.api.Validator): @classmethod def repair(cls, instance): invalid_shapes = cls.get_invalid(instance) - for shape in invalid_shapes: - cmds.polyCollapseTweaks(shape) + if not invalid_shapes: + return + + with maintained_selection(): + for shape in invalid_shapes: + cmds.polyCollapseTweaks(shape) def process(self, instance): """Process all the nodes in the instance "objectSet""" From 73400612435880f731230a18afa603b0ad05df3b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 17:31:07 +0100 Subject: [PATCH 007/229] Fix repair taking very long time for many heavy meshes (optimization) --- .../hosts/maya/plugins/publish/validate_shape_zero.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index acc42f073a..bb601b8f50 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -3,6 +3,7 @@ from maya import cmds import pyblish.api import openpype.api import openpype.hosts.maya.api.action +from openpype.hosts.maya.api import lib from avalon.maya import maintained_selection @@ -53,8 +54,13 @@ class ValidateShapeZero(pyblish.api.Validator): return with maintained_selection(): - for shape in invalid_shapes: - cmds.polyCollapseTweaks(shape) + with lib.tool("selectSuperContext"): + for shape in invalid_shapes: + cmds.polyCollapseTweaks(shape) + # cmds.polyCollapseTweaks keeps selecting the geometry + # after each command. When running on many meshes + # after one another this tends to get really heavy + cmds.select(clear=True) def process(self, instance): """Process all the nodes in the instance "objectSet""" From c4d91fb9c0e2fb6a57600c9dc18b319903735994 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 17:34:18 +0100 Subject: [PATCH 008/229] Improve docstring and error message - Previously it said something about translate, rotate and scale. However this validator doesn't check that at all and thus the docstring was incorrect. --- .../hosts/maya/plugins/publish/validate_shape_zero.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index bb601b8f50..6b5c5d1398 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -9,11 +9,9 @@ from avalon.maya import maintained_selection class ValidateShapeZero(pyblish.api.Validator): - """shape can't have any values + """Shape components may not have any "tweak" values - To solve this issue, try freezing the shapes. So long - as the translation, rotation and scaling values are zero, - you're all good. + To solve this issue, try freezing the shapes. """ @@ -67,5 +65,5 @@ class ValidateShapeZero(pyblish.api.Validator): invalid = self.get_invalid(instance) if invalid: - raise ValueError("Nodes found with shape or vertices not freezed" - "values: {0}".format(invalid)) + raise ValueError("Shapes found with non-zero component tweaks: " + "{0}".format(invalid)) From ef87a1f086adf20d57f9284b33253f7b04d38b75 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 17:14:43 +0100 Subject: [PATCH 009/229] confirmation dialog requires to write whole project name --- .../project_manager/project_manager/widgets.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index e4c58a8a2c..392f3f4503 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -336,18 +336,21 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self.setWindowTitle("Delete project?") - message = ( + message_label = QtWidgets.QLabel(self) + message_label.setWordWrap(True) + message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + message_label.setText(( "Project \"{}\" with all related data will be" " permanently removed from the database (This actions won't remove" " any files on disk)." - ).format(project_name) - message_label = QtWidgets.QLabel(message, self) - message_label.setWordWrap(True) + ).format(project_name)) question_label = QtWidgets.QLabel("Are you sure?", self) confirm_input = PlaceholderLineEdit(self) - confirm_input.setPlaceholderText("Type \"Delete\" to confirm...") + confirm_input.setPlaceholderText( + "Type \"{}\" to confirm...".format(project_name) + ) cancel_btn = _SameSizeBtns("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") @@ -379,6 +382,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self._confirm_btn = confirm_btn self._confirm_input = confirm_input self._result = 0 + self._project_name = project_name self.setMinimumWidth(480) self.setMaximumWidth(650) @@ -411,5 +415,5 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self._on_confirm_click() def _on_confirm_text_change(self): - enabled = self._confirm_input.text().lower() == "delete" + enabled = self._confirm_input.text() == self._project_name self._confirm_btn.setEnabled(enabled) From de1c043ec8819b7fd68cebc5152ea8ad60d3e754 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 4 Jan 2022 17:14:59 +0100 Subject: [PATCH 010/229] added label "Delete project" to delete button --- openpype/tools/project_manager/project_manager/window.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index a05811e813..0298d565a5 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -78,7 +78,9 @@ class ProjectManagerWindow(QtWidgets.QWidget): ) create_folders_btn.setEnabled(False) - remove_projects_btn = QtWidgets.QPushButton(project_widget) + remove_projects_btn = QtWidgets.QPushButton( + "Delete project", project_widget + ) remove_projects_btn.setIcon(ResourceCache.get_icon("remove")) remove_projects_btn.setObjectName("IconBtn") From 737a485530bbac1960ad4fe93bb381daea4cc956 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 4 Jan 2022 17:59:58 +0100 Subject: [PATCH 011/229] flame: fix passing env var and flame version dynamic filling --- .../hosts/flame/api/scripts/wiretap_com.py | 17 +++++++++----- openpype/hosts/flame/hooks/pre_flame_setup.py | 23 +++++++++++++------ .../system_settings/applications.json | 8 ++++--- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index d8dc1884cf..5f7b2580e6 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -10,14 +10,19 @@ import xml.dom.minidom as minidom from copy import deepcopy import datetime +FLAME_V = os.getenv("OPENPYPE_FLAME_VERSION") + +if not FLAME_V: + raise KeyError("Missing key in environment `OPENPYPE_FLAME_VERSION`") + try: from libwiretapPythonClientAPI import ( WireTapClientInit) except ImportError: - flame_python_path = "/opt/Autodesk/flame_2021/python" + flame_python_path = "/opt/Autodesk/flame_{}/python".format(FLAME_V) flame_exe_path = ( - "/opt/Autodesk/flame_2021/bin/flame.app" - "/Contents/MacOS/startApp") + "/opt/Autodesk/flame_{}/bin/flame.app" + "/Contents/MacOS/startApp").format(FLAME_V) sys.path.append(flame_python_path) @@ -169,7 +174,7 @@ class WireTapCom(object): # check if volumes exists if self.volume_name not in volumes: raise AttributeError( - ("Volume '{}' does not exist '{}'").format( + ("Volume '{}' does not exist in '{}'").format( self.volume_name, volumes) ) @@ -179,7 +184,7 @@ class WireTapCom(object): "/opt/Autodesk/", "wiretap", "tools", - "2021", + FLAME_V, "wiretap_create_node", ), '-n', @@ -434,7 +439,7 @@ class WireTapCom(object): "/opt/Autodesk/", "wiretap", "tools", - "2021", + FLAME_V, "wiretap_duplicate_node", ), "-s", diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 159fb37410..e7ef856907 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -19,7 +19,10 @@ class FlamePrelaunch(PreLaunchHook): app_groups = ["flame"] # todo: replace version number with avalon launch app version - flame_python_exe = "/opt/Autodesk/python/2021/bin/python2.7" + flame_python_exe = ( + "/opt/Autodesk/python/{OPENPYPE_FLAME_VERSION}" + "/bin/python2.7" + ) wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") @@ -30,6 +33,7 @@ class FlamePrelaunch(PreLaunchHook): self.signature = "( {} )".format(self.__class__.__name__) def execute(self): + _env = self.launch_context.env """Hook entry method.""" project_doc = self.data["project_doc"] user_name = get_openpype_username() @@ -58,9 +62,9 @@ class FlamePrelaunch(PreLaunchHook): data_to_script = { # from settings - "host_name": os.getenv("FLAME_WIRETAP_HOSTNAME") or hostname, - "volume_name": os.getenv("FLAME_WIRETAP_VOLUME"), - "group_name": os.getenv("FLAME_WIRETAP_GROUP"), + "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, + "volume_name": _env.get("FLAME_WIRETAP_VOLUME"), + "group_name": _env.get("FLAME_WIRETAP_GROUP"), "color_policy": "ACES 1.1", # from project @@ -68,9 +72,12 @@ class FlamePrelaunch(PreLaunchHook): "user_name": user_name, "project_data": project_data } + + self.log.info(pformat(dict(_env))) + self.log.info(pformat(data_to_script)) + app_arguments = self._get_launch_arguments(data_to_script) - self.log.info(pformat(dict(self.launch_context.env))) opflame.setup(self.launch_context.env) @@ -83,7 +90,9 @@ class FlamePrelaunch(PreLaunchHook): with make_temp_file(dumped_script_data) as tmp_json_path: # Prepare subprocess arguments args = [ - self.flame_python_exe, + self.flame_python_exe.format( + **self.launch_context.env + ), self.wtc_script_path, tmp_json_path ] @@ -91,7 +100,7 @@ class FlamePrelaunch(PreLaunchHook): process_kwargs = { "logger": self.log, - "env": {} + "env": self.launch_context.env } openpype.api.run_subprocess(args, **process_kwargs) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 1cbe09f576..23ea64fdc1 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -118,10 +118,10 @@ "executables": { "windows": [], "darwin": [ - "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp" + "/opt/Autodesk/flame_{OPENPYPE_FLAME_VERSION}/bin/flame.app/Contents/MacOS/startApp" ], "linux": [ - "/opt/Autodesk/flame_2021/bin/flame" + "/opt/Autodesk/flame_{OPENPYPE_FLAME_VERSION}/bin/flame" ] }, "arguments": { @@ -129,7 +129,9 @@ "darwin": [], "linux": [] }, - "environment": {} + "environment": { + "OPENPYPE_FLAME_VERSION": "2021" + } }, "__dynamic_keys_labels__": { "2021": "2021 (Testing Only)" From 1c865567f5b88061aff9bda6a3cd4ef4662a6034 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 11:12:21 +0100 Subject: [PATCH 012/229] flame path no env var --- .../defaults/system_settings/applications.json | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 23ea64fdc1..3a097d2b37 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -118,10 +118,10 @@ "executables": { "windows": [], "darwin": [ - "/opt/Autodesk/flame_{OPENPYPE_FLAME_VERSION}/bin/flame.app/Contents/MacOS/startApp" + "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp" ], "linux": [ - "/opt/Autodesk/flame_{OPENPYPE_FLAME_VERSION}/bin/flame" + "/opt/Autodesk/flame_2021/bin/flame" ] }, "arguments": { @@ -144,7 +144,10 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": ["{NUKE_PATH}", "{OPENPYPE_STUDIO_PLUGINS}/nuke"] + "NUKE_PATH": [ + "{NUKE_PATH}", + "{OPENPYPE_STUDIO_PLUGINS}/nuke" + ] }, "variants": { "13-0": { @@ -250,7 +253,10 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": ["{NUKE_PATH}", "{OPENPYPE_STUDIO_PLUGINS}/nuke"] + "NUKE_PATH": [ + "{NUKE_PATH}", + "{OPENPYPE_STUDIO_PLUGINS}/nuke" + ] }, "variants": { "13-0": { From b3a284294949189a3737ed28fac90294a8829087 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 11:29:37 +0100 Subject: [PATCH 013/229] flame: fixing permission issue overcoming --- openpype/hosts/flame/api/utils.py | 38 +++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 201c7d2fac..aae102dd7e 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -75,10 +75,19 @@ def _sync_utility_scripts(env=None): path = os.path.join(flame_shared_dir, _itm) log.info("Removing `{path}`...".format(**locals())) - if os.path.isdir(path): - shutil.rmtree(path, onerror=None) - else: - os.remove(path) + + try: + if os.path.isdir(path): + shutil.rmtree(path, onerror=None) + else: + os.remove(path) + except PermissionError as msg: + log.warning( + "Not able to remove: `{}`, Problem with: `{}`".format( + path, + msg + ) + ) # copy scripts into Resolve's utility scripts dir for dirpath, scriptlist in scripts.items(): @@ -88,13 +97,22 @@ def _sync_utility_scripts(env=None): src = os.path.join(dirpath, _script) dst = os.path.join(flame_shared_dir, _script) log.info("Copying `{src}` to `{dst}`...".format(**locals())) - if os.path.isdir(src): - shutil.copytree( - src, dst, symlinks=False, - ignore=None, ignore_dangling_symlinks=False + + try: + if os.path.isdir(src): + shutil.copytree( + src, dst, symlinks=False, + ignore=None, ignore_dangling_symlinks=False + ) + else: + shutil.copy2(src, dst) + except PermissionError as msg: + log.warning( + "Not able to coppy to: `{}`, Problem with: `{}`".format( + dst, + msg + ) ) - else: - shutil.copy2(src, dst) def setup(env=None): From 97f6afa90fb9bf6d5f4acbb1a41b0b80c730b8a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 14:36:21 +0100 Subject: [PATCH 014/229] flame: fixing pref file handling --- openpype/hosts/flame/api/lib.py | 9 ++++++--- openpype/hosts/flame/api/utils.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 96bffab774..44043c00f2 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -114,11 +114,14 @@ class FlameAppFramework(object): self.hostname, ) - self.log.info("[{}] waking up".format(self.__class__.__name__)) - self.load_prefs() + self.log.info("[{}] waking up".format(self.__class__.__name__)) + + try: + self.load_prefs() + except RuntimeError: + self.save_prefs() # menu auto-refresh defaults - if not self.prefs_global.get("menu_auto_refresh"): self.prefs_global["menu_auto_refresh"] = { "media_panel": True, diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index aae102dd7e..8ed8613b15 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -106,7 +106,7 @@ def _sync_utility_scripts(env=None): ) else: shutil.copy2(src, dst) - except PermissionError as msg: + except (PermissionError, FileExistsError) as msg: log.warning( "Not able to coppy to: `{}`, Problem with: `{}`".format( dst, From 723bfb2b3cc879c09f3ccdc2b89316e6ebfeb4c0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 18:04:06 +0100 Subject: [PATCH 015/229] flame: adding openpype marker on segment handling --- openpype/hosts/flame/api/lib.py | 152 +++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 44043c00f2..f91f593eb5 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -1,5 +1,6 @@ import sys import os +import json import pickle import contextlib from pprint import pformat @@ -8,6 +9,12 @@ from openpype.api import Logger log = Logger().get_logger(__name__) +class ctx: + # OpenPype marker workflow variables + marker_name = "OpenPypeData" + marker_duration = 0 + marker_color = (0.0, 1.0, 1.0) + publish_default = False @contextlib.contextmanager def io_preferences_file(klass, filepath, write=False): @@ -114,7 +121,7 @@ class FlameAppFramework(object): self.hostname, ) - self.log.info("[{}] waking up".format(self.__class__.__name__)) + self.log.info("[{}] waking up".format(self.__class__.__name__)) try: self.load_prefs() @@ -337,3 +344,146 @@ def get_metadata(project_name, _log=None): policy_wiretap = GetProjectColorPolicy(_log=_log) return policy_wiretap.process(project_name) + + +def get_segment_pype_tag(segment, with_marker=None): + """ + Get openpype track item tag created by creator or loader plugin. + + Attributes: + segment (flame.PySegment): flame api object + with_marker (bool)[optional]: if true it will return also marker object + + Returns: + dict: openpype tag data + + Returns(with_marker=True): + flame.PyMarker, dict + """ + for marker in segment.markers: + comment = marker.comment.get_value() + color = marker.colour.get_value() + name = marker.name.get_value() + + if name == ctx.marker_name and color == ctx.marker_color: + if not with_marker: + return json.loads(comment) + else: + return marker, json.loads(comment) + + +def set_segment_pype_tag(segment, data=None): + """ + Set openpype track item tag to input segment. + + Attributes: + segment (flame.PySegment): flame api object + + Returns: + dict: json loaded data + """ + data = data or dict() + + marker_data = get_segment_pype_tag(segment, True) + + if marker_data: + # get available openpype tag if any + marker, tag_data = marker_data + # update tag data with new data + tag_data.update(data) + # update marker with tag data + marker.comment = json.dumps(tag_data) + + return True + else: + # update tag data with new data + marker = create_pype_marker(segment) + # add tag data to marker's comment + marker.comment = json.dumps(data) + + return True + + + +def imprint(segment, data=None): + """ + Adding openpype data to Flame timeline segment. + + Also including publish attribute into tag. + + Arguments: + segment (flame.PySegment)): flame api object + data (dict): Any data which needst to be imprinted + + Examples: + data = { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + } + """ + data = data or {} + + if not set_segment_pype_tag(segment, data): + raise AttributeError("Not imprint data to segment") + + # add publish attribute + set_publish_attribute(segment, True) + + +def set_publish_attribute(segment, value): + """ Set Publish attribute in input Tag object + + Attribute: + segment (flame.PySegment)): flame api object + value (bool): True or False + """ + tag_data = get_segment_pype_tag(segment) + tag_data["publish"] = value + + # set data to the publish attribute + if not set_segment_pype_tag(segment, tag_data): + raise AttributeError("Not imprint data to segment") + + +def get_publish_attribute(segment): + """ Get Publish attribute from input Tag object + + Attribute: + segment (flame.PySegment)): flame api object + + Returns: + bool: True or False + """ + tag_data = get_segment_pype_tag(segment) + + if not tag_data: + set_publish_attribute(segment, ctx.publish_default) + return ctx.publish_default + + return tag_data["publish"] + + +def create_pype_marker(segment): + """ Create openpype marker on a segment. + + Attributes: + segment (flame.PySegment): flame api object + + Returns: + flame.PyMarker: flame api object + """ + # get duration of segment + duration = segment.record_duration.relative_frame + # calculate start frame of the new marker + start_frame = int(segment.record_in.relative_frame) + int(duration / 2) + # create marker + marker = segment.create_marker(start_frame) + # set marker name + marker.name = ctx.marker_name + # set duration + marker.duration = ctx.marker_duration + # set colour + marker.colour = ctx.marker_color + + return marker \ No newline at end of file From 9d0e3363c5bfab9741ee2be01ff3e5f56d5b59cf Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 21:44:29 +0100 Subject: [PATCH 016/229] flame: testing in publish plugin --- .../plugins/publish/collect_test_selection.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 9a80a92414..d30d6ed331 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -24,3 +24,24 @@ class CollectTestSelection(pyblish.api.ContextPlugin): otio_timeline = otio_export.create_otio_timeline(sequence) self.log.info(pformat(otio_timeline)) + + # test segment markers + for ver in sequence.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + + for segment in track.segments: + if str(segment.name)[1:-1] == "": + continue + if not segment.selected: + continue + + self.log.debug("Segment with OpenPypeData: {}".format( + segment.name)) + + lib.imprint(segment, { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + }) From 5d39784abb1228bddbb5abf0fc329c5d3d4aecfc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 22:32:07 +0100 Subject: [PATCH 017/229] flame: maintained segment selection --- openpype/hosts/flame/api/lib.py | 45 +++++++++++++++++++++++++--- openpype/hosts/flame/api/pipeline.py | 26 ---------------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index f91f593eb5..5860bb728d 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -393,15 +393,13 @@ def set_segment_pype_tag(segment, data=None): tag_data.update(data) # update marker with tag data marker.comment = json.dumps(tag_data) - - return True else: # update tag data with new data marker = create_pype_marker(segment) # add tag data to marker's comment marker.comment = json.dumps(data) - return True + return True @@ -486,4 +484,43 @@ def create_pype_marker(segment): # set colour marker.colour = ctx.marker_color - return marker \ No newline at end of file + return marker + + +@contextlib.contextmanager +def maintained_segment_selection(sequence): + """Maintain selection during context + + Example: + >>> with maintained_selection(): + ... node['selected'].setValue(True) + >>> print(node['selected'].value()) + False + """ + selected_segments = [] + for ver in sequence.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + for segment in track.segments: + if segment.selected != True: + continue + selected_segments.append(segment) + try: + # do the operation + yield + finally: + reset_segment_selection(sequence) + for segment in selected_segments: + segment.selected = True + + +def reset_segment_selection(sequence): + """Deselect all selected nodes + """ + for ver in sequence.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + for segment in track.segments: + segment.selected = False diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 26dfe7c032..00860857f1 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -97,32 +97,6 @@ def update_container(tl_segment, data=None): # TODO: update_container pass - -@contextlib.contextmanager -def maintained_selection(): - """Maintain selection during context - - Example: - >>> with maintained_selection(): - ... node['selected'].setValue(True) - >>> print(node['selected'].value()) - False - """ - # TODO: maintained_selection + remove undo steps - - try: - # do the operation - yield - finally: - pass - - -def reset_selection(): - """Deselect all selected nodes - """ - pass - - def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node passthrough states on instance toggles.""" From 655e85f12ad5c4e98b777c1f90e285fb838046a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Jan 2022 22:46:37 +0100 Subject: [PATCH 018/229] flame: adding docstring to maintained segment selection --- openpype/hosts/flame/api/lib.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 5860bb728d..0ba6d81c0d 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -491,26 +491,41 @@ def create_pype_marker(segment): def maintained_segment_selection(sequence): """Maintain selection during context + Attributes: + sequence (flame.PySequence): python api object + + Yield: + list of flame.PySegment + Example: - >>> with maintained_selection(): - ... node['selected'].setValue(True) - >>> print(node['selected'].value()) - False + >>> with maintained_segment_selection(sequence) as selected_segments: + ... for segment in selected_segments: + ... segment.selected = False + >>> print(segment.selected) + True """ selected_segments = [] + # loop versions in sequence for ver in sequence.versions: + # loop track in versions for track in ver.tracks: + # ignore all empty tracks and hidden too if len(track.segments) == 0 and track.hidden: continue + # loop all segment in remaining tracks for segment in track.segments: + # ignore all segments not selected if segment.selected != True: continue + # add it to original selection selected_segments.append(segment) try: - # do the operation - yield + # do the operation on selected segments + yield selected_segments finally: + # reset all selected clips reset_segment_selection(sequence) + # select only original selection of segments for segment in selected_segments: segment.selected = True From 152810f09d8eb61f17f39535819bcc789625f113 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 10:39:43 +0100 Subject: [PATCH 019/229] flame: moving and renaming api function --- openpype/hosts/flame/__init__.py | 15 +++++++++- openpype/hosts/flame/api/lib.py | 43 ++++++---------------------- openpype/hosts/flame/api/pipeline.py | 31 ++++++++++++++++++-- 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index da28170679..d6019100cb 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -8,7 +8,6 @@ from .api.pipeline import ( ls, containerise, update_container, - maintained_selection, remove_instance, list_instances, imprint @@ -21,6 +20,13 @@ from .api.lib import ( get_current_project, get_current_sequence, create_bin, + create_segment_data_marker, + get_segment_data_marker, + set_segment_data_marker, + set_publish_attribute, + get_publish_attribute, + maintained_segment_selection, + reset_segment_selection ) from .api.menu import ( @@ -90,6 +96,13 @@ __all__ = [ "get_current_project", "get_current_sequence", "create_bin", + "create_segment_data_marker", + "get_segment_data_marker", + "set_segment_data_marker", + "set_publish_attribute", + "get_publish_attribute", + "maintained_segment_selection", + "reset_segment_selection", # menu "FlameMenuProjectConnect", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 0ba6d81c0d..03b4c1f619 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -346,7 +346,7 @@ def get_metadata(project_name, _log=None): return policy_wiretap.process(project_name) -def get_segment_pype_tag(segment, with_marker=None): +def get_segment_data_marker(segment, with_marker=None): """ Get openpype track item tag created by creator or loader plugin. @@ -372,7 +372,7 @@ def get_segment_pype_tag(segment, with_marker=None): return marker, json.loads(comment) -def set_segment_pype_tag(segment, data=None): +def set_segment_data_marker(segment, data=None): """ Set openpype track item tag to input segment. @@ -384,7 +384,7 @@ def set_segment_pype_tag(segment, data=None): """ data = data or dict() - marker_data = get_segment_pype_tag(segment, True) + marker_data = get_segment_data_marker(segment, True) if marker_data: # get available openpype tag if any @@ -395,40 +395,13 @@ def set_segment_pype_tag(segment, data=None): marker.comment = json.dumps(tag_data) else: # update tag data with new data - marker = create_pype_marker(segment) + marker = create_segment_data_marker(segment) # add tag data to marker's comment marker.comment = json.dumps(data) return True - -def imprint(segment, data=None): - """ - Adding openpype data to Flame timeline segment. - - Also including publish attribute into tag. - - Arguments: - segment (flame.PySegment)): flame api object - data (dict): Any data which needst to be imprinted - - Examples: - data = { - 'asset': 'sq020sh0280', - 'family': 'render', - 'subset': 'subsetMain' - } - """ - data = data or {} - - if not set_segment_pype_tag(segment, data): - raise AttributeError("Not imprint data to segment") - - # add publish attribute - set_publish_attribute(segment, True) - - def set_publish_attribute(segment, value): """ Set Publish attribute in input Tag object @@ -436,11 +409,11 @@ def set_publish_attribute(segment, value): segment (flame.PySegment)): flame api object value (bool): True or False """ - tag_data = get_segment_pype_tag(segment) + tag_data = get_segment_data_marker(segment) tag_data["publish"] = value # set data to the publish attribute - if not set_segment_pype_tag(segment, tag_data): + if not set_segment_data_marker(segment, tag_data): raise AttributeError("Not imprint data to segment") @@ -453,7 +426,7 @@ def get_publish_attribute(segment): Returns: bool: True or False """ - tag_data = get_segment_pype_tag(segment) + tag_data = get_segment_data_marker(segment) if not tag_data: set_publish_attribute(segment, ctx.publish_default) @@ -462,7 +435,7 @@ def get_publish_attribute(segment): return tag_data["publish"] -def create_pype_marker(segment): +def create_segment_data_marker(segment): """ Create openpype marker on a segment. Attributes: diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 00860857f1..2295589627 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -5,6 +5,10 @@ import contextlib from avalon import api as avalon from pyblish import api as pyblish from openpype.api import Logger +from .lib import ( + set_segment_data_marker, + set_publish_attribute +) AVALON_CONTAINERS = "AVALON_CONTAINERS" @@ -124,6 +128,27 @@ def list_instances(): pass -def imprint(item, data=None): - # TODO: imprint - pass +def imprint(segment, data=None): + """ + Adding openpype data to Flame timeline segment. + + Also including publish attribute into tag. + + Arguments: + segment (flame.PySegment)): flame api object + data (dict): Any data which needst to be imprinted + + Examples: + data = { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + } + """ + data = data or {} + + if not set_segment_data_marker(segment, data): + raise AttributeError("Not imprint data to segment") + + # add publish attribute + set_publish_attribute(segment, True) From 455a8a50b1d2ca98f33c6661c1e654886c53563a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 13:02:58 +0100 Subject: [PATCH 020/229] flame: add lib functionalities for segment operations --- openpype/hosts/flame/__init__.py | 6 +- openpype/hosts/flame/api/lib.py | 124 ++++++++++++++++++++++++++----- 2 files changed, 109 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index d6019100cb..691b6f8119 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -25,8 +25,10 @@ from .api.lib import ( set_segment_data_marker, set_publish_attribute, get_publish_attribute, + get_sequence_segments, maintained_segment_selection, - reset_segment_selection + reset_segment_selection, + get_segment_attributes ) from .api.menu import ( @@ -101,8 +103,10 @@ __all__ = [ "set_segment_data_marker", "set_publish_attribute", "get_publish_attribute", + "get_sequence_segments", "maintained_segment_selection", "reset_segment_selection", + "get_segment_attributes" # menu "FlameMenuProjectConnect", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 03b4c1f619..2d30390d21 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -1,5 +1,6 @@ import sys import os +import re import json import pickle import contextlib @@ -13,8 +14,21 @@ class ctx: # OpenPype marker workflow variables marker_name = "OpenPypeData" marker_duration = 0 - marker_color = (0.0, 1.0, 1.0) + marker_color = "red" publish_default = False + color_map = { + "red": (1.0, 0.0, 0.0), + "orange": (1.0, 0.5, 0.0), + "yellow": (1.0, 1.0, 0.0), + "pink": (1.0, 0.5, 1.0), + "white": (1.0, 1.0, 1.0), + "green": (0.0, 1.0, 0.0), + "cyan": (0.0, 1.0, 1.0), + "blue": (0.0, 0.0, 1.0), + "purple": (0.5, 0.0, 0.5), + "magenta": (0.5, 0.0, 1.0), + "black": (0.0, 0.0, 0.0) +} @contextlib.contextmanager def io_preferences_file(klass, filepath, write=False): @@ -262,8 +276,8 @@ def get_media_storage(): def get_current_project(): - # TODO: get_current_project - return + import flame + return flame.project.current_project def get_current_sequence(selection): @@ -365,7 +379,7 @@ def get_segment_data_marker(segment, with_marker=None): color = marker.colour.get_value() name = marker.name.get_value() - if name == ctx.marker_name and color == ctx.marker_color: + if name == ctx.marker_name and color == ctx.color_map[ctx.marker_color]: if not with_marker: return json.loads(comment) else: @@ -455,10 +469,28 @@ def create_segment_data_marker(segment): # set duration marker.duration = ctx.marker_duration # set colour - marker.colour = ctx.marker_color + marker.colour = ctx.color_map[ctx.marker_color] # Red return marker +def get_sequence_segments(sequence, selected=False): + segments = [] + # loop versions in sequence + for ver in sequence.versions: + # loop track in versions + for track in ver.tracks: + # ignore all empty tracks and hidden too + if len(track.segments) == 0 and track.hidden: + continue + # loop all segment in remaining tracks + for segment in track.segments: + # ignore all segments not selected + if segment.selected != True and selected == True: + continue + # add it to original selection + segments.append(segment) + return segments + @contextlib.contextmanager def maintained_segment_selection(sequence): @@ -477,21 +509,7 @@ def maintained_segment_selection(sequence): >>> print(segment.selected) True """ - selected_segments = [] - # loop versions in sequence - for ver in sequence.versions: - # loop track in versions - for track in ver.tracks: - # ignore all empty tracks and hidden too - if len(track.segments) == 0 and track.hidden: - continue - # loop all segment in remaining tracks - for segment in track.segments: - # ignore all segments not selected - if segment.selected != True: - continue - # add it to original selection - selected_segments.append(segment) + selected_segments = get_sequence_segments(sequence, True) try: # do the operation on selected segments yield selected_segments @@ -512,3 +530,69 @@ def reset_segment_selection(sequence): continue for segment in track.segments: segment.selected = False + + +def _get_shot_tokens_values(clip, tokens): + old_value = None + output = {} + + if not clip.shot_name: + return output + + old_value = clip.shot_name.get_value() + + for token in tokens: + clip.shot_name.set_value(token) + _key = str(re.sub("[<>]", "", token)).replace(" ", "_") + + try: + output[_key] = int(clip.shot_name.get_value()) + except ValueError: + output[_key] = clip.shot_name.get_value() + + clip.shot_name.set_value(old_value) + + return output + + +def get_segment_attributes(segment): + if str(segment.name)[1:-1] == "": + return None + + # Add timeline segment to tree + clip_data = { + "segment_name": segment.name.get_value(), + "segment_comment": segment.comment.get_value(), + "tape_name": segment.tape_name, + "source_name": segment.source_name, + "fpath": segment.file_path, + "PySegment": segment + } + + # add all available shot tokens + shot_tokens = _get_shot_tokens_values(segment, [ + "", "", "", "", "", + "", "" + ]) + clip_data.update(shot_tokens) + + # populate shot source metadata + segment_attrs = [ + "record_duration", "record_in", "record_out", + "source_duration", "source_in", "source_out" + ] + segment_attrs_data = {} + for attr in segment_attrs: + if not hasattr(segment, attr): + continue + _value = getattr(segment, attr) + segment_attrs_data[attr] = str(_value).replace("+", ":") + + if attr in ["record_in", "record_out"]: + clip_data[attr] = _value.relative_frame + else: + clip_data[attr] = _value.frame + + clip_data["segment_timecodes"] = segment_attrs_data + + return clip_data From 8f721d3360ca3a445d9dd6ea10ec9c2aac64249f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 13:46:21 +0100 Subject: [PATCH 021/229] flame: create plugin abstractions --- openpype/hosts/flame/api/plugin.py | 623 +++++++++++++++++++++++++++++ openpype/hosts/flame/api/style.css | 26 ++ 2 files changed, 649 insertions(+) create mode 100644 openpype/hosts/flame/api/style.css diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 2a28a20a75..1a3880a19a 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -1,3 +1,626 @@ +import re +import os +from Qt import QtWidgets, QtCore +import openpype.api as openpype +import openpype.hosts.flame as opflame +from . import lib +from copy import deepcopy + +log = openpype.Logger().get_logger(__name__) + # Creator plugin functions +def load_stylesheet(): + path = os.path.join(os.path.dirname(__file__), "style.css") + if not os.path.exists(path): + log.warning("Unable to load stylesheet, file not found in resources") + return "" + + with open(path, "r") as file_stream: + stylesheet = file_stream.read() + return stylesheet + + +class CreatorWidget(QtWidgets.QDialog): + + # output items + items = dict() + + def __init__(self, name, info, ui_inputs, parent=None): + super(CreatorWidget, self).__init__(parent) + + self.setObjectName(name) + + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowStaysOnTopHint + ) + self.setWindowTitle(name or "Pype Creator Input") + self.resize(500, 700) + + # Where inputs and labels are set + self.content_widget = [QtWidgets.QWidget(self)] + top_layout = QtWidgets.QFormLayout(self.content_widget[0]) + top_layout.setObjectName("ContentLayout") + top_layout.addWidget(Spacer(5, self)) + + # first add widget tag line + top_layout.addWidget(QtWidgets.QLabel(info)) + + # main dynamic layout + self.scroll_area = QtWidgets.QScrollArea(self, widgetResizable=True) + self.scroll_area.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarAsNeeded) + self.scroll_area.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOn) + self.scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOff) + self.scroll_area.setWidgetResizable(True) + + self.content_widget.append(self.scroll_area) + + scroll_widget = QtWidgets.QWidget(self) + in_scroll_area = QtWidgets.QVBoxLayout(scroll_widget) + self.content_layout = [in_scroll_area] + + # add preset data into input widget layout + self.items = self.populate_widgets(ui_inputs) + self.scroll_area.setWidget(scroll_widget) + + # Confirmation buttons + btns_widget = QtWidgets.QWidget(self) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + + cancel_btn = QtWidgets.QPushButton("Cancel") + btns_layout.addWidget(cancel_btn) + + ok_btn = QtWidgets.QPushButton("Ok") + btns_layout.addWidget(ok_btn) + + # Main layout of the dialog + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(0) + + # adding content widget + for w in self.content_widget: + main_layout.addWidget(w) + + main_layout.addWidget(btns_widget) + + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + stylesheet = load_stylesheet() + self.setStyleSheet(stylesheet) + + def _on_ok_clicked(self): + self.result = self.value(self.items) + self.close() + + def _on_cancel_clicked(self): + self.result = None + self.close() + + def value(self, data, new_data=None): + new_data = new_data or dict() + for k, v in data.items(): + new_data[k] = { + "target": None, + "value": None + } + if v["type"] == "dict": + new_data[k]["target"] = v["target"] + new_data[k]["value"] = self.value(v["value"]) + if v["type"] == "section": + new_data.pop(k) + new_data = self.value(v["value"], new_data) + elif getattr(v["value"], "currentText", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].currentText() + elif getattr(v["value"], "isChecked", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].isChecked() + elif getattr(v["value"], "value", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].value() + elif getattr(v["value"], "text", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].text() + + return new_data + + def camel_case_split(self, text): + matches = re.finditer( + '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) + return " ".join([str(m.group(0)).capitalize() for m in matches]) + + def create_row(self, layout, type, text, **kwargs): + # get type attribute from qwidgets + attr = getattr(QtWidgets, type) + + # convert label text to normal capitalized text with spaces + label_text = self.camel_case_split(text) + + # assign the new text to lable widget + label = QtWidgets.QLabel(label_text) + label.setObjectName("LineLabel") + + # create attribute name text strip of spaces + attr_name = text.replace(" ", "") + + # create attribute and assign default values + setattr( + self, + attr_name, + attr(parent=self)) + + # assign the created attribute to variable + item = getattr(self, attr_name) + for func, val in kwargs.items(): + if getattr(item, func): + func_attr = getattr(item, func) + func_attr(val) + + # add to layout + layout.addRow(label, item) + + return item + + def populate_widgets(self, data, content_layout=None): + """ + Populate widget from input dict. + + Each plugin has its own set of widget rows defined in dictionary + each row values should have following keys: `type`, `target`, + `label`, `order`, `value` and optionally also `toolTip`. + + Args: + data (dict): widget rows or organized groups defined + by types `dict` or `section` + content_layout (QtWidgets.QFormLayout)[optional]: used when nesting + + Returns: + dict: redefined data dict updated with created widgets + + """ + + content_layout = content_layout or self.content_layout[-1] + # fix order of process by defined order value + ordered_keys = list(data.keys()) + for k, v in data.items(): + try: + # try removing a key from index which should + # be filled with new + ordered_keys.pop(v["order"]) + except IndexError: + pass + # add key into correct order + ordered_keys.insert(v["order"], k) + + # process ordered + for k in ordered_keys: + v = data[k] + tool_tip = v.get("toolTip", "") + if v["type"] == "dict": + # adding spacer between sections + self.content_layout.append(QtWidgets.QWidget(self)) + content_layout.addWidget(self.content_layout[-1]) + self.content_layout[-1].setObjectName("sectionHeadline") + + headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) + headline.addWidget(Spacer(20, self)) + headline.addWidget(QtWidgets.QLabel(v["label"])) + + # adding nested layout with label + self.content_layout.append(QtWidgets.QWidget(self)) + self.content_layout[-1].setObjectName("sectionContent") + + nested_content_layout = QtWidgets.QFormLayout( + self.content_layout[-1]) + nested_content_layout.setObjectName("NestedContentLayout") + content_layout.addWidget(self.content_layout[-1]) + + # add nested key as label + data[k]["value"] = self.populate_widgets( + v["value"], nested_content_layout) + + if v["type"] == "section": + # adding spacer between sections + self.content_layout.append(QtWidgets.QWidget(self)) + content_layout.addWidget(self.content_layout[-1]) + self.content_layout[-1].setObjectName("sectionHeadline") + + headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) + headline.addWidget(Spacer(20, self)) + headline.addWidget(QtWidgets.QLabel(v["label"])) + + # adding nested layout with label + self.content_layout.append(QtWidgets.QWidget(self)) + self.content_layout[-1].setObjectName("sectionContent") + + nested_content_layout = QtWidgets.QFormLayout( + self.content_layout[-1]) + nested_content_layout.setObjectName("NestedContentLayout") + content_layout.addWidget(self.content_layout[-1]) + + # add nested key as label + data[k]["value"] = self.populate_widgets( + v["value"], nested_content_layout) + + elif v["type"] == "QLineEdit": + data[k]["value"] = self.create_row( + content_layout, "QLineEdit", v["label"], + setText=v["value"], setToolTip=tool_tip) + elif v["type"] == "QComboBox": + data[k]["value"] = self.create_row( + content_layout, "QComboBox", v["label"], + addItems=v["value"], setToolTip=tool_tip) + elif v["type"] == "QCheckBox": + data[k]["value"] = self.create_row( + content_layout, "QCheckBox", v["label"], + setChecked=v["value"], setToolTip=tool_tip) + elif v["type"] == "QSpinBox": + data[k]["value"] = self.create_row( + content_layout, "QSpinBox", v["label"], + setValue=v["value"], setMinimum=0, + setMaximum=100000, setToolTip=tool_tip) + return data + + +class Spacer(QtWidgets.QWidget): + def __init__(self, height, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + self.setFixedHeight(height) + + real_spacer = QtWidgets.QWidget(self) + real_spacer.setObjectName("Spacer") + real_spacer.setFixedHeight(height) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(real_spacer) + + self.setLayout(layout) + + +class Creator(openpype.Creator): + """Creator class wrapper + """ + clip_color = lib.ctx.color_map["purple"] + rename_index = None + + def __init__(self, *args, **kwargs): + super(Creator, self).__init__(*args, **kwargs) + self.presets = openpype.get_current_project_settings()[ + "flame"]["create"].get(self.__class__.__name__, {}) + + # adding basic current context flame objects + self.project = lib.get_current_project() + self.sequence = lib.get_current_sequence(opflame.selection) + + if (self.options or {}).get("useSelection"): + self.selected = lib.get_sequence_segments(self.sequence, True) + else: + self.selected = lib.get_sequence_segments(self.sequence) + + self.widget = CreatorWidget + + +class PublishableClip: + """ + Convert a segment to publishable instance + + Args: + segment (flame.PySegment): flame api object + kwargs (optional): additional data needed for rename=True (presets) + + Returns: + flame.PySegment: flame api object + """ + vertical_clip_match = dict() + tag_data = dict() + types = { + "shot": "shot", + "folder": "folder", + "episode": "episode", + "sequence": "sequence", + "track": "sequence", + } + + # parents search patern + parents_search_patern = r"\{([a-z]*?)\}" + + # default templates for non-ui use + rename_default = False + hierarchy_default = "{_folder_}/{_sequence_}/{_track_}" + clip_name_default = "shot_{_trackIndex_:0>3}_{_clipIndex_:0>4}" + subset_name_default = "[ track name ]" + review_track_default = "[ none ]" + subset_family_default = "plate" + count_from_default = 10 + count_steps_default = 10 + vertical_sync_default = False + driving_layer_default = "" + + def __init__(self, cls, segment, **kwargs): + # populate input cls attribute onto self.[attr] + self.__dict__.update(cls.__dict__) + + # get main parent objects + self.current_segment = segment + sequence_name = lib.get_current_sequence([segment]).name.get_value() + self.sequence_name = str(sequence_name).replace(" ", "_") + + self.clip_data = lib.get_segment_attributes(segment) + # segment (clip) main attributes + self.cs_name = self.clip_data["segment_name"] + self.cs_index = int(self.clip_data["segment"]) + + # get track name and index + self.track_index = int(self.clip_data["track"]) + track_name = self.clip_data["track_name"] + self.track_name = str(track_name).replace(" ", "_").replace( + "*", "noname{}".format(self.track_index)) + + # adding tag.family into tag + if kwargs.get("avalon"): + self.tag_data.update(kwargs["avalon"]) + + # add publish attribute to marker data + self.tag_data.update({"publish": True}) + + # adding ui inputs if any + self.ui_inputs = kwargs.get("ui_inputs", {}) + + # populate default data before we get other attributes + self._populate_segment_default_data() + + # use all populated default data to create all important attributes + self._populate_attributes() + + # create parents with correct types + self._create_parents() + + def convert(self): + + # solve segment data and add them to marker data + self._convert_to_marker_data() + + # if track name is in review track name and also if driving track name + # is not in review track name: skip tag creation + if (self.track_name in self.review_layer) and ( + self.driving_layer not in self.review_layer): + return + + # deal with clip name + new_name = self.tag_data.pop("newClipName") + + if self.rename: + # rename segment + self.current_segment.setName(new_name) + self.tag_data["asset"] = new_name + else: + self.tag_data["asset"] = self.cs_name + self.tag_data["hierarchyData"]["shot"] = self.cs_name + + if self.tag_data["heroTrack"] and self.review_layer: + self.tag_data.update({"reviewTrack": self.review_layer}) + else: + self.tag_data.update({"reviewTrack": None}) + + # create pype tag on track_item and add data + lib.imprint(self.current_segment, self.tag_data) + + return self.current_segment + + def _populate_segment_default_data(self): + """ Populate default formating data from segment. """ + + self.current_segment_default_data = { + "_folder_": "shots", + "_sequence_": self.sequence_name, + "_track_": self.track_name, + "_clip_": self.cs_name, + "_trackIndex_": self.track_index, + "_clipIndex_": self.cs_index + } + + def _populate_attributes(self): + """ Populate main object attributes. """ + # segment frame range and parent track name for vertical sync check + self.clip_in = int(self.clip_data["record_in"]) + self.clip_out = int(self.clip_data["record_out"]) + + # define ui inputs if non gui mode was used + self.shot_num = self.cs_index + log.debug( + "____ self.shot_num: {}".format(self.shot_num)) + + # ui_inputs data or default values if gui was not used + self.rename = self.ui_inputs.get( + "clipRename", {}).get("value") or self.rename_default + self.clip_name = self.ui_inputs.get( + "clipName", {}).get("value") or self.clip_name_default + self.hierarchy = self.ui_inputs.get( + "hierarchy", {}).get("value") or self.hierarchy_default + self.hierarchy_data = self.ui_inputs.get( + "hierarchyData", {}).get("value") or \ + self.current_segment_default_data.copy() + self.count_from = self.ui_inputs.get( + "countFrom", {}).get("value") or self.count_from_default + self.count_steps = self.ui_inputs.get( + "countSteps", {}).get("value") or self.count_steps_default + self.subset_name = self.ui_inputs.get( + "subsetName", {}).get("value") or self.subset_name_default + self.subset_family = self.ui_inputs.get( + "subsetFamily", {}).get("value") or self.subset_family_default + self.vertical_sync = self.ui_inputs.get( + "vSyncOn", {}).get("value") or self.vertical_sync_default + self.driving_layer = self.ui_inputs.get( + "vSyncTrack", {}).get("value") or self.driving_layer_default + self.review_track = self.ui_inputs.get( + "reviewTrack", {}).get("value") or self.review_track_default + self.audio = self.ui_inputs.get( + "audio", {}).get("value") or False + + # build subset name from layer name + if self.subset_name == "[ track name ]": + self.subset_name = self.track_name + + # create subset for publishing + self.subset = self.subset_family + self.subset_name.capitalize() + + def _replace_hash_to_expression(self, name, text): + """ Replace hash with number in correct padding. """ + _spl = text.split("#") + _len = (len(_spl) - 1) + _repl = "{{{0}:0>{1}}}".format(name, _len) + return text.replace(("#" * _len), _repl) + + + def _convert_to_marker_data(self): + """ Convert internal data to marker data. + + Populating the marker data into internal variable self.tag_data + """ + # define vertical sync attributes + hero_track = True + self.review_layer = "" + if self.vertical_sync and self.track_name not in self.driving_layer: + # if it is not then define vertical sync as None + hero_track = False + + # increasing steps by index of rename iteration + self.count_steps *= self.rename_index + + hierarchy_formating_data = {} + hierarchy_data = deepcopy(self.hierarchy_data) + _data = self.current_segment_default_data.copy() + if self.ui_inputs: + # adding tag metadata from ui + for _k, _v in self.ui_inputs.items(): + if _v["target"] == "tag": + self.tag_data[_k] = _v["value"] + + # driving layer is set as positive match + if hero_track or self.vertical_sync: + # mark review layer + if self.review_track and ( + self.review_track not in self.review_track_default): + # if review layer is defined and not the same as defalut + self.review_layer = self.review_track + # shot num calculate + if self.rename_index == 0: + self.shot_num = self.count_from + else: + self.shot_num = self.count_from + self.count_steps + + # clip name sequence number + _data.update({"shot": self.shot_num}) + + # solve # in test to pythonic expression + for _k, _v in hierarchy_data.items(): + if "#" not in _v["value"]: + continue + hierarchy_data[ + _k]["value"] = self._replace_hash_to_expression( + _k, _v["value"]) + + # fill up pythonic expresisons in hierarchy data + for k, _v in hierarchy_data.items(): + hierarchy_formating_data[k] = _v["value"].format(**_data) + else: + # if no gui mode then just pass default data + hierarchy_formating_data = hierarchy_data + + tag_hierarchy_data = self._solve_tag_hierarchy_data( + hierarchy_formating_data + ) + + tag_hierarchy_data.update({"heroTrack": True}) + if hero_track and self.vertical_sync: + self.vertical_clip_match.update({ + (self.clip_in, self.clip_out): tag_hierarchy_data + }) + + if not hero_track and self.vertical_sync: + # driving layer is set as negative match + for (_in, _out), hero_data in self.vertical_clip_match.items(): + hero_data.update({"heroTrack": False}) + if _in == self.clip_in and _out == self.clip_out: + data_subset = hero_data["subset"] + # add track index in case duplicity of names in hero data + if self.subset in data_subset: + hero_data["subset"] = self.subset + str( + self.track_index) + # in case track name and subset name is the same then add + if self.subset_name == self.track_name: + hero_data["subset"] = self.subset + # assing data to return hierarchy data to tag + tag_hierarchy_data = hero_data + + # add data to return data dict + self.tag_data.update(tag_hierarchy_data) + + def _solve_tag_hierarchy_data(self, hierarchy_formating_data): + """ Solve marker data from hierarchy data and templates. """ + # fill up clip name and hierarchy keys + hierarchy_filled = self.hierarchy.format(**hierarchy_formating_data) + clip_name_filled = self.clip_name.format(**hierarchy_formating_data) + + # remove shot from hierarchy data: is not needed anymore + hierarchy_formating_data.pop("shot") + + return { + "newClipName": clip_name_filled, + "hierarchy": hierarchy_filled, + "parents": self.parents, + "hierarchyData": hierarchy_formating_data, + "subset": self.subset, + "family": self.subset_family, + "families": [self.data["family"]] + } + + def _convert_to_entity(self, type, template): + """ Converting input key to key with type. """ + # convert to entity type + entity_type = self.types.get(type, None) + + assert entity_type, "Missing entity type for `{}`".format( + type + ) + + # first collect formating data to use for formating template + formating_data = {} + for _k, _v in self.hierarchy_data.items(): + value = _v["value"].format( + **self.current_segment_default_data) + formating_data[_k] = value + + return { + "entity_type": entity_type, + "entity_name": template.format( + **formating_data + ) + } + + def _create_parents(self): + """ Create parents and return it in list. """ + self.parents = [] + + patern = re.compile(self.parents_search_patern) + + par_split = [(patern.findall(t).pop(), t) + for t in self.hierarchy.split("/")] + + for type, template in par_split: + parent = self._convert_to_entity(type, template) + self.parents.append(parent) + + # Publishing plugin functions # Loader plugin functions diff --git a/openpype/hosts/flame/api/style.css b/openpype/hosts/flame/api/style.css new file mode 100644 index 0000000000..b64c391c6e --- /dev/null +++ b/openpype/hosts/flame/api/style.css @@ -0,0 +1,26 @@ +QWidget { + font-size: 13px; +} + +QSpinBox { + padding: 2; + max-width: 8em; +} + +QLineEdit { + padding: 2; + min-width: 15em; +} + +QVBoxLayout { + min-width: 15em; + background-color: #201f1f; +} + +QComboBox { + min-width: 8em; +} + +#sectionContent { + background-color: #2E2D2D; +} \ No newline at end of file From 25e0bffe5854cbfbcfd43d8e0001c8d4471e2ee3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 14:04:01 +0100 Subject: [PATCH 022/229] flame: adding create plugin for publishable clips --- .../flame/plugins/create/create_shot_clip.py | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 openpype/hosts/flame/plugins/create/create_shot_clip.py diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py new file mode 100644 index 0000000000..71ea9b3c86 --- /dev/null +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -0,0 +1,268 @@ +from copy import deepcopy +import openpype.hosts.flame as opflame +import openpype.hosts.flame.api.plugin as fplugin +import openpype.hosts.flame.api.lib as flib +reload(fplugin) +reload(flib) + +def _get_video_track_names(sequence): + track_names = [] + for ver in sequence.versions: + for track in ver.tracks: + track_names.append(track.name.get_value()) + +class CreateShotClip(fplugin.Creator): + """Publishable clip""" + + label = "Create Publishable Clip" + family = "clip" + icon = "film" + defaults = ["Main"] + + gui_tracks = _get_video_track_names( + flib.get_current_sequence(opflame.selection) + ) + gui_name = "Pype publish attributes creator" + gui_info = "Define sequential rename and fill hierarchy data." + gui_inputs = { + "renameHierarchy": { + "type": "section", + "label": "Shot Hierarchy And Rename Settings", + "target": "ui", + "order": 0, + "value": { + "hierarchy": { + "value": "{folder}/{sequence}", + "type": "QLineEdit", + "label": "Shot Parent Hierarchy", + "target": "tag", + "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa + "order": 0}, + "clipRename": { + "value": False, + "type": "QCheckBox", + "label": "Rename clips", + "target": "ui", + "toolTip": "Renaming selected clips on fly", # noqa + "order": 1}, + "clipName": { + "value": "{sequence}{shot}", + "type": "QLineEdit", + "label": "Clip Name Template", + "target": "ui", + "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa + "order": 2}, + "countFrom": { + "value": 10, + "type": "QSpinBox", + "label": "Count sequence from", + "target": "ui", + "toolTip": "Set when the sequence number stafrom", # noqa + "order": 3}, + "countSteps": { + "value": 10, + "type": "QSpinBox", + "label": "Stepping number", + "target": "ui", + "toolTip": "What number is adding every new step", # noqa + "order": 4}, + } + }, + "hierarchyData": { + "type": "dict", + "label": "Shot Template Keywords", + "target": "tag", + "order": 1, + "value": { + "folder": { + "value": "shots", + "type": "QLineEdit", + "label": "{folder}", + "target": "tag", + "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 0}, + "episode": { + "value": "ep01", + "type": "QLineEdit", + "label": "{episode}", + "target": "tag", + "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 1}, + "sequence": { + "value": "sq01", + "type": "QLineEdit", + "label": "{sequence}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 2}, + "track": { + "value": "{_track_}", + "type": "QLineEdit", + "label": "{track}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 3}, + "shot": { + "value": "sh###", + "type": "QLineEdit", + "label": "{shot}", + "target": "tag", + "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 4} + } + }, + "verticalSync": { + "type": "section", + "label": "Vertical Synchronization Of Attributes", + "target": "ui", + "order": 2, + "value": { + "vSyncOn": { + "value": True, + "type": "QCheckBox", + "label": "Enable Vertical Sync", + "target": "ui", + "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa + "order": 0}, + "vSyncTrack": { + "value": gui_tracks, # noqa + "type": "QComboBox", + "label": "Hero track", + "target": "ui", + "toolTip": "Select driving track name which should be hero for all others", # noqa + "order": 1} + } + }, + "publishSettings": { + "type": "section", + "label": "Publish Settings", + "target": "ui", + "order": 3, + "value": { + "subsetName": { + "value": ["", "main", "bg", "fg", "bg", + "animatic"], + "type": "QComboBox", + "label": "Subset Name", + "target": "ui", + "toolTip": "chose subset name patern, if is selected, name of track layer will be used", # noqa + "order": 0}, + "subsetFamily": { + "value": ["plate", "take"], + "type": "QComboBox", + "label": "Subset Family", + "target": "ui", "toolTip": "What use of this subset is for", # noqa + "order": 1}, + "reviewTrack": { + "value": ["< none >"] + gui_tracks, + "type": "QComboBox", + "label": "Use Review Track", + "target": "ui", + "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa + "order": 2}, + "audio": { + "value": False, + "type": "QCheckBox", + "label": "Include audio", + "target": "tag", + "toolTip": "Process subsets with corresponding audio", # noqa + "order": 3}, + "sourceResolution": { + "value": False, + "type": "QCheckBox", + "label": "Source resolution", + "target": "tag", + "toolTip": "Is resloution taken from timeline or source?", # noqa + "order": 4}, + } + }, + "frameRangeAttr": { + "type": "section", + "label": "Shot Attributes", + "target": "ui", + "order": 4, + "value": { + "workfileFrameStart": { + "value": 1001, + "type": "QSpinBox", + "label": "Workfiles Start Frame", + "target": "tag", + "toolTip": "Set workfile starting frame number", # noqa + "order": 0 + }, + "handleStart": { + "value": 0, + "type": "QSpinBox", + "label": "Handle Start", + "target": "tag", + "toolTip": "Handle at start of clip", # noqa + "order": 1 + }, + "handleEnd": { + "value": 0, + "type": "QSpinBox", + "label": "Handle End", + "target": "tag", + "toolTip": "Handle at end of clip", # noqa + "order": 2 + } + } + } + } + + presets = None + + def process(self): + # Creator copy of object attributes that are modified during `process` + presets = deepcopy(self.presets) + gui_inputs = deepcopy(self.gui_inputs) + + # get key pares from presets and match it on ui inputs + for k, v in gui_inputs.items(): + if v["type"] in ("dict", "section"): + # nested dictionary (only one level allowed + # for sections and dict) + for _k, _v in v["value"].items(): + if presets.get(_k): + gui_inputs[k][ + "value"][_k]["value"] = presets[_k] + if presets.get(k): + gui_inputs[k]["value"] = presets[k] + + # open widget for plugins inputs + widget = self.widget(self.gui_name, self.gui_info, gui_inputs) + widget.exec_() + + if len(self.selected) < 1: + return + + if not widget.result: + print("Operation aborted") + return + + self.rename_add = 0 + + # get ui output for track name for vertical sync + v_sync_track = widget.result["vSyncTrack"]["value"] + + # sort selected trackItems by + sorted_selected_segments = [] + unsorted_selected_segments = [] + for _segment in self.selected: + if _segment.parent.name.get_value() in v_sync_track: + sorted_selected_segments.append(_segment) + else: + unsorted_selected_segments.append(_segment) + + sorted_selected_segments.extend(unsorted_selected_segments) + + kwargs = { + "ui_inputs": widget.result, + "avalon": self.data + } + + for i, segment in enumerate(sorted_selected_segments): + self.rename_index = i + + # convert track item to timeline media pool item + fplugin.PublishableClip(self, segment, **kwargs).convert() From e2ab00c54a91ffbf3d07c37b26d8a9f06e487f58 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 14:17:32 +0100 Subject: [PATCH 023/229] flame: tuning creator --- openpype/hosts/flame/api/plugin.py | 35 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 1a3880a19a..34e626b099 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -3,7 +3,7 @@ import os from Qt import QtWidgets, QtCore import openpype.api as openpype import openpype.hosts.flame as opflame -from . import lib +from . import lib, pipeline from copy import deepcopy log = openpype.Logger().get_logger(__name__) @@ -321,8 +321,8 @@ class PublishableClip: Returns: flame.PySegment: flame api object """ - vertical_clip_match = dict() - tag_data = dict() + vertical_clip_match = {} + marker_data = {} types = { "shot": "shot", "folder": "folder", @@ -368,10 +368,10 @@ class PublishableClip: # adding tag.family into tag if kwargs.get("avalon"): - self.tag_data.update(kwargs["avalon"]) + self.marker_data.update(kwargs["avalon"]) # add publish attribute to marker data - self.tag_data.update({"publish": True}) + self.marker_data.update({"publish": True}) # adding ui inputs if any self.ui_inputs = kwargs.get("ui_inputs", {}) @@ -397,23 +397,23 @@ class PublishableClip: return # deal with clip name - new_name = self.tag_data.pop("newClipName") + new_name = self.marker_data.pop("newClipName") if self.rename: # rename segment - self.current_segment.setName(new_name) - self.tag_data["asset"] = new_name + self.current_segment.name = new_name + self.marker_data["asset"] = new_name else: - self.tag_data["asset"] = self.cs_name - self.tag_data["hierarchyData"]["shot"] = self.cs_name + self.marker_data["asset"] = self.cs_name + self.marker_data["hierarchyData"]["shot"] = self.cs_name - if self.tag_data["heroTrack"] and self.review_layer: - self.tag_data.update({"reviewTrack": self.review_layer}) + if self.marker_data["heroTrack"] and self.review_layer: + self.marker_data.update({"reviewTrack": self.review_layer}) else: - self.tag_data.update({"reviewTrack": None}) + self.marker_data.update({"reviewTrack": None}) # create pype tag on track_item and add data - lib.imprint(self.current_segment, self.tag_data) + pipeline.imprint(self.current_segment, self.marker_data) return self.current_segment @@ -481,11 +481,10 @@ class PublishableClip: _repl = "{{{0}:0>{1}}}".format(name, _len) return text.replace(("#" * _len), _repl) - def _convert_to_marker_data(self): """ Convert internal data to marker data. - Populating the marker data into internal variable self.tag_data + Populating the marker data into internal variable self.marker_data """ # define vertical sync attributes hero_track = True @@ -504,7 +503,7 @@ class PublishableClip: # adding tag metadata from ui for _k, _v in self.ui_inputs.items(): if _v["target"] == "tag": - self.tag_data[_k] = _v["value"] + self.marker_data[_k] = _v["value"] # driving layer is set as positive match if hero_track or self.vertical_sync: @@ -564,7 +563,7 @@ class PublishableClip: tag_hierarchy_data = hero_data # add data to return data dict - self.tag_data.update(tag_hierarchy_data) + self.marker_data.update(tag_hierarchy_data) def _solve_tag_hierarchy_data(self, hierarchy_formating_data): """ Solve marker data from hierarchy data and templates. """ From a2bb8f1839ca253daa962a876719e94262115f6e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 14:59:59 +0100 Subject: [PATCH 024/229] flame: beautification of code --- openpype/hosts/flame/api/pipeline.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 2295589627..99a33e4cb9 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -22,7 +22,6 @@ def install(): CREATE_PATH, INVENTORY_PATH ) - # TODO: install # Disable all families except for the ones we explicitly want to see family_states = [ @@ -36,19 +35,18 @@ def install(): avalon.data["familiesStateDefault"] = False avalon.data["familiesStateToggled"] = family_states - log.info("openpype.hosts.flame installed") pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) - log.info("Registering Flame plug-ins..") - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) avalon.register_plugin_path(avalon.Creator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + log.info("OpenPype Flame plug-ins registred ...") # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + log.info("OpenPype Flame host installed ...") def uninstall(): from .. import ( @@ -58,11 +56,10 @@ def uninstall(): INVENTORY_PATH ) - # TODO: uninstall pyblish.deregister_host("flame") - pyblish.deregister_plugin_path(PUBLISH_PATH) - log.info("Deregistering DaVinci Resovle plug-ins..") + log.info("Deregistering Flame plug-ins..") + pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) @@ -70,6 +67,8 @@ def uninstall(): # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + log.info("OpenPype Flame host uninstalled ...") + def containerise(tl_segment, name, From 2f0fcaebe150b307e598b070bd996568eec1c2ac Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:00:15 +0100 Subject: [PATCH 025/229] flame: fix missing return --- openpype/hosts/flame/plugins/create/create_shot_clip.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 71ea9b3c86..79afca507f 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -11,6 +11,8 @@ def _get_video_track_names(sequence): for track in ver.tracks: track_names.append(track.name.get_value()) + return track_names + class CreateShotClip(fplugin.Creator): """Publishable clip""" From 5c11089aff32e09c17439743290b50a4097c8714 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:00:37 +0100 Subject: [PATCH 026/229] flame: testing plugin refactory --- .../plugins/publish/collect_test_selection.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index d30d6ed331..cd7355d1f5 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -1,9 +1,11 @@ +import os import pyblish.api import openpype.hosts.flame as opflame from openpype.hosts.flame.otio import flame_export as otio_export -from openpype.hosts.flame.api import lib +from openpype.hosts.flame.api import lib, pipeline from pprint import pformat reload(lib) # noqa +reload(pipeline) # noqa reload(otio_export) # noqa @@ -17,14 +19,30 @@ class CollectTestSelection(pyblish.api.ContextPlugin): hosts = ["flame"] def process(self, context): - self.log.info(opflame.selection) + self.log.info( + "Active Selection: {}".format(opflame.selection)) sequence = lib.get_current_sequence(opflame.selection) + self.test_imprint_data(sequence) + self.test_otio_export(sequence) + + def test_otio_export(self, sequence): + home_dir = os.path.expanduser("~") + export_path = os.path.normalize( + os.path.join( + home_dir, "otio_timeline_export.otio" + ) + ) otio_timeline = otio_export.create_otio_timeline(sequence) + otio_export.write_to_file( + otio_timeline, export_path + ) self.log.info(pformat(otio_timeline)) + self.log.info("Otio exported to: {}".format(export_path)) + def test_imprint_data(self, sequence): # test segment markers for ver in sequence.versions: for track in ver.tracks: @@ -40,8 +58,8 @@ class CollectTestSelection(pyblish.api.ContextPlugin): self.log.debug("Segment with OpenPypeData: {}".format( segment.name)) - lib.imprint(segment, { - 'asset': 'sq020sh0280', + pipeline.imprint(segment, { + 'asset': segment.name.get_value(), 'family': 'render', 'subset': 'subsetMain' }) From c67e672acaaa0ed5a459ef0e52a1cd81b6ee049d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:08:06 +0100 Subject: [PATCH 027/229] flame: improving testing plugin adding maintained seqment selection functionality --- openpype/hosts/flame/api/lib.py | 2 +- .../plugins/publish/collect_test_selection.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 2d30390d21..e5642dd6f9 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -14,7 +14,7 @@ class ctx: # OpenPype marker workflow variables marker_name = "OpenPypeData" marker_duration = 0 - marker_color = "red" + marker_color = "cyan" publish_default = False color_map = { "red": (1.0, 0.0, 0.0), diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index cd7355d1f5..875ef34a07 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -43,23 +43,16 @@ class CollectTestSelection(pyblish.api.ContextPlugin): self.log.info("Otio exported to: {}".format(export_path)) def test_imprint_data(self, sequence): - # test segment markers - for ver in sequence.versions: - for track in ver.tracks: - if len(track.segments) == 0 and track.hidden: + with lib.maintained_segment_selection(sequence) as selected_segments: + for segment in selected_segments: + if str(segment.name)[1:-1] == "": continue - for segment in track.segments: - if str(segment.name)[1:-1] == "": - continue - if not segment.selected: - continue + self.log.debug("Segment with OpenPypeData: {}".format( + segment.name)) - self.log.debug("Segment with OpenPypeData: {}".format( - segment.name)) - - pipeline.imprint(segment, { - 'asset': segment.name.get_value(), - 'family': 'render', - 'subset': 'subsetMain' - }) + pipeline.imprint(segment, { + 'asset': segment.name.get_value(), + 'family': 'render', + 'subset': 'subsetMain' + }) From 12f9eb2c328259c105cfe036cee08a14016be0a5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:09:41 +0100 Subject: [PATCH 028/229] flame: fix normalize to normpath --- openpype/hosts/flame/plugins/publish/collect_test_selection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 875ef34a07..29ca08d9b5 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -29,7 +29,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): def test_otio_export(self, sequence): home_dir = os.path.expanduser("~") - export_path = os.path.normalize( + export_path = os.path.normpath( os.path.join( home_dir, "otio_timeline_export.otio" ) From 02c41986250d1cc05dada82e4e32afc3e238536d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:20:58 +0100 Subject: [PATCH 029/229] flame: settings for creator plugin --- .../defaults/project_settings/flame.json | 20 +++ .../schemas/projects_schema/schema_main.json | 4 + .../projects_schema/schema_project_flame.json | 124 ++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 openpype/settings/defaults/project_settings/flame.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_flame.json diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json new file mode 100644 index 0000000000..b6fbdecc95 --- /dev/null +++ b/openpype/settings/defaults/project_settings/flame.json @@ -0,0 +1,20 @@ +{ + "create": { + "CreateShotClip": { + "hierarchy": "{folder}/{sequence}", + "clipRename": true, + "clipName": "{track}{sequence}{shot}", + "countFrom": 10, + "countSteps": 10, + "folder": "shots", + "episode": "ep01", + "sequence": "sq01", + "track": "{_track_}", + "shot": "sh###", + "vSyncOn": false, + "workfileFrameStart": 1001, + "handleStart": 10, + "handleEnd": 10 + } + } +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index c9eca5dedd..8a2ad451ee 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -110,6 +110,10 @@ "type": "schema", "name": "schema_project_celaction" }, + { + "type": "schema", + "name": "schema_project_flame" + }, { "type": "schema", "name": "schema_project_resolve" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json new file mode 100644 index 0000000000..d713c37620 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -0,0 +1,124 @@ +{ + "type": "dict", + "collapsible": true, + "key": "flame", + "label": "Flame", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Create plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateShotClip", + "label": "Create Shot Clip", + "is_group": true, + "children": [ + { + "type": "collapsible-wrap", + "label": "Shot Hierarchy And Rename Settings", + "collapsible": false, + "children": [ + { + "type": "text", + "key": "hierarchy", + "label": "Shot parent hierarchy" + }, + { + "type": "boolean", + "key": "clipRename", + "label": "Rename clips" + }, + { + "type": "text", + "key": "clipName", + "label": "Clip name template" + }, + { + "type": "number", + "key": "countFrom", + "label": "Count sequence from" + }, + { + "type": "number", + "key": "countSteps", + "label": "Stepping number" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Shot Template Keywords", + "collapsible": false, + "children": [ + { + "type": "text", + "key": "folder", + "label": "{folder}" + }, + { + "type": "text", + "key": "episode", + "label": "{episode}" + }, + { + "type": "text", + "key": "sequence", + "label": "{sequence}" + }, + { + "type": "text", + "key": "track", + "label": "{track}" + }, + { + "type": "text", + "key": "shot", + "label": "{shot}" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Vertical Synchronization Of Attributes", + "collapsible": false, + "children": [ + { + "type": "boolean", + "key": "vSyncOn", + "label": "Enable Vertical Sync" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Shot Attributes", + "collapsible": false, + "children": [ + { + "type": "number", + "key": "workfileFrameStart", + "label": "Workfiles Start Frame" + }, + { + "type": "number", + "key": "handleStart", + "label": "Handle start (head)" + }, + { + "type": "number", + "key": "handleEnd", + "label": "Handle end (tail)" + } + ] + } + ] + } + ] + } + ] +} From 3aa0efdc4dacee60e7995a4d32bfd675c0ae0afd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:29:59 +0100 Subject: [PATCH 030/229] flame: do not alter project attributes if it exists already --- openpype/hosts/flame/api/scripts/wiretap_com.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index 5f7b2580e6..f1b5ab2236 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -89,9 +89,11 @@ class WireTapCom(object): workspace_name = kwargs.get("workspace_name") color_policy = kwargs.get("color_policy") - self._project_prep(project_name) - self._set_project_settings(project_name, project_data) - self._set_project_colorspace(project_name, color_policy) + project_exists = self._project_prep(project_name) + if not project_exists: + self._set_project_settings(project_name, project_data) + self._set_project_colorspace(project_name, color_policy) + user_name = self._user_prep(user_name) if workspace_name is None: @@ -207,6 +209,7 @@ class WireTapCom(object): print( "A new project '{}' is created.".format(project_name)) + return project_exists def _get_all_volumes(self): """Request all available volumens from WireTap From f6b9d122442d8cd95b4b9307a7d2cad4466fdddd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 15:57:45 +0100 Subject: [PATCH 031/229] flame: host maintained selection --- openpype/hosts/flame/__init__.py | 4 +++- openpype/hosts/flame/api/pipeline.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index 691b6f8119..da42b313aa 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -10,7 +10,8 @@ from .api.pipeline import ( update_container, remove_instance, list_instances, - imprint + imprint, + maintained_selection ) from .api.lib import ( @@ -87,6 +88,7 @@ __all__ = [ "remove_instance", "list_instances", "imprint", + "maintained_selection", # utils "setup", diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 99a33e4cb9..ee0e12584a 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -7,7 +7,9 @@ from pyblish import api as pyblish from openpype.api import Logger from .lib import ( set_segment_data_marker, - set_publish_attribute + set_publish_attribute, + maintained_segment_selection, + get_current_sequence ) AVALON_CONTAINERS = "AVALON_CONTAINERS" @@ -151,3 +153,17 @@ def imprint(segment, data=None): # add publish attribute set_publish_attribute(segment, True) + +@contextlib.contextmanager +def maintained_selection(): + import flame + from .. import selection + + # check if segment is selected + if isinstance(selection[0], flame.PySegment): + sequence = get_current_sequence(selection) + try: + with maintained_segment_selection(sequence): + yield + finally: + pass \ No newline at end of file From 613e9ff2fb123b4e8606143a9a0af744cc384026 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:02:30 +0100 Subject: [PATCH 032/229] renamed get_pype_execute_args to get_openpype_execute_args --- openpype/lib/__init__.py | 2 ++ openpype/lib/execute.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 34926453cb..f721e0f577 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -24,6 +24,7 @@ from .env_tools import ( from .terminal import Terminal from .execute import ( + get_openpype_execute_args, get_pype_execute_args, get_linux_launcher_args, execute, @@ -173,6 +174,7 @@ from .pype_info import ( terminal = Terminal __all__ = [ + "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", "execute", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index f97617d906..452a8fd4c0 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -147,6 +147,11 @@ def path_to_subprocess_arg(path): def get_pype_execute_args(*args): + """Backwards compatible function for 'get_openpype_execute_args'.""" + return get_openpype_execute_args(*args) + + +def get_openpype_execute_args(*args): """Arguments to run pype command. Arguments for subprocess when need to spawn new pype process. Which may be From b3082d9e211aa2245262654d52eb0f89758db04a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 16:08:57 +0100 Subject: [PATCH 033/229] flame: track name preset fix --- openpype/hosts/flame/plugins/create/create_shot_clip.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 79afca507f..45c4557dad 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -142,12 +142,12 @@ class CreateShotClip(fplugin.Creator): "order": 3, "value": { "subsetName": { - "value": ["", "main", "bg", "fg", "bg", + "value": ["[ track name ]", "main", "bg", "fg", "bg", "animatic"], "type": "QComboBox", "label": "Subset Name", "target": "ui", - "toolTip": "chose subset name patern, if is selected, name of track layer will be used", # noqa + "toolTip": "chose subset name patern, if [ track name ] is selected, name of track layer will be used", # noqa "order": 0}, "subsetFamily": { "value": ["plate", "take"], From f48b864e2359398c960debd2ab0a9668792ca00c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 16:12:38 +0100 Subject: [PATCH 034/229] Flame: fixing wrong type --- openpype/hosts/flame/api/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 34e626b099..5e47ce3a68 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -401,8 +401,8 @@ class PublishableClip: if self.rename: # rename segment - self.current_segment.name = new_name - self.marker_data["asset"] = new_name + self.current_segment.name = str(new_name) + self.marker_data["asset"] = str(new_name) else: self.marker_data["asset"] = self.cs_name self.marker_data["hierarchyData"]["shot"] = self.cs_name From 3c1967f080504e1be3916d1deeb2241c234091e8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:13:30 +0100 Subject: [PATCH 035/229] use get_openpype_execute_args instead of get_pype_execute_args --- openpype/hooks/pre_non_python_host_launch.py | 5 ++--- openpype/hosts/tvpaint/hooks/pre_launch_args.py | 4 ++-- openpype/lib/pype_info.py | 4 ++-- .../ftrack/ftrack_server/event_server_cli.py | 6 +++--- .../default_modules/ftrack/ftrack_server/socket_thread.py | 4 ++-- openpype/modules/standalonepublish_action.py | 4 ++-- .../tools/standalonepublish/widgets/widget_components.py | 4 ++-- openpype/tools/tray/pype_tray.py | 6 +++--- 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index 848ed675a8..29e40d28c8 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -3,7 +3,7 @@ import subprocess from openpype.lib import ( PreLaunchHook, - get_pype_execute_args + get_openpype_execute_args ) from openpype import PACKAGE_DIR as OPENPYPE_DIR @@ -35,7 +35,7 @@ class NonPythonHostHook(PreLaunchHook): "non_python_host_launch.py" ) - new_launch_args = get_pype_execute_args( + new_launch_args = get_openpype_execute_args( "run", script_path, executable_path ) # Add workfile path if exists @@ -48,4 +48,3 @@ class NonPythonHostHook(PreLaunchHook): if remainders: self.launch_context.launch_args.extend(remainders) - diff --git a/openpype/hosts/tvpaint/hooks/pre_launch_args.py b/openpype/hosts/tvpaint/hooks/pre_launch_args.py index 62fd662d79..2a8f49d5b0 100644 --- a/openpype/hosts/tvpaint/hooks/pre_launch_args.py +++ b/openpype/hosts/tvpaint/hooks/pre_launch_args.py @@ -4,7 +4,7 @@ import shutil from openpype.hosts import tvpaint from openpype.lib import ( PreLaunchHook, - get_pype_execute_args + get_openpype_execute_args ) import avalon @@ -30,7 +30,7 @@ class TvpaintPrelaunchHook(PreLaunchHook): while self.launch_context.launch_args: remainders.append(self.launch_context.launch_args.pop(0)) - new_launch_args = get_pype_execute_args( + new_launch_args = get_openpype_execute_args( "run", self.launch_script_path(), executable_path ) diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index 33715e369d..15856bfb19 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -7,7 +7,7 @@ import socket import openpype.version from openpype.settings.lib import get_local_settings -from .execute import get_pype_execute_args +from .execute import get_openpype_execute_args from .local_settings import get_local_site_id from .python_module_tools import import_filepath @@ -71,7 +71,7 @@ def is_running_staging(): def get_pype_info(): """Information about currently used Pype process.""" - executable_args = get_pype_execute_args() + executable_args = get_openpype_execute_args() if is_running_from_build(): version_type = "build" else: diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py index 1a76905b38..90ce757242 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py @@ -14,7 +14,7 @@ import uuid import ftrack_api import pymongo from openpype.lib import ( - get_pype_execute_args, + get_openpype_execute_args, OpenPypeMongoConnection, get_openpype_version, get_build_version, @@ -136,7 +136,7 @@ def legacy_server(ftrack_url): if subproc is None: if subproc_failed_count < max_fail_count: - args = get_pype_execute_args("run", subproc_path) + args = get_openpype_execute_args("run", subproc_path) subproc = subprocess.Popen( args, stdout=subprocess.PIPE @@ -248,7 +248,7 @@ def main_loop(ftrack_url): ["Username", getpass.getuser()], ["Host Name", host_name], ["Host IP", socket.gethostbyname(host_name)], - ["OpenPype executable", get_pype_execute_args()[-1]], + ["OpenPype executable", get_openpype_execute_args()[-1]], ["OpenPype version", get_openpype_version() or "N/A"], ["OpenPype build version", get_build_version() or "N/A"] ] diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py b/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py index eb8ec4d06c..f49ca5557e 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py @@ -6,7 +6,7 @@ import threading import traceback import subprocess from openpype.api import Logger -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args class SocketThread(threading.Thread): @@ -59,7 +59,7 @@ class SocketThread(threading.Thread): env = os.environ.copy() env["OPENPYPE_PROCESS_MONGO_ID"] = str(Logger.mongo_process_id) # OpenPype executable (with path to start script if not build) - args = get_pype_execute_args( + args = get_openpype_execute_args( # Add `run` command "run", self.filepath, diff --git a/openpype/modules/standalonepublish_action.py b/openpype/modules/standalonepublish_action.py index 9321a415a9..ba53ce9b9e 100644 --- a/openpype/modules/standalonepublish_action.py +++ b/openpype/modules/standalonepublish_action.py @@ -1,7 +1,7 @@ import os import platform import subprocess -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args from openpype.modules import OpenPypeModule from openpype_interfaces import ITrayAction @@ -35,7 +35,7 @@ class StandAlonePublishAction(OpenPypeModule, ITrayAction): self.publish_paths.extend(publish_paths) def run_standalone_publisher(self): - args = get_pype_execute_args("standalonepublisher") + args = get_openpype_execute_args("standalonepublisher") kwargs = {} if platform.system().lower() == "darwin": new_args = ["open", "-na", args.pop(0), "--args"] diff --git a/openpype/tools/standalonepublish/widgets/widget_components.py b/openpype/tools/standalonepublish/widgets/widget_components.py index 2ac54af4e3..4d7f94f825 100644 --- a/openpype/tools/standalonepublish/widgets/widget_components.py +++ b/openpype/tools/standalonepublish/widgets/widget_components.py @@ -10,7 +10,7 @@ from .constants import HOST_NAME from avalon import io from openpype.api import execute, Logger from openpype.lib import ( - get_pype_execute_args, + get_openpype_execute_args, apply_project_environments_value ) @@ -193,7 +193,7 @@ def cli_publish(data, publish_paths, gui=True): project_name = os.environ["AVALON_PROJECT"] env_copy = apply_project_environments_value(project_name, envcopy) - args = get_pype_execute_args("run", PUBLISH_SCRIPT_PATH) + args = get_openpype_execute_args("run", PUBLISH_SCRIPT_PATH) result = execute(args, env=envcopy) result = {} diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 8c6a6d3266..df0238c848 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -14,7 +14,7 @@ from openpype.api import ( resources, get_system_settings ) -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args from openpype.modules import TrayModulesManager from openpype import style from openpype.settings import ( @@ -208,10 +208,10 @@ class TrayManager: First creates new process with same argument and close current tray. """ - args = get_pype_execute_args() + args = get_openpype_execute_args() # Create a copy of sys.argv additional_args = list(sys.argv) - # Check last argument from `get_pype_execute_args` + # Check last argument from `get_openpype_execute_args` # - when running from code it is the same as first from sys.argv if args[-1] == additional_args[0]: additional_args.pop(0) From 4ab54f4d735adf13f7a4db376000bc9454d2ff65 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:27:41 +0100 Subject: [PATCH 036/229] added deprecation warning --- openpype/lib/execute.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 452a8fd4c0..57cb01b4ab 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -148,6 +148,13 @@ def path_to_subprocess_arg(path): def get_pype_execute_args(*args): """Backwards compatible function for 'get_openpype_execute_args'.""" + import traceback + + log = Logger.get_logger("get_pype_execute_args") + stack = "\n".join(traceback.format_stack()) + log.warning(( + "Using deprecated function 'get_pype_execute_args'. Called from:\n{}" + ).format(stack)) return get_openpype_execute_args(*args) From 6de956dae2a876044bbbc77c7d023d53441e008d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:28:03 +0100 Subject: [PATCH 037/229] implemented functions to run openpype subprocess with cleanin environments --- openpype/lib/__init__.py | 4 ++++ openpype/lib/execute.py | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index f721e0f577..12e47a8961 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -29,6 +29,8 @@ from .execute import ( get_linux_launcher_args, execute, run_subprocess, + run_openpype_process, + clean_envs_for_openpype_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -179,6 +181,8 @@ __all__ = [ "get_linux_launcher_args", "execute", "run_subprocess", + "run_openpype_process", + "clean_envs_for_openpype_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 57cb01b4ab..16b98eefb4 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -138,6 +138,49 @@ def run_subprocess(*args, **kwargs): return full_output +def clean_envs_for_openpype_process(env=None): + """Modify environemnts that may affect OpenPype process. + + Main reason to implement this function is to pop PYTHONPATH which may be + affected by in-host environments. + """ + if env is None: + env = os.environ + return { + key: value + for key, value in env.items() + if key not in ("PYTHONPATH",) + } + + +def run_openpype_process(*args, **kwargs): + """Execute OpenPype process with passed arguments and wait. + + Wrapper for 'run_process' which prepends OpenPype executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_openpype_process' function. + + Example: + ``` + run_openpype_process(["run", ""]) + ``` + + Args: + *args (tuple): OpenPype cli arguments. + **kwargs (dict): Keyword arguments for for subprocess.Popen. + """ + args = get_openpype_execute_args(*args) + env = kwargs.pop("env", None) + # Keep env untouched if are passed and not empty + if not env: + # Skip envs that can affect OpenPype process + # - fill more if you find more + env = clean_envs_for_openpype_process(os.environ) + return run_subprocess(args, env=env, **kwargs) + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. From ac6280f959cc50f980bcc37f917bc86c43d36825 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 Jan 2022 16:28:43 +0100 Subject: [PATCH 038/229] use run_openpype_process in extract burnin --- openpype/plugins/publish/extract_burnin.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index df7dc47e17..1cb8608a56 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -13,7 +13,7 @@ import pyblish import openpype import openpype.api from openpype.lib import ( - get_pype_execute_args, + run_openpype_process, get_transcode_temp_directory, convert_for_ffmpeg, @@ -168,9 +168,8 @@ class ExtractBurnin(openpype.api.Extractor): anatomy = instance.context.data["anatomy"] scriptpath = self.burnin_script_path() - # Executable args that will execute the script - # [pype executable, *pype script, "run"] - executable_args = get_pype_execute_args("run", scriptpath) + # Args that will execute the script + executable_args = ["run", scriptpath] burnins_per_repres = self._get_burnins_per_representations( instance, burnin_defs ) @@ -313,7 +312,7 @@ class ExtractBurnin(openpype.api.Extractor): if platform.system().lower() == "windows": process_kwargs["creationflags"] = CREATE_NO_WINDOW - openpype.api.run_subprocess(args, **process_kwargs) + run_openpype_process(args, **process_kwargs) # Remove the temporary json os.remove(temporary_json_filepath) From ae74e0a79569308fa6dc6e602dc0b6d0d3e3c323 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 16:37:02 +0100 Subject: [PATCH 039/229] flame: stylize creator gui --- openpype/hosts/flame/api/style.css | 51 +++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/style.css b/openpype/hosts/flame/api/style.css index b64c391c6e..21e4750513 100644 --- a/openpype/hosts/flame/api/style.css +++ b/openpype/hosts/flame/api/style.css @@ -1,5 +1,5 @@ QWidget { - font-size: 13px; + font: 14px "Discreet"; } QSpinBox { @@ -8,13 +8,46 @@ QSpinBox { } QLineEdit { - padding: 2; - min-width: 15em; + color: #9a9a9a; + background-color: #373e47; + selection-color: #262626; + selection-background-color: #b8b1a7; + font: 14px "Discreet" +} +QLineEdit:focus { + background-color: #474e58 +} + +QLineEdit:disabled { + color: #6a6a6a; + background-color: #373737 +} + +QPushButton { + color: #9a9a9a; + background-color: #424142; + border-top: 1px inset #555555; + border-bottom: 1px inset black; + font: 14px "Discreet" +} + +QPushButton:pressed { + color: #d9d9d9; + background-color: #4f4f4f; + border-top: 1px inset #666666; + font: italic +} + +QPushButton:disabled { + color: #747474; + background-color: #353535; + border-top: 1px solid #444444; + border-bottom: 1px solid #242424 } QVBoxLayout { min-width: 15em; - background-color: #201f1f; + background-color: #313131 } QComboBox { @@ -23,4 +56,14 @@ QComboBox { #sectionContent { background-color: #2E2D2D; +} + +QLabel { + color: #9a9a9a; + border-bottom: 1px inset #282828; + font: 14px "Discreet" +} + +QLabel:disabled { + color: #6a6a6a } \ No newline at end of file From 09214e175b9674fb465517eaf5e4092701e09f6d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 16:59:02 +0100 Subject: [PATCH 040/229] Flame: adding openpype style to creator gui --- openpype/hosts/flame/api/plugin.py | 15 +------ openpype/hosts/flame/api/style.css | 69 ------------------------------ 2 files changed, 2 insertions(+), 82 deletions(-) delete mode 100644 openpype/hosts/flame/api/style.css diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 5e47ce3a68..b4fc75fe00 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -2,23 +2,13 @@ import re import os from Qt import QtWidgets, QtCore import openpype.api as openpype +from openpype import style import openpype.hosts.flame as opflame from . import lib, pipeline from copy import deepcopy log = openpype.Logger().get_logger(__name__) -# Creator plugin functions -def load_stylesheet(): - path = os.path.join(os.path.dirname(__file__), "style.css") - if not os.path.exists(path): - log.warning("Unable to load stylesheet, file not found in resources") - return "" - - with open(path, "r") as file_stream: - stylesheet = file_stream.read() - return stylesheet - class CreatorWidget(QtWidgets.QDialog): @@ -93,8 +83,7 @@ class CreatorWidget(QtWidgets.QDialog): ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) - stylesheet = load_stylesheet() - self.setStyleSheet(stylesheet) + self.setStyleSheet(style.load_stylesheet()) def _on_ok_clicked(self): self.result = self.value(self.items) diff --git a/openpype/hosts/flame/api/style.css b/openpype/hosts/flame/api/style.css deleted file mode 100644 index 21e4750513..0000000000 --- a/openpype/hosts/flame/api/style.css +++ /dev/null @@ -1,69 +0,0 @@ -QWidget { - font: 14px "Discreet"; -} - -QSpinBox { - padding: 2; - max-width: 8em; -} - -QLineEdit { - color: #9a9a9a; - background-color: #373e47; - selection-color: #262626; - selection-background-color: #b8b1a7; - font: 14px "Discreet" -} -QLineEdit:focus { - background-color: #474e58 -} - -QLineEdit:disabled { - color: #6a6a6a; - background-color: #373737 -} - -QPushButton { - color: #9a9a9a; - background-color: #424142; - border-top: 1px inset #555555; - border-bottom: 1px inset black; - font: 14px "Discreet" -} - -QPushButton:pressed { - color: #d9d9d9; - background-color: #4f4f4f; - border-top: 1px inset #666666; - font: italic -} - -QPushButton:disabled { - color: #747474; - background-color: #353535; - border-top: 1px solid #444444; - border-bottom: 1px solid #242424 -} - -QVBoxLayout { - min-width: 15em; - background-color: #313131 -} - -QComboBox { - min-width: 8em; -} - -#sectionContent { - background-color: #2E2D2D; -} - -QLabel { - color: #9a9a9a; - border-bottom: 1px inset #282828; - font: 14px "Discreet" -} - -QLabel:disabled { - color: #6a6a6a -} \ No newline at end of file From 16d920028ce4fe9492be653ffdedc8b26756310d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Jan 2022 17:04:24 +0100 Subject: [PATCH 041/229] Flame: adding closing event to creator gui --- openpype/hosts/flame/api/plugin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index b4fc75fe00..68bdbbe510 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -93,6 +93,10 @@ class CreatorWidget(QtWidgets.QDialog): self.result = None self.close() + def closeEvent(self, event): + self.result = None + event.accept() + def value(self, data, new_data=None): new_data = new_data or dict() for k, v in data.items(): From 9de27a8b04b1b16dbf0b1ca32b308b0d8ef46122 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 12:04:15 +0100 Subject: [PATCH 042/229] OP-2204 - added new setting for upload review in Slack notification --- .../schemas/projects_schema/schema_project_slack.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json index 9ca4e443bd..4e82c991e7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -91,6 +91,11 @@ "key": "upload_thumbnail", "label": "Upload thumbnail" }, + { + "type": "boolean", + "key": "upload_review", + "label": "Upload review" + }, { "type": "text", "multiline": true, From 2229e19caa5c3fd90ce4b8ab1516c56bbd64d3ee Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 12:06:33 +0100 Subject: [PATCH 043/229] OP-2204 - added possibility to upload or add link to review to Slack notification --- .../plugins/publish/integrate_slack_api.py | 132 ++++++++++++------ 1 file changed, 87 insertions(+), 45 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 7b81d3c364..b9f4b9d81f 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -14,6 +14,8 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): Project settings > Slack > Publish plugins > Notification to Slack. If instance contains 'thumbnail' it uploads it. Bot must be present in the target channel. + If instance contains 'review' it could upload (if configured) or place + link with {review_link} placeholder. Message template can contain {} placeholders from anatomyData. """ order = pyblish.api.IntegratorOrder + 0.499 @@ -23,44 +25,68 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True def process(self, instance): - published_path = self._get_thumbnail_path(instance) + thumbnail_path = self._get_thumbnail_path(instance) + review_path = self._get_review_path(instance) + publish_files = set() for message_profile in instance.data["slack_channel_message_profiles"]: message = self._get_filled_message(message_profile["message"], - instance) + instance, + review_path) if not message: return + if message_profile["upload_thumbnail"] and thumbnail_path: + publish_files.add(thumbnail_path) + + if message_profile["upload_review"] and review_path: + publish_files.add(review_path) + for channel in message_profile["channels"]: if six.PY2: self._python2_call(instance.data["slack_token"], channel, message, - published_path, - message_profile["upload_thumbnail"]) + publish_files) else: self._python3_call(instance.data["slack_token"], channel, message, - published_path, - message_profile["upload_thumbnail"]) + publish_files) - def _get_filled_message(self, message_templ, instance): - """Use message_templ and data from instance to get message content.""" + def _get_filled_message(self, message_templ, instance, review_path=None): + """Use message_templ and data from instance to get message content. + + Reviews might be large, so allow only adding link to message instead of + uploading only. + """ fill_data = copy.deepcopy(instance.context.data["anatomyData"]) - fill_pairs = ( + fill_pairs = [ ("asset", instance.data.get("asset", fill_data.get("asset"))), ("subset", instance.data.get("subset", fill_data.get("subset"))), - ("task", instance.data.get("task", fill_data.get("task"))), ("username", instance.data.get("username", fill_data.get("username"))), ("app", instance.data.get("app", fill_data.get("app"))), ("family", instance.data.get("family", fill_data.get("family"))), ("version", str(instance.data.get("version", fill_data.get("version")))) - ) + ] + if review_path: + fill_pairs.append(("review_link", review_path)) + task_on_instance = instance.data.get("task") + task_on_anatomy = fill_data.get("task") + if task_on_instance: + fill_pairs.append(("task[name]", task_on_instance.get("type"))) + fill_pairs.append(("task[name]", task_on_instance.get("name"))) + fill_pairs.append(("task[short]", task_on_instance.get("short"))) + elif task_on_anatomy: + fill_pairs.append(("task[name]", task_on_anatomy.get("type"))) + fill_pairs.append(("task[name]", task_on_anatomy.get("name"))) + fill_pairs.append(("task[short]", task_on_anatomy.get("short"))) + + self.log.debug("fill_pairs ::{}".format(fill_pairs)) multiple_case_variants = prepare_template_data(fill_pairs) fill_data.update(multiple_case_variants) @@ -79,39 +105,51 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): published_path = None for repre in instance.data['representations']: if repre.get('thumbnail') or "thumbnail" in repre.get('tags', []): - repre_files = repre["files"] - if isinstance(repre_files, (tuple, list, set)): - filename = repre_files[0] - else: - filename = repre_files - - published_path = os.path.join( - repre['stagingDir'], filename - ) + if os.path.exists(repre["published_path"]): + published_path = repre["published_path"] break return published_path + def _get_review_path(self, instance): + """Returns abs url for review if present in instance repres""" + published_path = None + for repre in instance.data['representations']: + tags = repre.get('tags', []) + if (repre.get("review") + or "review" in tags + or "burnin" in tags): + if os.path.exists(repre["published_path"]): + published_path = repre["published_path"] + if "burnin" in tags: # burnin has precedence if exists + break + return published_path + def _python2_call(self, token, channel, message, - published_path, upload_thumbnail): + publish_files): from slackclient import SlackClient try: client = SlackClient(token) - if upload_thumbnail and \ - published_path and os.path.exists(published_path): - with open(published_path, 'rb') as pf: + for p_file in publish_files: + attachment_str = "\n\n Attachment links: \n" + with open(p_file, 'rb') as pf: response = client.api_call( "files.upload", channels=channel, - initial_comment=message, file=pf, - title=os.path.basename(published_path) + title=os.path.basename(p_file), ) - else: - response = client.api_call( - "chat.postMessage", - channel=channel, - text=message - ) + attachment_str += "\n<{}|{}>".format( + response["file"]["permalink"], + os.path.basename(p_file)) + + if publish_files: + message += attachment_str + + response = client.api_call( + "chat.postMessage", + channel=channel, + text=message + ) if response.get("error"): error_str = self._enrich_error(str(response.get("error")), @@ -123,23 +161,27 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): self.log.warning("Error happened: {}".format(error_str)) def _python3_call(self, token, channel, message, - published_path, upload_thumbnail): + publish_files): from slack_sdk import WebClient from slack_sdk.errors import SlackApiError try: client = WebClient(token=token) - if upload_thumbnail and \ - published_path and os.path.exists(published_path): - _ = client.files_upload( - channels=channel, - initial_comment=message, - file=published_path, - ) - else: - _ = client.chat_postMessage( - channel=channel, - text=message - ) + attachment_str = "\n\n Attachment links: \n" + for published_file in publish_files: + response = client.files_upload( + file=published_file, + filename=os.path.basename(published_file)) + attachment_str += "\n<{}|{}>".format( + response["file"]["permalink"], + os.path.basename(published_file)) + + if publish_files: + message += attachment_str + + _ = client.chat_postMessage( + channel=channel, + text=message + ) except SlackApiError as e: # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e.response["error"]), channel) From bdd7d159bac52d598f2eaa674813664402594e0d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 12:07:35 +0100 Subject: [PATCH 044/229] OP-2204 - added documentation to Slack section --- website/docs/module_slack.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/website/docs/module_slack.md b/website/docs/module_slack.md index f71fcc2bb7..10d5e58eac 100644 --- a/website/docs/module_slack.md +++ b/website/docs/module_slack.md @@ -61,6 +61,18 @@ Integration can upload 'thumbnail' file (if present in an instance), for that bo manually added to target channel by Slack admin! (In target channel write: ```/invite @OpenPypeNotifier``) +#### Upload review +Integration can upload 'review' file (if present in an instance), for that bot must be +manually added to target channel by Slack admin! +(In target channel write: ```/invite @OpenPypeNotifier``) + +Burnin version (usually .mp4) is preferred if present. + +Please be sure that this configuration is viable for your use case. In case of uploading large reviews to Slack, +all publishes will be slowed down and you might hit a file limit on Slack pretty soon (it is 5GB for Free version of Slack, any file cannot be bigger than 1GB). +You might try to add `{review_link}` to message content. This link might help users to find review easier on their machines. +(It won't show a playable preview though!) + #### Message Message content can use Templating (see [Available template keys](admin_settings_project_anatomy#available-template-keys)). @@ -69,8 +81,22 @@ Few keys also have Capitalized and UPPERCASE format. Values will be modified acc **Available keys:** - asset - subset -- task +- task\[name\] +- task\[type\] +- task\[short\] - username - app - family - version +- review_link + +##### Message example +``` +{Subset} was published for {ASSET} in {task[name]} task. + +Here you can find review {review_link} +``` + +#### Message retention +Currently no purging of old messages is implemented in Openpype. Admins of Slack should set their own retention of messages and files per channel. +(see https://slack.com/help/articles/203457187-Customize-message-and-file-retention-policies) From 169b896ef40303b375003e5a2b3d63701894954f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 14:26:06 +0100 Subject: [PATCH 045/229] flame: refactory api calls --- openpype/hosts/flame/__init__.py | 121 ------------------ openpype/hosts/flame/api/__init__.py | 114 +++++++++++++++++ openpype/hosts/flame/api/constants.py | 24 ++++ openpype/hosts/flame/api/lib.py | 52 ++++---- openpype/hosts/flame/api/menu.py | 10 +- openpype/hosts/flame/api/pipeline.py | 11 +- openpype/hosts/flame/api/plugin.py | 28 ++-- .../hosts/flame/api/scripts/wiretap_com.py | 4 +- .../api/utility_scripts/openpype_in_flame.py | 45 +++---- openpype/hosts/flame/api/utils.py | 2 +- openpype/hosts/flame/api/workio.py | 2 +- openpype/hosts/flame/hooks/pre_flame_setup.py | 3 +- openpype/hosts/flame/otio/flame_export.py | 2 +- .../flame/plugins/create/create_shot_clip.py | 16 +-- .../plugins/publish/collect_test_selection.py | 18 ++- 15 files changed, 238 insertions(+), 214 deletions(-) create mode 100644 openpype/hosts/flame/api/constants.py diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index da42b313aa..02befa76e2 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -1,126 +1,5 @@ -from .api.utils import ( - setup -) - -from .api.pipeline import ( - install, - uninstall, - ls, - containerise, - update_container, - remove_instance, - list_instances, - imprint, - maintained_selection -) - -from .api.lib import ( - FlameAppFramework, - maintain_current_timeline, - get_project_manager, - get_current_project, - get_current_sequence, - create_bin, - create_segment_data_marker, - get_segment_data_marker, - set_segment_data_marker, - set_publish_attribute, - get_publish_attribute, - get_sequence_segments, - maintained_segment_selection, - reset_segment_selection, - get_segment_attributes -) - -from .api.menu import ( - FlameMenuProjectConnect, - FlameMenuTimeline -) - -from .api.workio import ( - open_file, - save_file, - current_file, - has_unsaved_changes, - file_extensions, - work_root -) - import os HOST_DIR = os.path.dirname( os.path.abspath(__file__) ) -API_DIR = os.path.join(HOST_DIR, "api") -PLUGINS_DIR = os.path.join(HOST_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") - -app_framework = None -apps = [] -selection = None - - -__all__ = [ - "HOST_DIR", - "API_DIR", - "PLUGINS_DIR", - "PUBLISH_PATH", - "LOAD_PATH", - "CREATE_PATH", - "INVENTORY_PATH", - "INVENTORY_PATH", - - "app_framework", - "apps", - "selection", - - # pipeline - "install", - "uninstall", - "ls", - "containerise", - "update_container", - "reload_pipeline", - "maintained_selection", - "remove_instance", - "list_instances", - "imprint", - "maintained_selection", - - # utils - "setup", - - # lib - "FlameAppFramework", - "maintain_current_timeline", - "get_project_manager", - "get_current_project", - "get_current_sequence", - "create_bin", - "create_segment_data_marker", - "get_segment_data_marker", - "set_segment_data_marker", - "set_publish_attribute", - "get_publish_attribute", - "get_sequence_segments", - "maintained_segment_selection", - "reset_segment_selection", - "get_segment_attributes" - - # menu - "FlameMenuProjectConnect", - "FlameMenuTimeline", - - # plugin - - # workio - "open_file", - "save_file", - "current_file", - "has_unsaved_changes", - "file_extensions", - "work_root" -] diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 50a6b3f098..c8660aafc4 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -1,3 +1,117 @@ """ OpenPype Autodesk Flame api """ +from .constants import ( + COLOR_MAP, + MARKER_NAME, + MARKER_COLOR, + MARKER_DURATION, + MARKER_PUBLISH_DEFAULT +) +from .lib import ( + CTX, + FlameAppFramework, + maintain_current_timeline, + get_project_manager, + get_current_project, + get_current_sequence, + create_bin, + create_segment_data_marker, + get_segment_data_marker, + set_segment_data_marker, + set_publish_attribute, + get_publish_attribute, + get_sequence_segments, + maintained_segment_selection, + reset_segment_selection, + get_segment_attributes +) +from .utils import ( + setup +) +from .pipeline import ( + install, + uninstall, + ls, + containerise, + update_container, + remove_instance, + list_instances, + imprint, + maintained_selection +) +from .menu import ( + FlameMenuProjectConnect, + FlameMenuTimeline +) +from .plugin import ( + Creator, + PublishableClip +) +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + +__all__ = [ + # constants + "COLOR_MAP", + "MARKER_NAME", + "MARKER_COLOR", + "MARKER_DURATION", + "MARKER_PUBLISH_DEFAULT", + + # lib + "CTX", + "FlameAppFramework", + "maintain_current_timeline", + "get_project_manager", + "get_current_project", + "get_current_sequence", + "create_bin", + "create_segment_data_marker", + "get_segment_data_marker", + "set_segment_data_marker", + "set_publish_attribute", + "get_publish_attribute", + "get_sequence_segments", + "maintained_segment_selection", + "reset_segment_selection", + "get_segment_attributes", + + # pipeline + "install", + "uninstall", + "ls", + "containerise", + "update_container", + "reload_pipeline", + "maintained_selection", + "remove_instance", + "list_instances", + "imprint", + "maintained_selection", + + # utils + "setup", + + # menu + "FlameMenuProjectConnect", + "FlameMenuTimeline", + + # plugin + "Creator", + "PublishableClip", + + # workio + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root" +] diff --git a/openpype/hosts/flame/api/constants.py b/openpype/hosts/flame/api/constants.py new file mode 100644 index 0000000000..1833031e13 --- /dev/null +++ b/openpype/hosts/flame/api/constants.py @@ -0,0 +1,24 @@ + +""" +OpenPype Flame api constances +""" +# OpenPype marker workflow variables +MARKER_NAME = "OpenPypeData" +MARKER_DURATION = 0 +MARKER_COLOR = "cyan" +MARKER_PUBLISH_DEFAULT = False + +# OpenPype color definitions +COLOR_MAP = { + "red": (1.0, 0.0, 0.0), + "orange": (1.0, 0.5, 0.0), + "yellow": (1.0, 1.0, 0.0), + "pink": (1.0, 0.5, 1.0), + "white": (1.0, 1.0, 1.0), + "green": (0.0, 1.0, 0.0), + "cyan": (0.0, 1.0, 1.0), + "blue": (0.0, 0.0, 1.0), + "purple": (0.5, 0.0, 0.5), + "magenta": (0.5, 0.0, 1.0), + "black": (0.0, 0.0, 0.0) +} diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index e5642dd6f9..ccc664ce63 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -5,30 +5,24 @@ import json import pickle import contextlib from pprint import pformat - +from .constants import ( + MARKER_COLOR, + MARKER_DURATION, + MARKER_NAME, + COLOR_MAP, + MARKER_PUBLISH_DEFAULT +) from openpype.api import Logger -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) + + +class CTX: + # singleton used for passing data between api modules + app_framework = None + apps = [] + selection = None -class ctx: - # OpenPype marker workflow variables - marker_name = "OpenPypeData" - marker_duration = 0 - marker_color = "cyan" - publish_default = False - color_map = { - "red": (1.0, 0.0, 0.0), - "orange": (1.0, 0.5, 0.0), - "yellow": (1.0, 1.0, 0.0), - "pink": (1.0, 0.5, 1.0), - "white": (1.0, 1.0, 1.0), - "green": (0.0, 1.0, 0.0), - "cyan": (0.0, 1.0, 1.0), - "blue": (0.0, 0.0, 1.0), - "purple": (0.5, 0.0, 0.5), - "magenta": (0.5, 0.0, 1.0), - "black": (0.0, 0.0, 0.0) -} @contextlib.contextmanager def io_preferences_file(klass, filepath, write=False): @@ -379,7 +373,8 @@ def get_segment_data_marker(segment, with_marker=None): color = marker.colour.get_value() name = marker.name.get_value() - if name == ctx.marker_name and color == ctx.color_map[ctx.marker_color]: + if (name == MARKER_NAME) and ( + color == COLOR_MAP[MARKER_COLOR]): if not with_marker: return json.loads(comment) else: @@ -443,8 +438,8 @@ def get_publish_attribute(segment): tag_data = get_segment_data_marker(segment) if not tag_data: - set_publish_attribute(segment, ctx.publish_default) - return ctx.publish_default + set_publish_attribute(segment, MARKER_PUBLISH_DEFAULT) + return MARKER_PUBLISH_DEFAULT return tag_data["publish"] @@ -465,14 +460,15 @@ def create_segment_data_marker(segment): # create marker marker = segment.create_marker(start_frame) # set marker name - marker.name = ctx.marker_name + marker.name = MARKER_NAME # set duration - marker.duration = ctx.marker_duration + marker.duration = MARKER_DURATION # set colour - marker.colour = ctx.color_map[ctx.marker_color] # Red + marker.colour = COLOR_MAP[MARKER_COLOR] # Red return marker + def get_sequence_segments(sequence, selected=False): segments = [] # loop versions in sequence @@ -485,7 +481,7 @@ def get_sequence_segments(sequence, selected=False): # loop all segment in remaining tracks for segment in track.segments: # ignore all segments not selected - if segment.selected != True and selected == True: + if segment.selected is not True and selected is True: continue # add it to original selection segments.append(segment) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index fef6dbfa35..642c40a7df 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -1,7 +1,7 @@ import os from Qt import QtWidgets from copy import deepcopy - +from pprint import pformat from openpype.tools.utils.host_tools import HostToolsHelper menu_group_name = 'OpenPype' @@ -26,9 +26,11 @@ default_flame_export_presets = { def callback_selection(selection, function): - import openpype.hosts.flame as opflame - opflame.selection = selection - print(opflame.selection) + import openpype.hosts.flame.api as opfapi + opfapi.CTX.selection = selection + print("Hook Selection: \n\t{}".format( + pformat({type(item): item.name for item in CTX.selection}) + )) function() diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index ee0e12584a..5333a07210 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -1,6 +1,7 @@ """ Basic avalon integration """ +import os import contextlib from avalon import api as avalon from pyblish import api as pyblish @@ -11,10 +12,18 @@ from .lib import ( maintained_segment_selection, get_current_sequence ) +from .. import HOST_DIR + +API_DIR = os.path.join(HOST_DIR, "api") +PLUGINS_DIR = os.path.join(HOST_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") AVALON_CONTAINERS = "AVALON_CONTAINERS" -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) def install(): diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 68bdbbe510..4f71f9424e 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -1,13 +1,17 @@ import re -import os from Qt import QtWidgets, QtCore import openpype.api as openpype from openpype import style -import openpype.hosts.flame as opflame -from . import lib, pipeline +from . import selection as opfapi_selection +from . import ( + lib as flib, + pipeline as fpipeline, + constants +) + from copy import deepcopy -log = openpype.Logger().get_logger(__name__) +log = openpype.Logger.get_logger(__name__) class CreatorWidget(QtWidgets.QDialog): @@ -283,7 +287,7 @@ class Spacer(QtWidgets.QWidget): class Creator(openpype.Creator): """Creator class wrapper """ - clip_color = lib.ctx.color_map["purple"] + clip_color = constants.COLOR_MAP["purple"] rename_index = None def __init__(self, *args, **kwargs): @@ -292,13 +296,13 @@ class Creator(openpype.Creator): "flame"]["create"].get(self.__class__.__name__, {}) # adding basic current context flame objects - self.project = lib.get_current_project() - self.sequence = lib.get_current_sequence(opflame.selection) + self.project = flib.get_current_project() + self.sequence = flib.get_current_sequence(opfapi_selection) if (self.options or {}).get("useSelection"): - self.selected = lib.get_sequence_segments(self.sequence, True) + self.selected = flib.get_sequence_segments(self.sequence, True) else: - self.selected = lib.get_sequence_segments(self.sequence) + self.selected = flib.get_sequence_segments(self.sequence) self.widget = CreatorWidget @@ -345,10 +349,10 @@ class PublishableClip: # get main parent objects self.current_segment = segment - sequence_name = lib.get_current_sequence([segment]).name.get_value() + sequence_name = flib.get_current_sequence([segment]).name.get_value() self.sequence_name = str(sequence_name).replace(" ", "_") - self.clip_data = lib.get_segment_attributes(segment) + self.clip_data = flib.get_segment_attributes(segment) # segment (clip) main attributes self.cs_name = self.clip_data["segment_name"] self.cs_index = int(self.clip_data["segment"]) @@ -406,7 +410,7 @@ class PublishableClip: self.marker_data.update({"reviewTrack": None}) # create pype tag on track_item and add data - pipeline.imprint(self.current_segment, self.marker_data) + fpipeline.imprint(self.current_segment, self.marker_data) return self.current_segment diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index f1b5ab2236..0cda25804b 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -16,7 +16,7 @@ if not FLAME_V: raise KeyError("Missing key in environment `OPENPYPE_FLAME_VERSION`") try: - from libwiretapPythonClientAPI import ( + from libwiretapPythonClientAPI import ( # noqa WireTapClientInit) except ImportError: flame_python_path = "/opt/Autodesk/flame_{}/python".format(FLAME_V) @@ -26,7 +26,7 @@ except ImportError: sys.path.append(flame_python_path) - from libwiretapPythonClientAPI import ( + from libwiretapPythonClientAPI import ( # noqa WireTapClientInit, WireTapClientUninit, WireTapNodeHandle, diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py index c5fa881f3c..6e7cebd997 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py @@ -5,17 +5,14 @@ from pprint import pformat import atexit import openpype import avalon -import openpype.hosts.flame as opflame - -flh = sys.modules[__name__] -flh._project = None +import openpype.hosts.flame.api as opfapi def openpype_install(): """Registering OpenPype in context """ openpype.install() - avalon.api.install(opflame) + avalon.api.install(opfapi) print("Avalon registred hosts: {}".format( avalon.api.registered_host())) @@ -48,19 +45,19 @@ sys.excepthook = exeption_handler def cleanup(): """Cleaning up Flame framework context """ - if opflame.apps: + if opfapi.CTX.apps: print('`{}` cleaning up apps:\n {}\n'.format( - __file__, pformat(opflame.apps))) - while len(opflame.apps): - app = opflame.apps.pop() + __file__, pformat(opfapi.CTX.apps))) + while len(opfapi.CTX.apps): + app = opfapi.CTX.apps.pop() print('`{}` removing : {}'.format(__file__, app.name)) del app - opflame.apps = [] + opfapi.CTX.apps = [] - if opflame.app_framework: - print('PYTHON\t: %s cleaning up' % opflame.app_framework.bundle_name) - opflame.app_framework.save_prefs() - opflame.app_framework = None + if opfapi.CTX.app_framework: + print('PYTHON\t: %s cleaning up' % opfapi.CTX.app_framework.bundle_name) + opfapi.CTX.app_framework.save_prefs() + opfapi.CTX.app_framework = None atexit.register(cleanup) @@ -69,9 +66,9 @@ atexit.register(cleanup) def load_apps(): """Load available apps into Flame framework """ - opflame.apps.append(opflame.FlameMenuProjectConnect(opflame.app_framework)) - opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework)) - opflame.app_framework.log.info("Apps are loaded") + opfapi.CTX.apps.append(opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) + opfapi.CTX.apps.append(opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) + opfapi.CTX.app_framework.log.info("Apps are loaded") def project_changed_dict(info): @@ -89,10 +86,10 @@ def app_initialized(parent=None): Args: parent (obj, optional): Parent object. Defaults to None. """ - opflame.app_framework = opflame.FlameAppFramework() + opfapi.CTX.app_framework = opfapi.FlameAppFramework() print("{} initializing".format( - opflame.app_framework.bundle_name)) + opfapi.CTX.app_framework.bundle_name)) load_apps() @@ -131,15 +128,15 @@ def _build_app_menu(app_name): # first find the relative appname app = None - for _app in opflame.apps: + for _app in opfapi.CTX.apps: if _app.__class__.__name__ == app_name: app = _app if app: menu.append(app.build_menu()) - if opflame.app_framework: - menu_auto_refresh = opflame.app_framework.prefs_global.get( + if opfapi.CTX.app_framework: + menu_auto_refresh = opfapi.CTX.app_framework.prefs_global.get( 'menu_auto_refresh', {}) if menu_auto_refresh.get('timeline_menu', True): try: @@ -163,8 +160,8 @@ def project_saved(project_name, save_time, is_auto_save): save_time (str): time when it was saved is_auto_save (bool): autosave is on or off """ - if opflame.app_framework: - opflame.app_framework.save_prefs() + if opfapi.CTX.app_framework: + opfapi.CTX.app_framework.save_prefs() def get_main_menu_custom_ui_actions(): diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 8ed8613b15..b9899900f5 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -5,7 +5,7 @@ Flame utils for syncing scripts import os import shutil from openpype.api import Logger -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) def _sync_utility_scripts(env=None): diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py index d2e2408798..0c96c0752a 100644 --- a/openpype/hosts/flame/api/workio.py +++ b/openpype/hosts/flame/api/workio.py @@ -8,7 +8,7 @@ from openpype.api import Logger # ) -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) exported_projet_ext = ".otoc" diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index e7ef856907..5e0ead9414 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -6,6 +6,7 @@ import socket from openpype.lib import ( PreLaunchHook, get_openpype_username) from openpype.hosts import flame as opflame +import openpype.hosts.flame.api as opfapi import openpype from pprint import pformat @@ -79,7 +80,7 @@ class FlamePrelaunch(PreLaunchHook): app_arguments = self._get_launch_arguments(data_to_script) - opflame.setup(self.launch_context.env) + opfapi.setup(self.launch_context.env) self.launch_context.launch_args.extend(app_arguments) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index aea1f387e8..bea30b58bd 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -11,7 +11,7 @@ from . import utils import flame from pprint import pformat -reload(utils) # noqa +reload(utils) # type: ignore log = logging.getLogger(__name__) diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 45c4557dad..70b2908bec 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -1,9 +1,8 @@ from copy import deepcopy -import openpype.hosts.flame as opflame -import openpype.hosts.flame.api.plugin as fplugin -import openpype.hosts.flame.api.lib as flib -reload(fplugin) -reload(flib) +import openpype.hosts.flame.api as opfapi + +reload(opfapi) # noqa + def _get_video_track_names(sequence): track_names = [] @@ -13,7 +12,8 @@ def _get_video_track_names(sequence): return track_names -class CreateShotClip(fplugin.Creator): + +class CreateShotClip(opfapi.Creator): """Publishable clip""" label = "Create Publishable Clip" @@ -22,7 +22,7 @@ class CreateShotClip(fplugin.Creator): defaults = ["Main"] gui_tracks = _get_video_track_names( - flib.get_current_sequence(opflame.selection) + opfapi.get_current_sequence(opfapi.CTX.selection) ) gui_name = "Pype publish attributes creator" gui_info = "Define sequential rename and fill hierarchy data." @@ -267,4 +267,4 @@ class CreateShotClip(fplugin.Creator): self.rename_index = i # convert track item to timeline media pool item - fplugin.PublishableClip(self, segment, **kwargs).convert() + opfapi.PublishableClip(self, segment, **kwargs).convert() diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 29ca08d9b5..97de4e8dde 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -1,12 +1,10 @@ import os import pyblish.api -import openpype.hosts.flame as opflame +import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export -from openpype.hosts.flame.api import lib, pipeline from pprint import pformat -reload(lib) # noqa -reload(pipeline) # noqa -reload(otio_export) # noqa +reload(opfapi) # type: ignore +reload(otio_export) # type: ignore @pyblish.api.log @@ -20,9 +18,9 @@ class CollectTestSelection(pyblish.api.ContextPlugin): def process(self, context): self.log.info( - "Active Selection: {}".format(opflame.selection)) + "Active Selection: {}".format(opfapi.CTX.selection)) - sequence = lib.get_current_sequence(opflame.selection) + sequence = opfapi.get_current_sequence(opfapi.CTX.selection) self.test_imprint_data(sequence) self.test_otio_export(sequence) @@ -43,15 +41,15 @@ class CollectTestSelection(pyblish.api.ContextPlugin): self.log.info("Otio exported to: {}".format(export_path)) def test_imprint_data(self, sequence): - with lib.maintained_segment_selection(sequence) as selected_segments: - for segment in selected_segments: + with opfapi.maintained_segment_selection(sequence) as sel_segments: + for segment in sel_segments: if str(segment.name)[1:-1] == "": continue self.log.debug("Segment with OpenPypeData: {}".format( segment.name)) - pipeline.imprint(segment, { + opfapi.imprint(segment, { 'asset': segment.name.get_value(), 'family': 'render', 'subset': 'subsetMain' From b12b27e505619a115d641eb09dba365539f5b7a9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 14:40:00 +0100 Subject: [PATCH 046/229] OP-2204 - added customization of bot appearance --- openpype/modules/slack/manifest.yml | 1 + .../slack/plugins/publish/integrate_slack_api.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/openpype/modules/slack/manifest.yml b/openpype/modules/slack/manifest.yml index 37d4669903..bd920ac266 100644 --- a/openpype/modules/slack/manifest.yml +++ b/openpype/modules/slack/manifest.yml @@ -15,6 +15,7 @@ oauth_config: scopes: bot: - chat:write + - chat:write.customize - chat:write.public - files:write settings: diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index b9f4b9d81f..dd2d4ca048 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -24,6 +24,10 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True + # internal, not configurable + bot_user_name = "OpenpypeNotifier" + icon_url = "https://openpype.io/img/favicon/favicon.ico" + def process(self, instance): thumbnail_path = self._get_thumbnail_path(instance) review_path = self._get_review_path(instance) @@ -148,7 +152,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): response = client.api_call( "chat.postMessage", channel=channel, - text=message + text=message, + username=self.bot_user_name, + icon_url=self.icon_url ) if response.get("error"): @@ -180,7 +186,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): _ = client.chat_postMessage( channel=channel, - text=message + text=message, + username=self.bot_user_name, + icon_url=self.icon_url ) except SlackApiError as e: # You will get a SlackApiError if "ok" is False From 8121a532db97e45f8414a457678d7e250f703acc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 14:40:32 +0100 Subject: [PATCH 047/229] hound_ suggestions --- openpype/hosts/flame/api/menu.py | 2 +- openpype/hosts/flame/api/pipeline.py | 3 ++- .../hosts/flame/api/utility_scripts/openpype_in_flame.py | 7 +++++-- openpype/hosts/flame/hooks/pre_flame_setup.py | 1 - openpype/hosts/flame/otio/flame_export.py | 2 +- .../hosts/flame/plugins/publish/collect_test_selection.py | 6 +++--- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index 642c40a7df..c4a18496d3 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -29,7 +29,7 @@ def callback_selection(selection, function): import openpype.hosts.flame.api as opfapi opfapi.CTX.selection = selection print("Hook Selection: \n\t{}".format( - pformat({type(item): item.name for item in CTX.selection}) + pformat({type(item): item.name for item in opfapi.CTX.selection}) )) function() diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 5333a07210..f454c33209 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -163,6 +163,7 @@ def imprint(segment, data=None): # add publish attribute set_publish_attribute(segment, True) + @contextlib.contextmanager def maintained_selection(): import flame @@ -175,4 +176,4 @@ def maintained_selection(): with maintained_segment_selection(sequence): yield finally: - pass \ No newline at end of file + pass diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py index 6e7cebd997..c385fbb8cb 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py @@ -55,7 +55,9 @@ def cleanup(): opfapi.CTX.apps = [] if opfapi.CTX.app_framework: - print('PYTHON\t: %s cleaning up' % opfapi.CTX.app_framework.bundle_name) + print('openpype\t: {} cleaning up'.format( + opfapi.CTX.app_framework.bundle_name) + ) opfapi.CTX.app_framework.save_prefs() opfapi.CTX.app_framework = None @@ -66,7 +68,8 @@ atexit.register(cleanup) def load_apps(): """Load available apps into Flame framework """ - opfapi.CTX.apps.append(opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) + opfapi.CTX.apps.append( + opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) opfapi.CTX.apps.append(opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) opfapi.CTX.app_framework.log.info("Apps are loaded") diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 5e0ead9414..512433b718 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -79,7 +79,6 @@ class FlamePrelaunch(PreLaunchHook): app_arguments = self._get_launch_arguments(data_to_script) - opfapi.setup(self.launch_context.env) self.launch_context.launch_args.extend(app_arguments) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index bea30b58bd..aea1f387e8 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -11,7 +11,7 @@ from . import utils import flame from pprint import pformat -reload(utils) # type: ignore +reload(utils) # noqa log = logging.getLogger(__name__) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 97de4e8dde..0431bd1fe3 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -3,8 +3,8 @@ import pyblish.api import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export from pprint import pformat -reload(opfapi) # type: ignore -reload(otio_export) # type: ignore +reload(opfapi) # noqa +reload(otio_export) # noqa @pyblish.api.log @@ -35,7 +35,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): otio_timeline = otio_export.create_otio_timeline(sequence) otio_export.write_to_file( otio_timeline, export_path - ) + ) self.log.info(pformat(otio_timeline)) self.log.info("Otio exported to: {}".format(export_path)) From 6ca2ae704bbc76e61dbc439cff23dee8cb25f85d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 14:50:17 +0100 Subject: [PATCH 048/229] Update openpype/modules/slack/plugins/publish/integrate_slack_api.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/integrate_slack_api.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index b9f4b9d81f..e094c268da 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -75,16 +75,13 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): if review_path: fill_pairs.append(("review_link", review_path)) - task_on_instance = instance.data.get("task") - task_on_anatomy = fill_data.get("task") - if task_on_instance: - fill_pairs.append(("task[name]", task_on_instance.get("type"))) - fill_pairs.append(("task[name]", task_on_instance.get("name"))) - fill_pairs.append(("task[short]", task_on_instance.get("short"))) - elif task_on_anatomy: - fill_pairs.append(("task[name]", task_on_anatomy.get("type"))) - fill_pairs.append(("task[name]", task_on_anatomy.get("name"))) - fill_pairs.append(("task[short]", task_on_anatomy.get("short"))) + task_data = instance.data.get("task") + if not task_data: + task_data = fill_data.get("task") + for key, value in task_data.items(): + fill_key = "task[{}]".format(key) + fill_pairs.append((fill_key , value)) + fill_pairs.append(("task", task_data["name"])) self.log.debug("fill_pairs ::{}".format(fill_pairs)) multiple_case_variants = prepare_template_data(fill_pairs) From 69b9c06f6a79236ea1c9ceeaee83ef79db5a29fd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 7 Jan 2022 14:57:32 +0100 Subject: [PATCH 049/229] OP-2204 - renamed placeholder to review_filepath --- .../slack/plugins/publish/integrate_slack_api.py | 4 ++-- website/docs/module_slack.md | 15 +++------------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index e094c268da..cdc90a7a28 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -15,7 +15,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): If instance contains 'thumbnail' it uploads it. Bot must be present in the target channel. If instance contains 'review' it could upload (if configured) or place - link with {review_link} placeholder. + link with {review_filepath} placeholder. Message template can contain {} placeholders from anatomyData. """ order = pyblish.api.IntegratorOrder + 0.499 @@ -73,7 +73,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): fill_data.get("version")))) ] if review_path: - fill_pairs.append(("review_link", review_path)) + fill_pairs.append(("review_filepath", review_path)) task_data = instance.data.get("task") if not task_data: diff --git a/website/docs/module_slack.md b/website/docs/module_slack.md index 10d5e58eac..d74ff3a290 100644 --- a/website/docs/module_slack.md +++ b/website/docs/module_slack.md @@ -78,23 +78,14 @@ Message content can use Templating (see [Available template keys](admin_settings Few keys also have Capitalized and UPPERCASE format. Values will be modified accordingly ({Asset} >> "Asset", {FAMILY} >> "RENDER"). -**Available keys:** -- asset -- subset -- task\[name\] -- task\[type\] -- task\[short\] -- username -- app -- family -- version -- review_link +**Additional implemented keys:** +- review_filepath ##### Message example ``` {Subset} was published for {ASSET} in {task[name]} task. -Here you can find review {review_link} +Here you can find review {review_filepath} ``` #### Message retention From 75b828022103a7385a5c25116d1bcb54967fa833 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 15:07:13 +0100 Subject: [PATCH 050/229] flame: review comment https://github.com/pypeclub/OpenPype/pull/2495#discussion_r779708538 --- .../hosts/flame/api/scripts/wiretap_com.py | 33 +++++-------------- openpype/hosts/flame/hooks/pre_flame_setup.py | 20 ++++++++--- .../system_settings/applications.json | 3 +- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index 0cda25804b..4e54dfd913 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -9,31 +9,14 @@ import json import xml.dom.minidom as minidom from copy import deepcopy import datetime - -FLAME_V = os.getenv("OPENPYPE_FLAME_VERSION") - -if not FLAME_V: - raise KeyError("Missing key in environment `OPENPYPE_FLAME_VERSION`") - -try: - from libwiretapPythonClientAPI import ( # noqa - WireTapClientInit) -except ImportError: - flame_python_path = "/opt/Autodesk/flame_{}/python".format(FLAME_V) - flame_exe_path = ( - "/opt/Autodesk/flame_{}/bin/flame.app" - "/Contents/MacOS/startApp").format(FLAME_V) - - sys.path.append(flame_python_path) - - from libwiretapPythonClientAPI import ( # noqa - WireTapClientInit, - WireTapClientUninit, - WireTapNodeHandle, - WireTapServerHandle, - WireTapInt, - WireTapStr - ) +from libwiretapPythonClientAPI import ( # noqa + WireTapClientInit, + WireTapClientUninit, + WireTapNodeHandle, + WireTapServerHandle, + WireTapInt, + WireTapStr +) class WireTapCom(object): diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 512433b718..fc6b65c958 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -20,10 +20,8 @@ class FlamePrelaunch(PreLaunchHook): app_groups = ["flame"] # todo: replace version number with avalon launch app version - flame_python_exe = ( - "/opt/Autodesk/python/{OPENPYPE_FLAME_VERSION}" - "/bin/python2.7" - ) + flame_python_exe = os.getenv("OPENPYPE_FLAME_PYTHON_EXEC") + flame_pythonpath = os.getenv("OPENPYPE_FLAME_PYTHONPATH") wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") @@ -60,7 +58,6 @@ class FlamePrelaunch(PreLaunchHook): "FieldDominance": "PROGRESSIVE" } - data_to_script = { # from settings "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, @@ -77,12 +74,25 @@ class FlamePrelaunch(PreLaunchHook): self.log.info(pformat(dict(_env))) self.log.info(pformat(data_to_script)) + # add to python path from settings + self._add_pythonpath() + app_arguments = self._get_launch_arguments(data_to_script) opfapi.setup(self.launch_context.env) self.launch_context.launch_args.extend(app_arguments) + def _add_pythonpath(self): + pythonpath = self.launch_context.env.get("PYTHONPATH") + + # separate it explicity by `;` that is what we use in settings + new_pythonpath = self.flame_pythonpath.split(";") + new_pythonpath += pythonpath.split(os.pathsep) + + self.launch_context.env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) + + def _get_launch_arguments(self, script_data): # Dump data to string dumped_script_data = json.dumps(script_data) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 3a097d2b37..7fe0432fdf 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -130,7 +130,8 @@ "linux": [] }, "environment": { - "OPENPYPE_FLAME_VERSION": "2021" + "OPENPYPE_FLAME_PYTHON_EXEC": "/opt/Autodesk/python/2021/bin/python2.7", + "OPENPYPE_FLAME_PYTHONPATH": "/opt/Autodesk/flame_2021/python" } }, "__dynamic_keys_labels__": { From 8a7c4772aaf4838b1cdaa03bc454b848b9eacf23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 7 Jan 2022 15:09:45 +0100 Subject: [PATCH 051/229] Update openpype/hosts/flame/api/plugin.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/flame/api/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 4f71f9424e..b291a6ea06 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -134,9 +134,9 @@ class CreatorWidget(QtWidgets.QDialog): '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) return " ".join([str(m.group(0)).capitalize() for m in matches]) - def create_row(self, layout, type, text, **kwargs): + def create_row(self, layout, type_name, text, **kwargs): # get type attribute from qwidgets - attr = getattr(QtWidgets, type) + attr = getattr(QtWidgets, type_name) # convert label text to normal capitalized text with spaces label_text = self.camel_case_split(text) From 389e90670ed5181d9dd45211950f67f9aec1cc5e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 15:16:40 +0100 Subject: [PATCH 052/229] flame: improving creator gui --- openpype/hosts/flame/api/plugin.py | 29 ++++--------------- .../flame/plugins/create/create_shot_clip.py | 2 +- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 4f71f9424e..3f93262e6f 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -38,7 +38,7 @@ class CreatorWidget(QtWidgets.QDialog): self.content_widget = [QtWidgets.QWidget(self)] top_layout = QtWidgets.QFormLayout(self.content_widget[0]) top_layout.setObjectName("ContentLayout") - top_layout.addWidget(Spacer(5, self)) + top_layout.addSpacing(5) # first add widget tag line top_layout.addWidget(QtWidgets.QLabel(info)) @@ -202,13 +202,13 @@ class CreatorWidget(QtWidgets.QDialog): v = data[k] tool_tip = v.get("toolTip", "") if v["type"] == "dict": - # adding spacer between sections self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addWidget(Spacer(20, self)) + headline.addSpacing(20) + headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label @@ -225,13 +225,12 @@ class CreatorWidget(QtWidgets.QDialog): v["value"], nested_content_layout) if v["type"] == "section": - # adding spacer between sections self.content_layout.append(QtWidgets.QWidget(self)) content_layout.addWidget(self.content_layout[-1]) self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addWidget(Spacer(20, self)) + headline.addSpacing(20) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label @@ -267,23 +266,6 @@ class CreatorWidget(QtWidgets.QDialog): return data -class Spacer(QtWidgets.QWidget): - def __init__(self, height, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) - - self.setFixedHeight(height) - - real_spacer = QtWidgets.QWidget(self) - real_spacer.setObjectName("Spacer") - real_spacer.setFixedHeight(height) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(real_spacer) - - self.setLayout(layout) - - class Creator(openpype.Creator): """Creator class wrapper """ @@ -304,7 +286,8 @@ class Creator(openpype.Creator): else: self.selected = flib.get_sequence_segments(self.sequence) - self.widget = CreatorWidget + def create_widget(self, *args, **kwargs): + return CreatorWidget(*args, **kwargs) class PublishableClip: diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 70b2908bec..7129b965ac 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -232,7 +232,7 @@ class CreateShotClip(opfapi.Creator): gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs - widget = self.widget(self.gui_name, self.gui_info, gui_inputs) + widget = self.create_widget(self.gui_name, self.gui_info, gui_inputs) widget.exec_() if len(self.selected) < 1: From ae02ad0d86fc7cf1207017729db24e7b48f326ad Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 15:47:35 +0100 Subject: [PATCH 053/229] flame: refactory creator plugin with abstract class --- openpype/hosts/flame/api/plugin.py | 6 +- .../flame/plugins/create/create_shot_clip.py | 413 +++++++++--------- 2 files changed, 208 insertions(+), 211 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index b0f7568e50..6122b7bf1f 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -326,10 +326,8 @@ class PublishableClip: vertical_sync_default = False driving_layer_default = "" - def __init__(self, cls, segment, **kwargs): - # populate input cls attribute onto self.[attr] - self.__dict__.update(cls.__dict__) - + def __init__(self, segment, **kwargs): + self.rename_index = kwargs["rename_index"] # get main parent objects self.current_segment = segment sequence_name = flib.get_current_sequence([segment]).name.get_value() diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 7129b965ac..866b5108fa 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -4,15 +4,6 @@ import openpype.hosts.flame.api as opfapi reload(opfapi) # noqa -def _get_video_track_names(sequence): - track_names = [] - for ver in sequence.versions: - for track in ver.tracks: - track_names.append(track.name.get_value()) - - return track_names - - class CreateShotClip(opfapi.Creator): """Publishable clip""" @@ -21,203 +12,12 @@ class CreateShotClip(opfapi.Creator): icon = "film" defaults = ["Main"] - gui_tracks = _get_video_track_names( - opfapi.get_current_sequence(opfapi.CTX.selection) - ) - gui_name = "Pype publish attributes creator" - gui_info = "Define sequential rename and fill hierarchy data." - gui_inputs = { - "renameHierarchy": { - "type": "section", - "label": "Shot Hierarchy And Rename Settings", - "target": "ui", - "order": 0, - "value": { - "hierarchy": { - "value": "{folder}/{sequence}", - "type": "QLineEdit", - "label": "Shot Parent Hierarchy", - "target": "tag", - "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa - "order": 0}, - "clipRename": { - "value": False, - "type": "QCheckBox", - "label": "Rename clips", - "target": "ui", - "toolTip": "Renaming selected clips on fly", # noqa - "order": 1}, - "clipName": { - "value": "{sequence}{shot}", - "type": "QLineEdit", - "label": "Clip Name Template", - "target": "ui", - "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa - "order": 2}, - "countFrom": { - "value": 10, - "type": "QSpinBox", - "label": "Count sequence from", - "target": "ui", - "toolTip": "Set when the sequence number stafrom", # noqa - "order": 3}, - "countSteps": { - "value": 10, - "type": "QSpinBox", - "label": "Stepping number", - "target": "ui", - "toolTip": "What number is adding every new step", # noqa - "order": 4}, - } - }, - "hierarchyData": { - "type": "dict", - "label": "Shot Template Keywords", - "target": "tag", - "order": 1, - "value": { - "folder": { - "value": "shots", - "type": "QLineEdit", - "label": "{folder}", - "target": "tag", - "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 0}, - "episode": { - "value": "ep01", - "type": "QLineEdit", - "label": "{episode}", - "target": "tag", - "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 1}, - "sequence": { - "value": "sq01", - "type": "QLineEdit", - "label": "{sequence}", - "target": "tag", - "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 2}, - "track": { - "value": "{_track_}", - "type": "QLineEdit", - "label": "{track}", - "target": "tag", - "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 3}, - "shot": { - "value": "sh###", - "type": "QLineEdit", - "label": "{shot}", - "target": "tag", - "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa - "order": 4} - } - }, - "verticalSync": { - "type": "section", - "label": "Vertical Synchronization Of Attributes", - "target": "ui", - "order": 2, - "value": { - "vSyncOn": { - "value": True, - "type": "QCheckBox", - "label": "Enable Vertical Sync", - "target": "ui", - "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa - "order": 0}, - "vSyncTrack": { - "value": gui_tracks, # noqa - "type": "QComboBox", - "label": "Hero track", - "target": "ui", - "toolTip": "Select driving track name which should be hero for all others", # noqa - "order": 1} - } - }, - "publishSettings": { - "type": "section", - "label": "Publish Settings", - "target": "ui", - "order": 3, - "value": { - "subsetName": { - "value": ["[ track name ]", "main", "bg", "fg", "bg", - "animatic"], - "type": "QComboBox", - "label": "Subset Name", - "target": "ui", - "toolTip": "chose subset name patern, if [ track name ] is selected, name of track layer will be used", # noqa - "order": 0}, - "subsetFamily": { - "value": ["plate", "take"], - "type": "QComboBox", - "label": "Subset Family", - "target": "ui", "toolTip": "What use of this subset is for", # noqa - "order": 1}, - "reviewTrack": { - "value": ["< none >"] + gui_tracks, - "type": "QComboBox", - "label": "Use Review Track", - "target": "ui", - "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa - "order": 2}, - "audio": { - "value": False, - "type": "QCheckBox", - "label": "Include audio", - "target": "tag", - "toolTip": "Process subsets with corresponding audio", # noqa - "order": 3}, - "sourceResolution": { - "value": False, - "type": "QCheckBox", - "label": "Source resolution", - "target": "tag", - "toolTip": "Is resloution taken from timeline or source?", # noqa - "order": 4}, - } - }, - "frameRangeAttr": { - "type": "section", - "label": "Shot Attributes", - "target": "ui", - "order": 4, - "value": { - "workfileFrameStart": { - "value": 1001, - "type": "QSpinBox", - "label": "Workfiles Start Frame", - "target": "tag", - "toolTip": "Set workfile starting frame number", # noqa - "order": 0 - }, - "handleStart": { - "value": 0, - "type": "QSpinBox", - "label": "Handle Start", - "target": "tag", - "toolTip": "Handle at start of clip", # noqa - "order": 1 - }, - "handleEnd": { - "value": 0, - "type": "QSpinBox", - "label": "Handle End", - "target": "tag", - "toolTip": "Handle at end of clip", # noqa - "order": 2 - } - } - } - } - presets = None def process(self): # Creator copy of object attributes that are modified during `process` presets = deepcopy(self.presets) - gui_inputs = deepcopy(self.gui_inputs) + gui_inputs = self.get_gui_inputs() # get key pares from presets and match it on ui inputs for k, v in gui_inputs.items(): @@ -232,7 +32,11 @@ class CreateShotClip(opfapi.Creator): gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs - widget = self.create_widget(self.gui_name, self.gui_info, gui_inputs) + widget = self.create_widget( + "Pype publish attributes creator", + "Define sequential rename and fill hierarchy data.", + gui_inputs + ) widget.exec_() if len(self.selected) < 1: @@ -242,8 +46,6 @@ class CreateShotClip(opfapi.Creator): print("Operation aborted") return - self.rename_add = 0 - # get ui output for track name for vertical sync v_sync_track = widget.result["vSyncTrack"]["value"] @@ -264,7 +66,204 @@ class CreateShotClip(opfapi.Creator): } for i, segment in enumerate(sorted_selected_segments): - self.rename_index = i - + kwargs["rename_index"] = i # convert track item to timeline media pool item - opfapi.PublishableClip(self, segment, **kwargs).convert() + opfapi.PublishableClip(segment, **kwargs).convert() + + def get_gui_inputs(self): + gui_tracks = self._get_video_track_names( + opfapi.get_current_sequence(opfapi.CTX.selection) + ) + return deepcopy({ + "renameHierarchy": { + "type": "section", + "label": "Shot Hierarchy And Rename Settings", + "target": "ui", + "order": 0, + "value": { + "hierarchy": { + "value": "{folder}/{sequence}", + "type": "QLineEdit", + "label": "Shot Parent Hierarchy", + "target": "tag", + "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa + "order": 0}, + "clipRename": { + "value": False, + "type": "QCheckBox", + "label": "Rename clips", + "target": "ui", + "toolTip": "Renaming selected clips on fly", # noqa + "order": 1}, + "clipName": { + "value": "{sequence}{shot}", + "type": "QLineEdit", + "label": "Clip Name Template", + "target": "ui", + "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa + "order": 2}, + "countFrom": { + "value": 10, + "type": "QSpinBox", + "label": "Count sequence from", + "target": "ui", + "toolTip": "Set when the sequence number stafrom", # noqa + "order": 3}, + "countSteps": { + "value": 10, + "type": "QSpinBox", + "label": "Stepping number", + "target": "ui", + "toolTip": "What number is adding every new step", # noqa + "order": 4}, + } + }, + "hierarchyData": { + "type": "dict", + "label": "Shot Template Keywords", + "target": "tag", + "order": 1, + "value": { + "folder": { + "value": "shots", + "type": "QLineEdit", + "label": "{folder}", + "target": "tag", + "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 0}, + "episode": { + "value": "ep01", + "type": "QLineEdit", + "label": "{episode}", + "target": "tag", + "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 1}, + "sequence": { + "value": "sq01", + "type": "QLineEdit", + "label": "{sequence}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 2}, + "track": { + "value": "{_track_}", + "type": "QLineEdit", + "label": "{track}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 3}, + "shot": { + "value": "sh###", + "type": "QLineEdit", + "label": "{shot}", + "target": "tag", + "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 4} + } + }, + "verticalSync": { + "type": "section", + "label": "Vertical Synchronization Of Attributes", + "target": "ui", + "order": 2, + "value": { + "vSyncOn": { + "value": True, + "type": "QCheckBox", + "label": "Enable Vertical Sync", + "target": "ui", + "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa + "order": 0}, + "vSyncTrack": { + "value": gui_tracks, # noqa + "type": "QComboBox", + "label": "Hero track", + "target": "ui", + "toolTip": "Select driving track name which should be hero for all others", # noqa + "order": 1} + } + }, + "publishSettings": { + "type": "section", + "label": "Publish Settings", + "target": "ui", + "order": 3, + "value": { + "subsetName": { + "value": ["[ track name ]", "main", "bg", "fg", "bg", + "animatic"], + "type": "QComboBox", + "label": "Subset Name", + "target": "ui", + "toolTip": "chose subset name patern, if [ track name ] is selected, name of track layer will be used", # noqa + "order": 0}, + "subsetFamily": { + "value": ["plate", "take"], + "type": "QComboBox", + "label": "Subset Family", + "target": "ui", "toolTip": "What use of this subset is for", # noqa + "order": 1}, + "reviewTrack": { + "value": ["< none >"] + gui_tracks, + "type": "QComboBox", + "label": "Use Review Track", + "target": "ui", + "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa + "order": 2}, + "audio": { + "value": False, + "type": "QCheckBox", + "label": "Include audio", + "target": "tag", + "toolTip": "Process subsets with corresponding audio", # noqa + "order": 3}, + "sourceResolution": { + "value": False, + "type": "QCheckBox", + "label": "Source resolution", + "target": "tag", + "toolTip": "Is resloution taken from timeline or source?", # noqa + "order": 4}, + } + }, + "frameRangeAttr": { + "type": "section", + "label": "Shot Attributes", + "target": "ui", + "order": 4, + "value": { + "workfileFrameStart": { + "value": 1001, + "type": "QSpinBox", + "label": "Workfiles Start Frame", + "target": "tag", + "toolTip": "Set workfile starting frame number", # noqa + "order": 0 + }, + "handleStart": { + "value": 0, + "type": "QSpinBox", + "label": "Handle Start", + "target": "tag", + "toolTip": "Handle at start of clip", # noqa + "order": 1 + }, + "handleEnd": { + "value": 0, + "type": "QSpinBox", + "label": "Handle End", + "target": "tag", + "toolTip": "Handle at end of clip", # noqa + "order": 2 + } + } + } + }) + + def _get_video_track_names(self, sequence): + track_names = [] + for ver in sequence.versions: + for track in ver.tracks: + track_names.append(track.name.get_value()) + + return track_names From d1b4ac5d40cb922dc678e472bef71d61ebeb3582 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 15:52:19 +0100 Subject: [PATCH 054/229] moved timers manager one hierarchy higher --- openpype/modules/{default_modules => }/timers_manager/__init__.py | 0 .../modules/{default_modules => }/timers_manager/exceptions.py | 0 .../modules/{default_modules => }/timers_manager/idle_threads.py | 0 .../timers_manager/launch_hooks/post_start_timer.py | 0 openpype/modules/{default_modules => }/timers_manager/rest_api.py | 0 .../{default_modules => }/timers_manager/timers_manager.py | 0 .../{default_modules => }/timers_manager/widget_user_idle.py | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename openpype/modules/{default_modules => }/timers_manager/__init__.py (100%) rename openpype/modules/{default_modules => }/timers_manager/exceptions.py (100%) rename openpype/modules/{default_modules => }/timers_manager/idle_threads.py (100%) rename openpype/modules/{default_modules => }/timers_manager/launch_hooks/post_start_timer.py (100%) rename openpype/modules/{default_modules => }/timers_manager/rest_api.py (100%) rename openpype/modules/{default_modules => }/timers_manager/timers_manager.py (100%) rename openpype/modules/{default_modules => }/timers_manager/widget_user_idle.py (100%) diff --git a/openpype/modules/default_modules/timers_manager/__init__.py b/openpype/modules/timers_manager/__init__.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/__init__.py rename to openpype/modules/timers_manager/__init__.py diff --git a/openpype/modules/default_modules/timers_manager/exceptions.py b/openpype/modules/timers_manager/exceptions.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/exceptions.py rename to openpype/modules/timers_manager/exceptions.py diff --git a/openpype/modules/default_modules/timers_manager/idle_threads.py b/openpype/modules/timers_manager/idle_threads.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/idle_threads.py rename to openpype/modules/timers_manager/idle_threads.py diff --git a/openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py b/openpype/modules/timers_manager/launch_hooks/post_start_timer.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/launch_hooks/post_start_timer.py rename to openpype/modules/timers_manager/launch_hooks/post_start_timer.py diff --git a/openpype/modules/default_modules/timers_manager/rest_api.py b/openpype/modules/timers_manager/rest_api.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/rest_api.py rename to openpype/modules/timers_manager/rest_api.py diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/timers_manager/timers_manager.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/timers_manager.py rename to openpype/modules/timers_manager/timers_manager.py diff --git a/openpype/modules/default_modules/timers_manager/widget_user_idle.py b/openpype/modules/timers_manager/widget_user_idle.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/widget_user_idle.py rename to openpype/modules/timers_manager/widget_user_idle.py From 00118b249a6555d1c75e077399d9a3a4a87bf1f6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 15:52:32 +0100 Subject: [PATCH 055/229] added timers manager to default modules --- openpype/modules/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index b5c491a1c0..d566692439 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -42,6 +42,7 @@ DEFAULT_OPENPYPE_MODULES = ( "settings_action", "standalonepublish_action", "job_queue", + "timers_manager", ) From d0faab89f96b35b297d3117bea619d2dc8cc4c5a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 15:55:53 +0100 Subject: [PATCH 056/229] flame: adding wiretap tools dir to app env var --- openpype/hosts/flame/api/scripts/wiretap_com.py | 17 +++++++---------- .../defaults/system_settings/applications.json | 3 ++- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index 4e54dfd913..2cd9a46184 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -43,6 +43,9 @@ class WireTapCom(object): self.volume_name = volume_name or "stonefs" self.group_name = group_name or "staff" + # wiretap tools dir path + self.wiretap_tools_dir = os.getenv("OPENPYPE_WIRETAP_TOOLS") + # initialize WireTap client WireTapClientInit() @@ -166,11 +169,8 @@ class WireTapCom(object): # form cmd arguments project_create_cmd = [ os.path.join( - "/opt/Autodesk/", - "wiretap", - "tools", - FLAME_V, - "wiretap_create_node", + self.wiretap_tools_dir, + "wiretap_create_node" ), '-n', os.path.join("/volumes", self.volume_name), @@ -422,11 +422,8 @@ class WireTapCom(object): color_policy = color_policy or "Legacy" project_colorspace_cmd = [ os.path.join( - "/opt/Autodesk/", - "wiretap", - "tools", - FLAME_V, - "wiretap_duplicate_node", + self.wiretap_tools_dir, + "wiretap_duplicate_node" ), "-s", "/syncolor/policies/Autodesk/{}".format(color_policy), diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 7fe0432fdf..4a8b6d82a2 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -131,7 +131,8 @@ }, "environment": { "OPENPYPE_FLAME_PYTHON_EXEC": "/opt/Autodesk/python/2021/bin/python2.7", - "OPENPYPE_FLAME_PYTHONPATH": "/opt/Autodesk/flame_2021/python" + "OPENPYPE_FLAME_PYTHONPATH": "/opt/Autodesk/flame_2021/python", + "OPENPYPE_WIRETAP_TOOLS": "/opt/Autodesk/wiretap/tools/2021" } }, "__dynamic_keys_labels__": { From a24b43451dde32333eec70d410fd8791a29fb696 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:18:11 +0100 Subject: [PATCH 057/229] flame: fix wrong selection import --- openpype/hosts/flame/api/__init__.py | 2 -- openpype/hosts/flame/api/lib.py | 34 ---------------------------- openpype/hosts/flame/api/plugin.py | 3 +-- 3 files changed, 1 insertion(+), 38 deletions(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index c8660aafc4..dc47488dc1 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -11,7 +11,6 @@ from .constants import ( from .lib import ( CTX, FlameAppFramework, - maintain_current_timeline, get_project_manager, get_current_project, get_current_sequence, @@ -68,7 +67,6 @@ __all__ = [ # lib "CTX", "FlameAppFramework", - "maintain_current_timeline", "get_project_manager", "get_current_project", "get_current_sequence", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index ccc664ce63..b37cc35afd 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -225,40 +225,6 @@ class FlameAppFramework(object): return True -@contextlib.contextmanager -def maintain_current_timeline(to_timeline, from_timeline=None): - """Maintain current timeline selection during context - - Attributes: - from_timeline (resolve.Timeline)[optional]: - Example: - >>> print(from_timeline.GetName()) - timeline1 - >>> print(to_timeline.GetName()) - timeline2 - - >>> with maintain_current_timeline(to_timeline): - ... print(get_current_sequence().GetName()) - timeline2 - - >>> print(get_current_sequence().GetName()) - timeline1 - """ - # todo: this is still Resolve's implementation - project = get_current_project() - working_timeline = from_timeline or project.GetCurrentTimeline() - - # swith to the input timeline - project.SetCurrentTimeline(to_timeline) - - try: - # do a work - yield - finally: - # put the original working timeline to context - project.SetCurrentTimeline(working_timeline) - - def get_project_manager(): # TODO: get_project_manager return diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 6122b7bf1f..1ae62f3a8d 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -2,7 +2,6 @@ import re from Qt import QtWidgets, QtCore import openpype.api as openpype from openpype import style -from . import selection as opfapi_selection from . import ( lib as flib, pipeline as fpipeline, @@ -279,7 +278,7 @@ class Creator(openpype.Creator): # adding basic current context flame objects self.project = flib.get_current_project() - self.sequence = flib.get_current_sequence(opfapi_selection) + self.sequence = flib.get_current_sequence(flib.CTX.selection) if (self.options or {}).get("useSelection"): self.selected = flib.get_sequence_segments(self.sequence, True) From b56fabbf8c01e2e5b82112e340c2a2a835018afd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:34:28 +0100 Subject: [PATCH 058/229] added new image and define new color for delete button --- openpype/style/data.json | 3 +++ openpype/style/style.css | 7 ++++++ .../project_manager/images/warning.png | Bin 0 -> 9393 bytes .../project_manager/project_manager/style.py | 21 ++++++++++++++++++ 4 files changed, 31 insertions(+) create mode 100644 openpype/tools/project_manager/project_manager/images/warning.png diff --git a/openpype/style/data.json b/openpype/style/data.json index 026eaf4264..205e30563b 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,6 +51,9 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", + "delete-btn-bg": "rgb(201, 54, 54)", + "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "tab-widget": { "bg": "#21252B", "bg-selected": "#434a56", diff --git a/openpype/style/style.css b/openpype/style/style.css index 4159fe1676..8f613f3888 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -713,6 +713,13 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-view-hover}; } +#DeleteButton { + background: {color:delete-btn-bg}; +} +#DeleteButton:disabled { + background: {color:delete-btn-bg-disabled}; +} + /* Launcher specific stylesheets */ #IconView[mode="icon"] { /* font size can't be set on items */ diff --git a/openpype/tools/project_manager/project_manager/images/warning.png b/openpype/tools/project_manager/project_manager/images/warning.png new file mode 100644 index 0000000000000000000000000000000000000000..3b4ae861f9f3982195c82d681d4b3f31cdea9df8 GIT binary patch literal 9393 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 H|MdR=mHnnz literal 0 HcmV?d00001 diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index d3d6857a63..9fa7a5520b 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -1,6 +1,7 @@ import os from Qt import QtCore, QtGui +from openpype.style import get_objected_colors from avalon.vendor import qtawesome @@ -90,6 +91,17 @@ class ResourceCache: icon.addPixmap(disabled_pix, QtGui.QIcon.Disabled, QtGui.QIcon.Off) return icon + @classmethod + def get_warning_pixmap(cls): + src_image = get_warning_image() + colors = get_objected_colors() + color_value = colors["delete-btn-bg"] + + return paint_image_with_color( + src_image, + color_value.get_qcolor() + ) + def get_remove_image(): image_path = os.path.join( @@ -100,6 +112,15 @@ def get_remove_image(): return QtGui.QImage(image_path) +def get_warning_image(): + image_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + "warning.png" + ) + return QtGui.QImage(image_path) + + def paint_image_with_color(image, color): """TODO: This function should be imported from utils. From 53ad6027c5d30c365a8ea52da59f83b8c708a169 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:36:26 +0100 Subject: [PATCH 059/229] changed buttons classes and mark delete btn with DeleteButton object name --- openpype/tools/project_manager/project_manager/widgets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 392f3f4503..20a6955d81 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -352,9 +352,10 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): "Type \"{}\" to confirm...".format(project_name) ) - cancel_btn = _SameSizeBtns("Cancel", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") - confirm_btn = _SameSizeBtns("Delete", self) + confirm_btn = QtWidgets.QPushButton("Permanently Delete Project", self) + confirm_btn.setObjectName("DeleteButton") confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") From 857d22690deb8bc95df8bef3d1c2bf164beeb6b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 16:36:50 +0100 Subject: [PATCH 060/229] added warning pixmap into dialog --- .../project_manager/widgets.py | 87 +++++++++++-------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 20a6955d81..45599ab747 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -4,6 +4,7 @@ from .constants import ( NAME_ALLOWED_SYMBOLS, NAME_REGEX ) +from .style import ResourceCache from openpype.lib import ( create_project, PROJECT_NAME_ALLOWED_SYMBOLS, @@ -13,7 +14,7 @@ from openpype.style import load_stylesheet from openpype.tools.utils import PlaceholderLineEdit from avalon.api import AvalonMongoDB -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui class NameTextEdit(QtWidgets.QLineEdit): @@ -291,42 +292,41 @@ class CreateProjectDialog(QtWidgets.QDialog): return project_names, project_codes -class _SameSizeBtns(QtWidgets.QPushButton): - """Button that keep width of all button added as related. +# TODO PixmapLabel should be moved to 'utils' in other future PR so should be +# imported from there +class PixmapLabel(QtWidgets.QLabel): + """Label resizing image to height of font.""" + def __init__(self, pixmap, parent): + super(PixmapLabel, self).__init__(parent) + self._empty_pixmap = QtGui.QPixmap(0, 0) + self._source_pixmap = pixmap - This happens without changing min/max/fix size of button. Which is - welcomed for multidisplay desktops with different resolution. - """ - def __init__(self, *args, **kwargs): - super(_SameSizeBtns, self).__init__(*args, **kwargs) - self._related_btns = [] + def set_source_pixmap(self, pixmap): + """Change source image.""" + self._source_pixmap = pixmap + self._set_resized_pix() - def add_related_btn(self, btn): - """Add related button which should be checked for width. + def _get_pix_size(self): + size = self.fontMetrics().height() * 4 + return size, size - Args: - btn (_SameSizeBtns): Other object of _SameSizeBtns. - """ - self._related_btns.append(btn) + def _set_resized_pix(self): + if self._source_pixmap is None: + self.setPixmap(self._empty_pixmap) + return + width, height = self._get_pix_size() + self.setPixmap( + self._source_pixmap.scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) - def hint_width(self): - """Get size hint of button not related to others.""" - return super(_SameSizeBtns, self).sizeHint().width() - - def sizeHint(self): - """Calculate size hint based on size hint of this button and related. - - If width is lower than any other button it is changed to higher. - """ - result = super(_SameSizeBtns, self).sizeHint() - width = result.width() - for btn in self._related_btns: - btn_width = btn.hint_width() - if btn_width > width: - width = btn_width - - result.setWidth(width) - return result + def resizeEvent(self, event): + self._set_resized_pix() + super(PixmapLabel, self).resizeEvent(event) class ConfirmProjectDeletion(QtWidgets.QDialog): @@ -336,15 +336,29 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self.setWindowTitle("Delete project?") - message_label = QtWidgets.QLabel(self) + top_widget = QtWidgets.QWidget(self) + + warning_pixmap = ResourceCache.get_warning_pixmap() + warning_icon_label = PixmapLabel(warning_pixmap, top_widget) + + message_label = QtWidgets.QLabel(top_widget) message_label.setWordWrap(True) message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) message_label.setText(( + "WARNING: This cannot be undone.

" "Project \"{}\" with all related data will be" " permanently removed from the database (This actions won't remove" " any files on disk)." ).format(project_name)) + top_layout = QtWidgets.QHBoxLayout(top_widget) + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget( + warning_icon_label, 0, + QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter + ) + top_layout.addWidget(message_label, 1) + question_label = QtWidgets.QLabel("Are you sure?", self) confirm_input = PlaceholderLineEdit(self) @@ -359,16 +373,13 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") - cancel_btn.add_related_btn(confirm_btn) - confirm_btn.add_related_btn(cancel_btn) - btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) btns_layout.addWidget(cancel_btn, 0) btns_layout.addWidget(confirm_btn, 0) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(message_label, 0) + layout.addWidget(top_widget, 0) layout.addStretch(1) layout.addWidget(question_label, 0) layout.addWidget(confirm_input, 0) From 9913872a2a36bfac2e625e04194a994fd7f97d85 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:37:37 +0100 Subject: [PATCH 061/229] flame: small fixes --- openpype/hosts/flame/api/pipeline.py | 20 +++---------------- openpype/hosts/flame/hooks/pre_flame_setup.py | 18 +++++++---------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index f454c33209..9be59990d2 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -27,13 +27,6 @@ log = Logger.get_logger(__name__) def install(): - from .. import ( - PUBLISH_PATH, - LOAD_PATH, - CREATE_PATH, - INVENTORY_PATH - ) - # Disable all families except for the ones we explicitly want to see family_states = [ "imagesequence", @@ -60,13 +53,6 @@ def install(): log.info("OpenPype Flame host installed ...") def uninstall(): - from .. import ( - PUBLISH_PATH, - LOAD_PATH, - CREATE_PATH, - INVENTORY_PATH - ) - pyblish.deregister_host("flame") log.info("Deregistering Flame plug-ins..") @@ -167,11 +153,11 @@ def imprint(segment, data=None): @contextlib.contextmanager def maintained_selection(): import flame - from .. import selection + from .lib import CTX # check if segment is selected - if isinstance(selection[0], flame.PySegment): - sequence = get_current_sequence(selection) + if isinstance(CTX.selection[0], flame.PySegment): + sequence = get_current_sequence(CTX.selection) try: with maintained_segment_selection(sequence): yield diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index fc6b65c958..6c13638f35 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -19,20 +19,17 @@ class FlamePrelaunch(PreLaunchHook): """ app_groups = ["flame"] - # todo: replace version number with avalon launch app version - flame_python_exe = os.getenv("OPENPYPE_FLAME_PYTHON_EXEC") - flame_pythonpath = os.getenv("OPENPYPE_FLAME_PYTHONPATH") - wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + self._env = self.launch_context.env + self.flame_python_exe = self._env["OPENPYPE_FLAME_PYTHON_EXEC"] + self.flame_pythonpath = self._env["OPENPYPE_FLAME_PYTHONPATH"] self.signature = "( {} )".format(self.__class__.__name__) def execute(self): - _env = self.launch_context.env """Hook entry method.""" project_doc = self.data["project_doc"] user_name = get_openpype_username() @@ -60,9 +57,9 @@ class FlamePrelaunch(PreLaunchHook): data_to_script = { # from settings - "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, - "volume_name": _env.get("FLAME_WIRETAP_VOLUME"), - "group_name": _env.get("FLAME_WIRETAP_GROUP"), + "host_name": self._env.get("FLAME_WIRETAP_HOSTNAME") or hostname, + "volume_name": self._env.get("FLAME_WIRETAP_VOLUME"), + "group_name": self._env.get("FLAME_WIRETAP_GROUP"), "color_policy": "ACES 1.1", # from project @@ -71,7 +68,7 @@ class FlamePrelaunch(PreLaunchHook): "project_data": project_data } - self.log.info(pformat(dict(_env))) + self.log.info(pformat(dict(self._env))) self.log.info(pformat(data_to_script)) # add to python path from settings @@ -92,7 +89,6 @@ class FlamePrelaunch(PreLaunchHook): self.launch_context.env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) - def _get_launch_arguments(self, script_data): # Dump data to string dumped_script_data = json.dumps(script_data) From c08ad5a4cba31a37493ae041ece3a8781f20973d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:43:13 +0100 Subject: [PATCH 062/229] flame: adding Spacer class back to plugin creator gui --- openpype/hosts/flame/api/plugin.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 1ae62f3a8d..30a4f3dfc4 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -37,7 +37,7 @@ class CreatorWidget(QtWidgets.QDialog): self.content_widget = [QtWidgets.QWidget(self)] top_layout = QtWidgets.QFormLayout(self.content_widget[0]) top_layout.setObjectName("ContentLayout") - top_layout.addSpacing(5) + top_layout.addWidget(Spacer(5, self)) # first add widget tag line top_layout.addWidget(QtWidgets.QLabel(info)) @@ -206,8 +206,7 @@ class CreatorWidget(QtWidgets.QDialog): self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addSpacing(20) - + headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label @@ -229,7 +228,7 @@ class CreatorWidget(QtWidgets.QDialog): self.content_layout[-1].setObjectName("sectionHeadline") headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) - headline.addSpacing(20) + headline.addWidget(Spacer(20, self)) headline.addWidget(QtWidgets.QLabel(v["label"])) # adding nested layout with label @@ -265,6 +264,23 @@ class CreatorWidget(QtWidgets.QDialog): return data +class Spacer(QtWidgets.QWidget): + def __init__(self, height, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + self.setFixedHeight(height) + + real_spacer = QtWidgets.QWidget(self) + real_spacer.setObjectName("Spacer") + real_spacer.setFixedHeight(height) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(real_spacer) + + self.setLayout(layout) + + class Creator(openpype.Creator): """Creator class wrapper """ From 7baf6437f323a8b408504f51ff78bbb0e35bb499 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:53:09 +0100 Subject: [PATCH 063/229] flame: env not resolving when discovery --- openpype/hosts/flame/hooks/pre_flame_setup.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 6c13638f35..d5ddafde0c 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -24,12 +24,13 @@ class FlamePrelaunch(PreLaunchHook): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._env = self.launch_context.env - self.flame_python_exe = self._env["OPENPYPE_FLAME_PYTHON_EXEC"] - self.flame_pythonpath = self._env["OPENPYPE_FLAME_PYTHONPATH"] self.signature = "( {} )".format(self.__class__.__name__) def execute(self): + _env = self.launch_context.env + self.flame_python_exe = _env["OPENPYPE_FLAME_PYTHON_EXEC"] + self.flame_pythonpath = _env["OPENPYPE_FLAME_PYTHONPATH"] + """Hook entry method.""" project_doc = self.data["project_doc"] user_name = get_openpype_username() @@ -57,9 +58,9 @@ class FlamePrelaunch(PreLaunchHook): data_to_script = { # from settings - "host_name": self._env.get("FLAME_WIRETAP_HOSTNAME") or hostname, - "volume_name": self._env.get("FLAME_WIRETAP_VOLUME"), - "group_name": self._env.get("FLAME_WIRETAP_GROUP"), + "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, + "volume_name": _env.get("FLAME_WIRETAP_VOLUME"), + "group_name": _env.get("FLAME_WIRETAP_GROUP"), "color_policy": "ACES 1.1", # from project @@ -68,7 +69,7 @@ class FlamePrelaunch(PreLaunchHook): "project_data": project_data } - self.log.info(pformat(dict(self._env))) + self.log.info(pformat(dict(_env))) self.log.info(pformat(data_to_script)) # add to python path from settings From a271e05e8c8f30fcb0f98176e6a456f9fb09a3d8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 16:55:33 +0100 Subject: [PATCH 064/229] flame: reduction of project menu items --- openpype/hosts/flame/api/menu.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index c4a18496d3..edb71dd118 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -111,16 +111,6 @@ class FlameMenuProjectConnect(_FlameMenuApp): "name": "Workfiles ...", "execute": lambda x: self.tools_helper.show_workfiles() }) - menu['actions'].append({ - "name": "Create ...", - "execute": lambda x: callback_selection( - x, self.tools_helper.show_creator) - }) - menu['actions'].append({ - "name": "Publish ...", - "execute": lambda x: callback_selection( - x, self.tools_helper.show_publish) - }) menu['actions'].append({ "name": "Load ...", "execute": lambda x: self.tools_helper.show_loader() From 64445d8d21e8209e8894027664cc4770c19cc0aa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 17:32:18 +0100 Subject: [PATCH 065/229] flame: creator debuging --- openpype/hosts/flame/api/pipeline.py | 12 +++++++++--- openpype/hosts/flame/api/plugin.py | 16 +++++++++++----- .../flame/plugins/create/create_shot_clip.py | 1 + 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 9be59990d2..b65c85f5df 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -10,7 +10,8 @@ from .lib import ( set_segment_data_marker, set_publish_attribute, maintained_segment_selection, - get_current_sequence + get_current_sequence, + reset_segment_selection ) from .. import HOST_DIR @@ -158,8 +159,13 @@ def maintained_selection(): # check if segment is selected if isinstance(CTX.selection[0], flame.PySegment): sequence = get_current_sequence(CTX.selection) + try: - with maintained_segment_selection(sequence): + with maintained_segment_selection(sequence) as selected: yield finally: - pass + # reset all selected clips + reset_segment_selection(sequence) + # select only original selection of segments + for segment in selected: + segment.selected = True diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 30a4f3dfc4..f2e67749f2 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -89,7 +89,8 @@ class CreatorWidget(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) def _on_ok_clicked(self): - self.result = self.value(self.items) + log.debug("ok is clicked: {}".format(self.items)) + self.result = self._values(self.items) self.close() def _on_cancel_clicked(self): @@ -100,7 +101,7 @@ class CreatorWidget(QtWidgets.QDialog): self.result = None event.accept() - def value(self, data, new_data=None): + def _values(self, data, new_data=None): new_data = new_data or dict() for k, v in data.items(): new_data[k] = { @@ -109,10 +110,10 @@ class CreatorWidget(QtWidgets.QDialog): } if v["type"] == "dict": new_data[k]["target"] = v["target"] - new_data[k]["value"] = self.value(v["value"]) + new_data[k]["value"] = self._values(v["value"]) if v["type"] == "section": new_data.pop(k) - new_data = self.value(v["value"], new_data) + new_data = self._values(v["value"], new_data) elif getattr(v["value"], "currentText", None): new_data[k]["target"] = v["target"] new_data[k]["value"] = v["value"].currentText() @@ -343,6 +344,8 @@ class PublishableClip: def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] + self.log = kwargs["log"] + # get main parent objects self.current_segment = segment sequence_name = flib.get_current_sequence([segment]).name.get_value() @@ -369,6 +372,9 @@ class PublishableClip: # adding ui inputs if any self.ui_inputs = kwargs.get("ui_inputs", {}) + self.log.info("Inside of plugin: {}".format( + self.marker_data + )) # populate default data before we get other attributes self._populate_segment_default_data() @@ -430,7 +436,7 @@ class PublishableClip: # define ui inputs if non gui mode was used self.shot_num = self.cs_index - log.debug( + self.log.debug( "____ self.shot_num: {}".format(self.shot_num)) # ui_inputs data or default values if gui was not used diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 866b5108fa..123a1c1575 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -61,6 +61,7 @@ class CreateShotClip(opfapi.Creator): sorted_selected_segments.extend(unsorted_selected_segments) kwargs = { + "log": self.log, "ui_inputs": widget.result, "avalon": self.data } From 4097a5c0a603b9bfc10e2a500176f0a5e3b90e59 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 7 Jan 2022 18:00:51 +0100 Subject: [PATCH 066/229] flame: better selection print --- openpype/hosts/flame/api/menu.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index edb71dd118..b7a94e7866 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -29,7 +29,9 @@ def callback_selection(selection, function): import openpype.hosts.flame.api as opfapi opfapi.CTX.selection = selection print("Hook Selection: \n\t{}".format( - pformat({type(item): item.name for item in opfapi.CTX.selection}) + pformat({ + index: (type(item), item.name) + for index, item in enumerate(opfapi.CTX.selection)}) )) function() From f306de1faa9d04fbe1fbfd64c290e3fdc7039cc1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 Jan 2022 21:29:23 +0100 Subject: [PATCH 067/229] cosmetic changes in label --- openpype/tools/project_manager/project_manager/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 45599ab747..4b5aca35ef 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -347,8 +347,8 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): message_label.setText(( "WARNING: This cannot be undone.

" "Project \"{}\" with all related data will be" - " permanently removed from the database (This actions won't remove" - " any files on disk)." + " permanently removed from the database. (This action won't remove" + " any files on disk.)" ).format(project_name)) top_layout = QtWidgets.QHBoxLayout(top_widget) From 1be9a4112a7baff6ae91f324f048b5af849bb32a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Jan 2022 20:42:40 +0100 Subject: [PATCH 068/229] Improve FusionPreLaunch hook error readability + make it a pop-up from the launcher. - I've removed the usage of ` in the string as they would convert into special characters in the pop-up. So those are changed to '. --- .../hosts/fusion/hooks/pre_fusion_setup.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index a0c16a6700..9da7237505 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -1,6 +1,6 @@ import os import importlib -from openpype.lib import PreLaunchHook +from openpype.lib import PreLaunchHook, ApplicationLaunchFailed from openpype.hosts.fusion.api import utils @@ -14,24 +14,26 @@ class FusionPrelaunch(PreLaunchHook): def execute(self): # making sure pyton 3.6 is installed at provided path py36_dir = os.path.normpath(self.launch_context.env.get("PYTHON36", "")) - assert os.path.isdir(py36_dir), ( - "Python 3.6 is not installed at the provided folder path. Either " - "make sure the `environments\resolve.json` is having correctly " - "set `PYTHON36` or make sure Python 3.6 is installed " - f"in given path. \nPYTHON36E: `{py36_dir}`" + if not os.path.isdir(py36_dir): + raise ApplicationLaunchFailed( + "Python 3.6 is not installed at the provided path.\n" + "Either make sure the 'environments/fusion.json' has " + "'PYTHON36' set corectly or make sure Python 3.6 is installed " + f"in the given path.\n\nPYTHON36: {py36_dir}" ) - self.log.info(f"Path to Fusion Python folder: `{py36_dir}`...") + self.log.info(f"Path to Fusion Python folder: '{py36_dir}'...") self.launch_context.env["PYTHON36"] = py36_dir # setting utility scripts dir for scripts syncing us_dir = os.path.normpath( self.launch_context.env.get("FUSION_UTILITY_SCRIPTS_DIR", "") ) - assert os.path.isdir(us_dir), ( - "Fusion utility script dir does not exists. Either make sure " - "the `environments\fusion.json` is having correctly set " - "`FUSION_UTILITY_SCRIPTS_DIR` or reinstall DaVinci Resolve. \n" - f"FUSION_UTILITY_SCRIPTS_DIR: `{us_dir}`" + if not os.path.isdir(us_dir): + raise ApplicationLaunchFailed( + "Fusion utility script dir does not exist. Either make sure " + "the 'environments/fusion.json' has 'FUSION_UTILITY_SCRIPTS_DIR' " + "set correctly or reinstall DaVinci Resolve.\n\n" + f"FUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" ) try: From ff8643a128e57bb72ad42c8e31ad9925026c2e81 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Jan 2022 20:48:39 +0100 Subject: [PATCH 069/229] Fix indentations --- .../hosts/fusion/hooks/pre_fusion_setup.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index 9da7237505..906c1e7b8a 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -16,11 +16,11 @@ class FusionPrelaunch(PreLaunchHook): py36_dir = os.path.normpath(self.launch_context.env.get("PYTHON36", "")) if not os.path.isdir(py36_dir): raise ApplicationLaunchFailed( - "Python 3.6 is not installed at the provided path.\n" - "Either make sure the 'environments/fusion.json' has " - "'PYTHON36' set corectly or make sure Python 3.6 is installed " - f"in the given path.\n\nPYTHON36: {py36_dir}" - ) + "Python 3.6 is not installed at the provided path.\n" + "Either make sure the 'environments/fusion.json' has " + "'PYTHON36' set corectly or make sure Python 3.6 is installed " + f"in the given path.\n\nPYTHON36: {py36_dir}" + ) self.log.info(f"Path to Fusion Python folder: '{py36_dir}'...") self.launch_context.env["PYTHON36"] = py36_dir @@ -30,11 +30,12 @@ class FusionPrelaunch(PreLaunchHook): ) if not os.path.isdir(us_dir): raise ApplicationLaunchFailed( - "Fusion utility script dir does not exist. Either make sure " - "the 'environments/fusion.json' has 'FUSION_UTILITY_SCRIPTS_DIR' " - "set correctly or reinstall DaVinci Resolve.\n\n" - f"FUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" - ) + "Fusion utility script dir does not exist. Either make sure " + "the 'environments/fusion.json' has " + "'FUSION_UTILITY_SCRIPTS_DIR' set correctly or reinstall " + "DaVinci Resolve.\n\n" + f"FUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" + ) try: __import__("avalon.fusion") From 425dbad2ac33cdcb960aa1ed539f2caf9532543e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Jan 2022 20:49:24 +0100 Subject: [PATCH 070/229] Refactor mention of Resolve to Fusion. --- openpype/hosts/fusion/hooks/pre_fusion_setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index 906c1e7b8a..8c4973cf43 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -33,8 +33,7 @@ class FusionPrelaunch(PreLaunchHook): "Fusion utility script dir does not exist. Either make sure " "the 'environments/fusion.json' has " "'FUSION_UTILITY_SCRIPTS_DIR' set correctly or reinstall " - "DaVinci Resolve.\n\n" - f"FUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" + f"Fusion.\n\nFUSION_UTILITY_SCRIPTS_DIR: '{us_dir}'" ) try: From 5d2c5d7776b68de2b7f37a262f69e16c7f4c4071 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Jan 2022 20:55:14 +0100 Subject: [PATCH 071/229] Fix #2497: reset empty string attributes correctly to "" instead of "None" --- openpype/hosts/maya/api/lib.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..ac22cdc777 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -313,13 +313,7 @@ def attribute_values(attr_values): """ - # NOTE(antirotor): this didn't work for some reason for Yeti attributes - # original = [(attr, cmds.getAttr(attr)) for attr in attr_values] - original = [] - for attr in attr_values: - type = cmds.getAttr(attr, type=True) - value = cmds.getAttr(attr) - original.append((attr, str(value) if type == "string" else value)) + original = [(attr, cmds.getAttr(attr)) for attr in attr_values] try: for attr, value in attr_values.items(): if isinstance(value, string_types): @@ -331,6 +325,12 @@ def attribute_values(attr_values): for attr, value in original: if isinstance(value, string_types): cmds.setAttr(attr, value, type="string") + elif value is None and cmds.getAttr(attr, type=True) == "string": + # In some cases the maya.cmds.getAttr command returns None + # for string attributes but this value cannot assigned. + # Note: After setting it once to "" it will then return "" + # instead of None. So this would only happen once. + cmds.setAttr(attr, "", type="string") else: cmds.setAttr(attr, value) From b66f6b95bb46dbaf671ca70e16c506943fad88be Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 10 Jan 2022 12:00:42 +0100 Subject: [PATCH 072/229] OP-2205 - working version for upload multiple files Won't be used probably as purging of old files would be impossible in this use case. --- .../plugins/publish/integrate_slack_api.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index d7be0c0bfa..5aba372549 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -25,7 +25,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True # internal, not configurable - bot_user_name = "OpenpypeNotifier" + bot_user_name = "OpenPypeNotifier" icon_url = "https://openpype.io/img/favicon/favicon.ico" def process(self, instance): @@ -37,11 +37,12 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): message = self._get_filled_message(message_profile["message"], instance, review_path) + self.log.info("message:: {}".format(message)) if not message: return - if message_profile["upload_thumbnail"] and thumbnail_path: - publish_files.add(thumbnail_path) + # if message_profile["upload_thumbnail"] and thumbnail_path: + # publish_files.add(thumbnail_path) if message_profile["upload_review"] and review_path: publish_files.add(review_path) @@ -130,14 +131,14 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): from slackclient import SlackClient try: client = SlackClient(token) + self.log.info("publish {}".format(publish_files)) + attachment_str = "\n\n Attachment links: \n" for p_file in publish_files: - attachment_str = "\n\n Attachment links: \n" with open(p_file, 'rb') as pf: response = client.api_call( "files.upload", channels=channel, - file=pf, - title=os.path.basename(p_file), + file=pf ) attachment_str += "\n<{}|{}>".format( response["file"]["permalink"], @@ -149,11 +150,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): response = client.api_call( "chat.postMessage", channel=channel, - text=message, - username=self.bot_user_name, - icon_url=self.icon_url + text=message ) - + self.log.info("repsonse {}".format(response)) if response.get("error"): error_str = self._enrich_error(str(response.get("error")), channel) From e268ad9de1bd6ba4a020931376b335fd7e0acf1e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:11:06 +0100 Subject: [PATCH 073/229] flame: fixing selected conditions --- openpype/hosts/flame/api/lib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index b37cc35afd..2cc9fee173 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -446,8 +446,12 @@ def get_sequence_segments(sequence, selected=False): continue # loop all segment in remaining tracks for segment in track.segments: - # ignore all segments not selected - if segment.selected is not True and selected is True: + if segment.name.get_value() == "": + continue + if ( + selected is True + and segment.selected.get_value() is not True + ): continue # add it to original selection segments.append(segment) From ca693c1666aa0e69e92193aee62781bd7dc20be1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:14:39 +0100 Subject: [PATCH 074/229] flame: fixing creator plugin operation after refactory --- openpype/hosts/flame/api/plugin.py | 27 ++++++++++++++----- .../flame/plugins/create/create_shot_clip.py | 14 +++++----- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index f2e67749f2..7432d61890 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -17,6 +17,7 @@ class CreatorWidget(QtWidgets.QDialog): # output items items = dict() + _results_back = None def __init__(self, name, info, ui_inputs, parent=None): super(CreatorWidget, self).__init__(parent) @@ -88,18 +89,27 @@ class CreatorWidget(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) + @classmethod + def set_results_back(cls, value): + cls._results_back = value + + @classmethod + def get_results_back(cls): + return cls._results_back + def _on_ok_clicked(self): log.debug("ok is clicked: {}".format(self.items)) - self.result = self._values(self.items) + results_back = self._values(self.items) + self.set_results_back(results_back) self.close() def _on_cancel_clicked(self): - self.result = None + self.set_results_back(None) self.close() - def closeEvent(self, event): - self.result = None - event.accept() + def showEvent(self, event): + self.set_results_back(None) + super(CreatorWidget, self).showEvent(event) def _values(self, data, new_data=None): new_data = new_data or dict() @@ -303,7 +313,9 @@ class Creator(openpype.Creator): self.selected = flib.get_sequence_segments(self.sequence) def create_widget(self, *args, **kwargs): - return CreatorWidget(*args, **kwargs) + widget = CreatorWidget(*args, **kwargs) + widget.exec_() + return widget.get_results_back() class PublishableClip: @@ -344,6 +356,7 @@ class PublishableClip: def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] + self.family = kwargs["family"] self.log = kwargs["log"] # get main parent objects @@ -580,7 +593,7 @@ class PublishableClip: "hierarchyData": hierarchy_formating_data, "subset": self.subset, "family": self.subset_family, - "families": [self.data["family"]] + "families": [self.family] } def _convert_to_entity(self, type, template): diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index 123a1c1575..edc3e7176c 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -1,8 +1,6 @@ from copy import deepcopy import openpype.hosts.flame.api as opfapi -reload(opfapi) # noqa - class CreateShotClip(opfapi.Creator): """Publishable clip""" @@ -32,22 +30,21 @@ class CreateShotClip(opfapi.Creator): gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs - widget = self.create_widget( + results_back = self.create_widget( "Pype publish attributes creator", "Define sequential rename and fill hierarchy data.", gui_inputs ) - widget.exec_() if len(self.selected) < 1: return - if not widget.result: + if not results_back: print("Operation aborted") return # get ui output for track name for vertical sync - v_sync_track = widget.result["vSyncTrack"]["value"] + v_sync_track = results_back["vSyncTrack"]["value"] # sort selected trackItems by sorted_selected_segments = [] @@ -62,8 +59,9 @@ class CreateShotClip(opfapi.Creator): kwargs = { "log": self.log, - "ui_inputs": widget.result, - "avalon": self.data + "ui_inputs": results_back, + "avalon": self.data, + "family": self.data["family"] } for i, segment in enumerate(sorted_selected_segments): From 1454e718e7722c66c4f0d238fef1398704fc59d2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:15:15 +0100 Subject: [PATCH 075/229] flame: remove flame api reload destroying selection --- openpype/hosts/flame/plugins/publish/collect_test_selection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 0431bd1fe3..0c75b3204f 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -3,7 +3,6 @@ import pyblish.api import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export from pprint import pformat -reload(opfapi) # noqa reload(otio_export) # noqa From ba382242ce3d80d221c3622f441c0f58efb55c75 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:45:05 +0100 Subject: [PATCH 076/229] flame: shot number based on segment index --- openpype/hosts/flame/api/plugin.py | 9 ++++++++- .../hosts/flame/plugins/create/create_shot_clip.py | 11 +++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 7432d61890..e6165a6d7e 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -353,6 +353,7 @@ class PublishableClip: count_steps_default = 10 vertical_sync_default = False driving_layer_default = "" + index_from_segment_default = False def __init__(self, segment, **kwargs): self.rename_index = kwargs["rename_index"] @@ -462,6 +463,8 @@ class PublishableClip: self.hierarchy_data = self.ui_inputs.get( "hierarchyData", {}).get("value") or \ self.current_segment_default_data.copy() + self.index_from_segment = self.ui_inputs.get( + "segmentIndex", {}).get("value") or self.index_from_segment_default self.count_from = self.ui_inputs.get( "countFrom", {}).get("value") or self.count_from_default self.count_steps = self.ui_inputs.get( @@ -524,8 +527,12 @@ class PublishableClip: self.review_track not in self.review_track_default): # if review layer is defined and not the same as defalut self.review_layer = self.review_track + # shot num calculate - if self.rename_index == 0: + if self.index_from_segment: + # use clip index from timeline + self.shot_num = self.count_steps * self.cs_index + elif self.rename_index == 0: self.shot_num = self.count_from else: self.shot_num = self.count_from + self.count_steps diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index edc3e7176c..f055c77a89 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -101,20 +101,27 @@ class CreateShotClip(opfapi.Creator): "target": "ui", "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa "order": 2}, + "segmentIndex": { + "value": True, + "type": "QCheckBox", + "label": "Segment index", + "target": "ui", + "toolTip": "Take number from segment index", # noqa + "order": 3}, "countFrom": { "value": 10, "type": "QSpinBox", "label": "Count sequence from", "target": "ui", "toolTip": "Set when the sequence number stafrom", # noqa - "order": 3}, + "order": 4}, "countSteps": { "value": 10, "type": "QSpinBox", "label": "Stepping number", "target": "ui", "toolTip": "What number is adding every new step", # noqa - "order": 4}, + "order": 5}, } }, "hierarchyData": { From 4f612a6a169123ea9918d2f2bbb1e3816bc4b07e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 10 Jan 2022 12:54:55 +0100 Subject: [PATCH 077/229] flame: improving previous commit --- openpype/hosts/flame/api/plugin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index e6165a6d7e..f34999bcf3 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -509,7 +509,8 @@ class PublishableClip: hero_track = False # increasing steps by index of rename iteration - self.count_steps *= self.rename_index + if not self.index_from_segment: + self.count_steps *= self.rename_index hierarchy_formating_data = {} hierarchy_data = deepcopy(self.hierarchy_data) @@ -532,10 +533,11 @@ class PublishableClip: if self.index_from_segment: # use clip index from timeline self.shot_num = self.count_steps * self.cs_index - elif self.rename_index == 0: - self.shot_num = self.count_from else: - self.shot_num = self.count_from + self.count_steps + if self.rename_index == 0: + self.shot_num = self.count_from + else: + self.shot_num = self.count_from + self.count_steps # clip name sequence number _data.update({"shot": self.shot_num}) From 2a0f7b48d99d95219db0816b23679e965178cdf2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 14:06:12 +0100 Subject: [PATCH 078/229] fix how run_openpype_process works --- openpype/lib/execute.py | 2 +- openpype/plugins/publish/extract_burnin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index 16b98eefb4..3cf67a379c 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -164,7 +164,7 @@ def run_openpype_process(*args, **kwargs): Example: ``` - run_openpype_process(["run", ""]) + run_openpype_process("run", "") ``` Args: diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 1cb8608a56..459c66ee43 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -312,7 +312,7 @@ class ExtractBurnin(openpype.api.Extractor): if platform.system().lower() == "windows": process_kwargs["creationflags"] = CREATE_NO_WINDOW - run_openpype_process(args, **process_kwargs) + run_openpype_process(*args, **process_kwargs) # Remove the temporary json os.remove(temporary_json_filepath) From e94aa5311651c3219c5afe8219b0c5ecaa35e4c7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:00:45 +0100 Subject: [PATCH 079/229] moved photoshop from avalon to openpype --- openpype/hosts/photoshop/api/README.md | 255 ++++ openpype/hosts/photoshop/api/__init__.py | 114 +- openpype/hosts/photoshop/api/extension.zxp | Bin 0 -> 54111 bytes openpype/hosts/photoshop/api/extension/.debug | 9 + .../photoshop/api/extension/CSXS/manifest.xml | 53 + .../api/extension/client/CSInterface.js | 1193 +++++++++++++++++ .../photoshop/api/extension/client/client.js | 300 +++++ .../api/extension/client/loglevel.min.js | 2 + .../photoshop/api/extension/client/wsrpc.js | 393 ++++++ .../api/extension/client/wsrpc.min.js | 1 + .../hosts/photoshop/api/extension/host/JSX.js | 774 +++++++++++ .../photoshop/api/extension/host/index.jsx | 484 +++++++ .../photoshop/api/extension/host/json.js | 530 ++++++++ .../api/extension/icons/avalon-logo-48.png | Bin 0 -> 1362 bytes .../hosts/photoshop/api/extension/index.html | 119 ++ openpype/hosts/photoshop/api/launch_logic.py | 315 +++++ openpype/hosts/photoshop/api/lib.py | 76 ++ openpype/hosts/photoshop/api/panel.PNG | Bin 0 -> 8756 bytes .../hosts/photoshop/api/panel_failure.PNG | Bin 0 -> 13568 bytes openpype/hosts/photoshop/api/pipeline.py | 199 +++ openpype/hosts/photoshop/api/workio.py | 50 + openpype/hosts/photoshop/api/ws_stub.py | 470 +++++++ 22 files changed, 5268 insertions(+), 69 deletions(-) create mode 100644 openpype/hosts/photoshop/api/README.md create mode 100644 openpype/hosts/photoshop/api/extension.zxp create mode 100644 openpype/hosts/photoshop/api/extension/.debug create mode 100644 openpype/hosts/photoshop/api/extension/CSXS/manifest.xml create mode 100644 openpype/hosts/photoshop/api/extension/client/CSInterface.js create mode 100644 openpype/hosts/photoshop/api/extension/client/client.js create mode 100644 openpype/hosts/photoshop/api/extension/client/loglevel.min.js create mode 100644 openpype/hosts/photoshop/api/extension/client/wsrpc.js create mode 100644 openpype/hosts/photoshop/api/extension/client/wsrpc.min.js create mode 100644 openpype/hosts/photoshop/api/extension/host/JSX.js create mode 100644 openpype/hosts/photoshop/api/extension/host/index.jsx create mode 100644 openpype/hosts/photoshop/api/extension/host/json.js create mode 100644 openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png create mode 100644 openpype/hosts/photoshop/api/extension/index.html create mode 100644 openpype/hosts/photoshop/api/launch_logic.py create mode 100644 openpype/hosts/photoshop/api/lib.py create mode 100644 openpype/hosts/photoshop/api/panel.PNG create mode 100644 openpype/hosts/photoshop/api/panel_failure.PNG create mode 100644 openpype/hosts/photoshop/api/pipeline.py create mode 100644 openpype/hosts/photoshop/api/workio.py create mode 100644 openpype/hosts/photoshop/api/ws_stub.py diff --git a/openpype/hosts/photoshop/api/README.md b/openpype/hosts/photoshop/api/README.md new file mode 100644 index 0000000000..b958f53803 --- /dev/null +++ b/openpype/hosts/photoshop/api/README.md @@ -0,0 +1,255 @@ +# Photoshop Integration + +## Setup + +The Photoshop integration requires two components to work; `extension` and `server`. + +### Extension + +To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). + +``` +ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp +``` + +### Server + +The easiest way to get the server and Photoshop launch is with: + +``` +python -c ^"import avalon.photoshop;avalon.photoshop.launch(""C:\Program Files\Adobe\Adobe Photoshop 2020\Photoshop.exe"")^" +``` + +`avalon.photoshop.launch` launches the application and server, and also closes the server when Photoshop exists. + +## Usage + +The Photoshop extension can be found under `Window > Extensions > Avalon`. Once launched you should be presented with a panel like this: + +![Avalon Panel](panel.PNG "Avalon Panel") + + +## Developing + +### Extension +When developing the extension you can load it [unsigned](https://github.com/Adobe-CEP/CEP-Resources/blob/master/CEP_9.x/Documentation/CEP%209.0%20HTML%20Extension%20Cookbook.md#debugging-unsigned-extensions). + +When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). + +``` +ZXPSignCmd -selfSignedCert NA NA Avalon Avalon-Photoshop avalon extension.p12 +ZXPSignCmd -sign {path to avalon-core}\avalon\photoshop\extension {path to avalon-core}\avalon\photoshop\extension.zxp extension.p12 avalon +``` + +### Plugin Examples + +These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). + +#### Creator Plugin +```python +from avalon import photoshop + + +class CreateImage(photoshop.Creator): + """Image folder for publish.""" + + name = "imageDefault" + label = "Image" + family = "image" + + def __init__(self, *args, **kwargs): + super(CreateImage, self).__init__(*args, **kwargs) +``` + +#### Collector Plugin +```python +import pythoncom + +import pyblish.api + + +class CollectInstances(pyblish.api.ContextPlugin): + """Gather instances by LayerSet and file metadata + + This collector takes into account assets that are associated with + an LayerSet and marked with a unique identifier; + + Identifier: + id (str): "pyblish.avalon.instance" + """ + + label = "Instances" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + families_mapping = { + "image": [] + } + + def process(self, context): + # Necessary call when running in a different thread which pyblish-qml + # can be. + pythoncom.CoInitialize() + + photoshop_client = PhotoshopClientStub() + layers = photoshop_client.get_layers() + layers_meta = photoshop_client.get_layers_metadata() + for layer in layers: + layer_data = photoshop_client.read(layer, layers_meta) + + # Skip layers without metadata. + if layer_data is None: + continue + + # Skip containers. + if "container" in layer_data["id"]: + continue + + # child_layers = [*layer.Layers] + # self.log.debug("child_layers {}".format(child_layers)) + # if not child_layers: + # self.log.info("%s skipped, it was empty." % layer.Name) + # continue + + instance = context.create_instance(layer.name) + instance.append(layer) + instance.data.update(layer_data) + instance.data["families"] = self.families_mapping[ + layer_data["family"] + ] + instance.data["publish"] = layer.visible + + # Produce diagnostic message for any graphical + # user interface interested in visualising it. + self.log.info("Found: \"%s\" " % instance.data["name"]) +``` + +#### Extractor Plugin +```python +import os + +import openpype.api +from avalon import photoshop + + +class ExtractImage(openpype.api.Extractor): + """Produce a flattened image file from instance + + This plug-in takes into account only the layers in the group. + """ + + label = "Extract Image" + hosts = ["photoshop"] + families = ["image"] + formats = ["png", "jpg"] + + def process(self, instance): + + staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(staging_dir)) + + # Perform extraction + stub = photoshop.stub() + files = {} + with photoshop.maintained_selection(): + self.log.info("Extracting %s" % str(list(instance))) + with photoshop.maintained_visibility(): + # Hide all other layers. + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers([instance[0]])]) + + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) + + save_options = [] + if "png" in self.formats: + save_options.append('png') + if "jpg" in self.formats: + save_options.append('jpg') + + file_basename = os.path.splitext( + stub.get_active_document_name() + )[0] + for extension in save_options: + _filename = "{}.{}".format(file_basename, extension) + files[extension] = _filename + + full_filename = os.path.join(staging_dir, _filename) + stub.saveAs(full_filename, extension, True) + + representations = [] + for extension, filename in files.items(): + representations.append({ + "name": extension, + "ext": extension, + "files": filename, + "stagingDir": staging_dir + }) + instance.data["representations"] = representations + instance.data["stagingDir"] = staging_dir + + self.log.info(f"Extracted {instance} to {staging_dir}") +``` + +#### Loader Plugin +```python +from avalon import api, photoshop + +stub = photoshop.stub() + + +class ImageLoader(api.Loader): + """Load images + + Stores the imported asset in a container named after the asset. + """ + + families = ["image"] + representations = ["*"] + + def load(self, context, name=None, namespace=None, data=None): + with photoshop.maintained_selection(): + layer = stub.import_smart_object(self.fname) + + self[:] = [layer] + + return photoshop.containerise( + name, + namespace, + layer, + context, + self.__class__.__name__ + ) + + def update(self, container, representation): + layer = container.pop("layer") + + with photoshop.maintained_selection(): + stub.replace_smart_object( + layer, api.get_representation_path(representation) + ) + + stub.imprint( + layer, {"representation": str(representation["_id"])} + ) + + def remove(self, container): + container["layer"].Delete() + + def switch(self, container, representation): + self.update(container, representation) +``` +For easier debugging of Javascript: +https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 +Add --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome +then localhost:8078 (port set in `photoshop\extension\.debug`) + +Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 + +Or install CEF client from https://github.com/Adobe-CEP/CEP-Resources/tree/master/CEP_9.x +## Resources + - https://github.com/lohriialo/photoshop-scripting-python + - https://www.adobe.com/devnet/photoshop/scripting.html + - https://github.com/Adobe-CEP/Getting-Started-guides + - https://github.com/Adobe-CEP/CEP-Resources diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index d978d6ecc1..43756b9ee4 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -1,79 +1,55 @@ -import os -import sys -import logging +"""Public API -from Qt import QtWidgets +Anything that isn't defined here is INTERNAL and unreliable for external use. -from avalon import io -from avalon import api as avalon -from openpype import lib -from pyblish import api as pyblish -import openpype.hosts.photoshop +""" -log = logging.getLogger("openpype.hosts.photoshop") +from .pipeline import ( + ls, + list_instances, + remove_instance, + Creator, + install, + containerise +) -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.photoshop.__file__)) -PLUGINS_DIR = os.path.join(HOST_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") +from .workio import ( + file_extensions, + has_unsaved_changes, + save_file, + open_file, + current_file, + work_root, +) -def check_inventory(): - if not lib.any_outdated(): - return +from .lib import ( + maintained_selection, + maintained_visibility +) - host = avalon.registered_host() - outdated_containers = [] - for container in host.ls(): - representation = container['representation'] - representation_doc = io.find_one( - { - "_id": io.ObjectId(representation), - "type": "representation" - }, - projection={"parent": True} - ) - if representation_doc and not lib.is_latest(representation_doc): - outdated_containers.append(container) +from .launch_logic import stub - # Warn about outdated containers. - print("Starting new QApplication..") - app = QtWidgets.QApplication(sys.argv) +__all__ = [ + # pipeline + "ls", + "list_instances", + "remove_instance", + "Creator", + "install", + "containerise", - message_box = QtWidgets.QMessageBox() - message_box.setIcon(QtWidgets.QMessageBox.Warning) - msg = "There are outdated containers in the scene." - message_box.setText(msg) - message_box.exec_() + # workfiles + "file_extensions", + "has_unsaved_changes", + "save_file", + "open_file", + "current_file", + "work_root", - # Garbage collect QApplication. - del app + # lib + "maintained_selection", + "maintained_visibility", - -def application_launch(): - check_inventory() - - -def install(): - print("Installing Pype config...") - - pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - - avalon.on("application.launched", application_launch) - -def uninstall(): - pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) - -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value + # launch_logic + "stub" +] diff --git a/openpype/hosts/photoshop/api/extension.zxp b/openpype/hosts/photoshop/api/extension.zxp new file mode 100644 index 0000000000000000000000000000000000000000..a25ec96e7df2348e558277faedf4f74561a7f316 GIT binary patch literal 54111 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� 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¨9*zdQ|q^=Mf~ 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 z + + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml new file mode 100644 index 0000000000..6396cd2412 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + ./index.html + + + + true + + + applicationActivate + com.adobe.csxs.events.ApplicationInitialized + + + + Panel + OpenPype + + + 300 + 140 + + + 400 + 200 + + + + ./icons/avalon-logo-48.png + + + + + + diff --git a/openpype/hosts/photoshop/api/extension/client/CSInterface.js b/openpype/hosts/photoshop/api/extension/client/CSInterface.js new file mode 100644 index 0000000000..4239391efd --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/CSInterface.js @@ -0,0 +1,1193 @@ +/************************************************************************************************** +* +* ADOBE SYSTEMS INCORPORATED +* Copyright 2013 Adobe Systems Incorporated +* All Rights Reserved. +* +* NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the +* terms of the Adobe license agreement accompanying it. If you have received this file from a +* source other than Adobe, then your use, modification, or distribution of it requires the prior +* written permission of Adobe. +* +**************************************************************************************************/ + +/** CSInterface - v8.0.0 */ + +/** + * Stores constants for the window types supported by the CSXS infrastructure. + */ +function CSXSWindowType() +{ +} + +/** Constant for the CSXS window type Panel. */ +CSXSWindowType._PANEL = "Panel"; + +/** Constant for the CSXS window type Modeless. */ +CSXSWindowType._MODELESS = "Modeless"; + +/** Constant for the CSXS window type ModalDialog. */ +CSXSWindowType._MODAL_DIALOG = "ModalDialog"; + +/** EvalScript error message */ +EvalScript_ErrMessage = "EvalScript error."; + +/** + * @class Version + * Defines a version number with major, minor, micro, and special + * components. The major, minor and micro values are numeric; the special + * value can be any string. + * + * @param major The major version component, a positive integer up to nine digits long. + * @param minor The minor version component, a positive integer up to nine digits long. + * @param micro The micro version component, a positive integer up to nine digits long. + * @param special The special version component, an arbitrary string. + * + * @return A new \c Version object. + */ +function Version(major, minor, micro, special) +{ + this.major = major; + this.minor = minor; + this.micro = micro; + this.special = special; +} + +/** + * The maximum value allowed for a numeric version component. + * This reflects the maximum value allowed in PlugPlug and the manifest schema. + */ +Version.MAX_NUM = 999999999; + +/** + * @class VersionBound + * Defines a boundary for a version range, which associates a \c Version object + * with a flag for whether it is an inclusive or exclusive boundary. + * + * @param version The \c #Version object. + * @param inclusive True if this boundary is inclusive, false if it is exclusive. + * + * @return A new \c VersionBound object. + */ +function VersionBound(version, inclusive) +{ + this.version = version; + this.inclusive = inclusive; +} + +/** + * @class VersionRange + * Defines a range of versions using a lower boundary and optional upper boundary. + * + * @param lowerBound The \c #VersionBound object. + * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. + * + * @return A new \c VersionRange object. + */ +function VersionRange(lowerBound, upperBound) +{ + this.lowerBound = lowerBound; + this.upperBound = upperBound; +} + +/** + * @class Runtime + * Represents a runtime related to the CEP infrastructure. + * Extensions can declare dependencies on particular + * CEP runtime versions in the extension manifest. + * + * @param name The runtime name. + * @param version A \c #VersionRange object that defines a range of valid versions. + * + * @return A new \c Runtime object. + */ +function Runtime(name, versionRange) +{ + this.name = name; + this.versionRange = versionRange; +} + +/** +* @class Extension +* Encapsulates a CEP-based extension to an Adobe application. +* +* @param id The unique identifier of this extension. +* @param name The localizable display name of this extension. +* @param mainPath The path of the "index.html" file. +* @param basePath The base path of this extension. +* @param windowType The window type of the main window of this extension. + Valid values are defined by \c #CSXSWindowType. +* @param width The default width in pixels of the main window of this extension. +* @param height The default height in pixels of the main window of this extension. +* @param minWidth The minimum width in pixels of the main window of this extension. +* @param minHeight The minimum height in pixels of the main window of this extension. +* @param maxWidth The maximum width in pixels of the main window of this extension. +* @param maxHeight The maximum height in pixels of the main window of this extension. +* @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. +* @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. +* @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. +* @param isAutoVisible True if this extension is visible on loading. +* @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. +* +* @return A new \c Extension object. +*/ +function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, + defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) +{ + this.id = id; + this.name = name; + this.mainPath = mainPath; + this.basePath = basePath; + this.windowType = windowType; + this.width = width; + this.height = height; + this.minWidth = minWidth; + this.minHeight = minHeight; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + this.defaultExtensionDataXml = defaultExtensionDataXml; + this.specialExtensionDataXml = specialExtensionDataXml; + this.requiredRuntimeList = requiredRuntimeList; + this.isAutoVisible = isAutoVisible; + this.isPluginExtension = isPluginExtension; +} + +/** + * @class CSEvent + * A standard JavaScript event, the base class for CEP events. + * + * @param type The name of the event type. + * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". + * @param appId The unique identifier of the application that generated the event. + * @param extensionId The unique identifier of the extension that generated the event. + * + * @return A new \c CSEvent object + */ +function CSEvent(type, scope, appId, extensionId) +{ + this.type = type; + this.scope = scope; + this.appId = appId; + this.extensionId = extensionId; +} + +/** Event-specific data. */ +CSEvent.prototype.data = ""; + +/** + * @class SystemPath + * Stores operating-system-specific location constants for use in the + * \c #CSInterface.getSystemPath() method. + * @return A new \c SystemPath object. + */ +function SystemPath() +{ +} + +/** The path to user data. */ +SystemPath.USER_DATA = "userData"; + +/** The path to common files for Adobe applications. */ +SystemPath.COMMON_FILES = "commonFiles"; + +/** The path to the user's default document folder. */ +SystemPath.MY_DOCUMENTS = "myDocuments"; + +/** @deprecated. Use \c #SystemPath.Extension. */ +SystemPath.APPLICATION = "application"; + +/** The path to current extension. */ +SystemPath.EXTENSION = "extension"; + +/** The path to hosting application's executable. */ +SystemPath.HOST_APPLICATION = "hostApplication"; + +/** + * @class ColorType + * Stores color-type constants. + */ +function ColorType() +{ +} + +/** RGB color type. */ +ColorType.RGB = "rgb"; + +/** Gradient color type. */ +ColorType.GRADIENT = "gradient"; + +/** Null color type. */ +ColorType.NONE = "none"; + +/** + * @class RGBColor + * Stores an RGB color with red, green, blue, and alpha values. + * All values are in the range [0.0 to 255.0]. Invalid numeric values are + * converted to numbers within this range. + * + * @param red The red value, in the range [0.0 to 255.0]. + * @param green The green value, in the range [0.0 to 255.0]. + * @param blue The blue value, in the range [0.0 to 255.0]. + * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. + * The default, 255.0, means that the color is fully opaque. + * + * @return A new RGBColor object. + */ +function RGBColor(red, green, blue, alpha) +{ + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; +} + +/** + * @class Direction + * A point value in which the y component is 0 and the x component + * is positive or negative for a right or left direction, + * or the x component is 0 and the y component is positive or negative for + * an up or down direction. + * + * @param x The horizontal component of the point. + * @param y The vertical component of the point. + * + * @return A new \c Direction object. + */ +function Direction(x, y) +{ + this.x = x; + this.y = y; +} + +/** + * @class GradientStop + * Stores gradient stop information. + * + * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. + * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. + * + * @return GradientStop object. + */ +function GradientStop(offset, rgbColor) +{ + this.offset = offset; + this.rgbColor = rgbColor; +} + +/** + * @class GradientColor + * Stores gradient color information. + * + * @param type The gradient type, must be "linear". + * @param direction A \c #Direction object for the direction of the gradient + (up, down, right, or left). + * @param numStops The number of stops in the gradient. + * @param gradientStopList An array of \c #GradientStop objects. + * + * @return A new \c GradientColor object. + */ +function GradientColor(type, direction, numStops, arrGradientStop) +{ + this.type = type; + this.direction = direction; + this.numStops = numStops; + this.arrGradientStop = arrGradientStop; +} + +/** + * @class UIColor + * Stores color information, including the type, anti-alias level, and specific color + * values in a color object of an appropriate type. + * + * @param type The color type, 1 for "rgb" and 2 for "gradient". + The supplied color object must correspond to this type. + * @param antialiasLevel The anti-alias level constant. + * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. + * + * @return A new \c UIColor object. + */ +function UIColor(type, antialiasLevel, color) +{ + this.type = type; + this.antialiasLevel = antialiasLevel; + this.color = color; +} + +/** + * @class AppSkinInfo + * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. + * + * @param baseFontFamily The base font family of the application. + * @param baseFontSize The base font size of the application. + * @param appBarBackgroundColor The application bar background color. + * @param panelBackgroundColor The background color of the extension panel. + * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. + * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. + * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. + * + * @return AppSkinInfo object. + */ +function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) +{ + this.baseFontFamily = baseFontFamily; + this.baseFontSize = baseFontSize; + this.appBarBackgroundColor = appBarBackgroundColor; + this.panelBackgroundColor = panelBackgroundColor; + this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; + this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; + this.systemHighlightColor = systemHighlightColor; +} + +/** + * @class HostEnvironment + * Stores information about the environment in which the extension is loaded. + * + * @param appName The application's name. + * @param appVersion The application's version. + * @param appLocale The application's current license locale. + * @param appUILocale The application's current UI locale. + * @param appId The application's unique identifier. + * @param isAppOnline True if the application is currently online. + * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. + * + * @return A new \c HostEnvironment object. + */ +function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) +{ + this.appName = appName; + this.appVersion = appVersion; + this.appLocale = appLocale; + this.appUILocale = appUILocale; + this.appId = appId; + this.isAppOnline = isAppOnline; + this.appSkinInfo = appSkinInfo; +} + +/** + * @class HostCapabilities + * Stores information about the host capabilities. + * + * @param EXTENDED_PANEL_MENU True if the application supports panel menu. + * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. + * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. + * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. + * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. + * + * @return A new \c HostCapabilities object. + */ +function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) +{ + this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; + this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; + this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; + this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; + this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 +} + +/** + * @class ApiVersion + * Stores current api version. + * + * Since 4.2.0 + * + * @param major The major version + * @param minor The minor version. + * @param micro The micro version. + * + * @return ApiVersion object. + */ +function ApiVersion(major, minor, micro) +{ + this.major = major; + this.minor = minor; + this.micro = micro; +} + +/** + * @class MenuItemStatus + * Stores flyout menu item status + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function MenuItemStatus(menuItemLabel, enabled, checked) +{ + this.menuItemLabel = menuItemLabel; + this.enabled = enabled; + this.checked = checked; +} + +/** + * @class ContextMenuItemStatus + * Stores the status of the context menu item. + * + * Since 5.2.0 + * + * @param menuItemID The menu item id. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function ContextMenuItemStatus(menuItemID, enabled, checked) +{ + this.menuItemID = menuItemID; + this.enabled = enabled; + this.checked = checked; +} +//------------------------------ CSInterface ---------------------------------- + +/** + * @class CSInterface + * This is the entry point to the CEP extensibility infrastructure. + * Instantiate this object and use it to: + *
    + *
  • Access information about the host application in which an extension is running
  • + *
  • Launch an extension
  • + *
  • Register interest in event notifications, and dispatch events
  • + *
+ * + * @return A new \c CSInterface object + */ +function CSInterface() +{ +} + +/** + * User can add this event listener to handle native application theme color changes. + * Callback function gives extensions ability to fine-tune their theme color after the + * global theme color has been changed. + * The callback function should be like below: + * + * @example + * // event is a CSEvent object, but user can ignore it. + * function OnAppThemeColorChanged(event) + * { + * // Should get a latest HostEnvironment object from application. + * var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; + * // Gets the style information such as color info from the skinInfo, + * // and redraw all UI controls of your extension according to the style info. + * } + */ +CSInterface.THEME_COLOR_CHANGED_EVENT = "com.adobe.csxs.events.ThemeColorChanged"; + +/** The host environment data object. */ +CSInterface.prototype.hostEnvironment = window.__adobe_cep__ ? JSON.parse(window.__adobe_cep__.getHostEnvironment()) : null; + +/** Retrieves information about the host environment in which the + * extension is currently running. + * + * @return A \c #HostEnvironment object. + */ +CSInterface.prototype.getHostEnvironment = function() +{ + this.hostEnvironment = JSON.parse(window.__adobe_cep__.getHostEnvironment()); + return this.hostEnvironment; +}; + +/** Closes this extension. */ +CSInterface.prototype.closeExtension = function() +{ + window.__adobe_cep__.closeExtension(); +}; + +/** + * Retrieves a path for which a constant is defined in the system. + * + * @param pathType The path-type constant defined in \c #SystemPath , + * + * @return The platform-specific system path string. + */ +CSInterface.prototype.getSystemPath = function(pathType) +{ + var path = decodeURI(window.__adobe_cep__.getSystemPath(pathType)); + var OSVersion = this.getOSInformation(); + if (OSVersion.indexOf("Windows") >= 0) + { + path = path.replace("file:///", ""); + } + else if (OSVersion.indexOf("Mac") >= 0) + { + path = path.replace("file://", ""); + } + return path; +}; + +/** + * Evaluates a JavaScript script, which can use the JavaScript DOM + * of the host application. + * + * @param script The JavaScript script. + * @param callback Optional. A callback function that receives the result of execution. + * If execution fails, the callback function receives the error message \c EvalScript_ErrMessage. + */ +CSInterface.prototype.evalScript = function(script, callback) +{ + if(callback === null || callback === undefined) + { + callback = function(result){}; + } + window.__adobe_cep__.evalScript(script, callback); +}; + +/** + * Retrieves the unique identifier of the application. + * in which the extension is currently running. + * + * @return The unique ID string. + */ +CSInterface.prototype.getApplicationID = function() +{ + var appId = this.hostEnvironment.appId; + return appId; +}; + +/** + * Retrieves host capability information for the application + * in which the extension is currently running. + * + * @return A \c #HostCapabilities object. + */ +CSInterface.prototype.getHostCapabilities = function() +{ + var hostCapabilities = JSON.parse(window.__adobe_cep__.getHostCapabilities() ); + return hostCapabilities; +}; + +/** + * Triggers a CEP event programmatically. Yoy can use it to dispatch + * an event of a predefined type, or of a type you have defined. + * + * @param event A \c CSEvent object. + */ +CSInterface.prototype.dispatchEvent = function(event) +{ + if (typeof event.data == "object") + { + event.data = JSON.stringify(event.data); + } + + window.__adobe_cep__.dispatchEvent(event); +}; + +/** + * Registers an interest in a CEP event of a particular type, and + * assigns an event handler. + * The event infrastructure notifies your extension when events of this type occur, + * passing the event object to the registered handler function. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.addEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.addEventListener(type, listener, obj); +}; + +/** + * Removes a registered event listener. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method that was registered. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.removeEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.removeEventListener(type, listener, obj); +}; + +/** + * Loads and launches another extension, or activates the extension if it is already loaded. + * + * @param extensionId The extension's unique identifier. + * @param startupParams Not currently used, pass "". + * + * @example + * To launch the extension "help" with ID "HLP" from this extension, call: + * requestOpenExtension("HLP", ""); + * + */ +CSInterface.prototype.requestOpenExtension = function(extensionId, params) +{ + window.__adobe_cep__.requestOpenExtension(extensionId, params); +}; + +/** + * Retrieves the list of extensions currently loaded in the current host application. + * The extension list is initialized once, and remains the same during the lifetime + * of the CEP session. + * + * @param extensionIds Optional, an array of unique identifiers for extensions of interest. + * If omitted, retrieves data for all extensions. + * + * @return Zero or more \c #Extension objects. + */ +CSInterface.prototype.getExtensions = function(extensionIds) +{ + var extensionIdsStr = JSON.stringify(extensionIds); + var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr); + + var extensions = JSON.parse(extensionsStr); + return extensions; +}; + +/** + * Retrieves network-related preferences. + * + * @return A JavaScript object containing network preferences. + */ +CSInterface.prototype.getNetworkPreferences = function() +{ + var result = window.__adobe_cep__.getNetworkPreferences(); + var networkPre = JSON.parse(result); + + return networkPre; +}; + +/** + * Initializes the resource bundle for this extension with property values + * for the current application and locale. + * To support multiple locales, you must define a property file for each locale, + * containing keyed display-string values for that locale. + * See localization documentation for Extension Builder and related products. + * + * Keys can be in the + * form key.value="localized string", for use in HTML text elements. + * For example, in this input element, the localized \c key.value string is displayed + * instead of the empty \c value string: + * + * + * + * @return An object containing the resource bundle information. + */ +CSInterface.prototype.initResourceBundle = function() +{ + var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle()); + var resElms = document.querySelectorAll('[data-locale]'); + for (var n = 0; n < resElms.length; n++) + { + var resEl = resElms[n]; + // Get the resource key from the element. + var resKey = resEl.getAttribute('data-locale'); + if (resKey) + { + // Get all the resources that start with the key. + for (var key in resourceBundle) + { + if (key.indexOf(resKey) === 0) + { + var resValue = resourceBundle[key]; + if (key.length == resKey.length) + { + resEl.innerHTML = resValue; + } + else if ('.' == key.charAt(resKey.length)) + { + var attrKey = key.substring(resKey.length + 1); + resEl[attrKey] = resValue; + } + } + } + } + } + return resourceBundle; +}; + +/** + * Writes installation information to a file. + * + * @return The file path. + */ +CSInterface.prototype.dumpInstallationInfo = function() +{ + return window.__adobe_cep__.dumpInstallationInfo(); +}; + +/** + * Retrieves version information for the current Operating System, + * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values. + * + * @return A string containing the OS version, or "unknown Operation System". + * If user customizes the User Agent by setting CEF command parameter "--user-agent", only + * "Mac OS X" or "Windows" will be returned. + */ +CSInterface.prototype.getOSInformation = function() +{ + var userAgent = navigator.userAgent; + + if ((navigator.platform == "Win32") || (navigator.platform == "Windows")) + { + var winVersion = "Windows"; + var winBit = ""; + if (userAgent.indexOf("Windows") > -1) + { + if (userAgent.indexOf("Windows NT 5.0") > -1) + { + winVersion = "Windows 2000"; + } + else if (userAgent.indexOf("Windows NT 5.1") > -1) + { + winVersion = "Windows XP"; + } + else if (userAgent.indexOf("Windows NT 5.2") > -1) + { + winVersion = "Windows Server 2003"; + } + else if (userAgent.indexOf("Windows NT 6.0") > -1) + { + winVersion = "Windows Vista"; + } + else if (userAgent.indexOf("Windows NT 6.1") > -1) + { + winVersion = "Windows 7"; + } + else if (userAgent.indexOf("Windows NT 6.2") > -1) + { + winVersion = "Windows 8"; + } + else if (userAgent.indexOf("Windows NT 6.3") > -1) + { + winVersion = "Windows 8.1"; + } + else if (userAgent.indexOf("Windows NT 10") > -1) + { + winVersion = "Windows 10"; + } + + if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1) + { + winBit = " 64-bit"; + } + else + { + winBit = " 32-bit"; + } + } + + return winVersion + winBit; + } + else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh")) + { + var result = "Mac OS X"; + + if (userAgent.indexOf("Mac OS X") > -1) + { + result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")")); + result = result.replace(/_/g, "."); + } + + return result; + } + + return "Unknown Operation System"; +}; + +/** + * Opens a page in the default system browser. + * + * Since 4.2.0 + * + * @param url The URL of the page/file to open, or the email address. + * Must use HTTP/HTTPS/file/mailto protocol. For example: + * "http://www.adobe.com" + * "https://github.com" + * "file:///C:/log.txt" + * "mailto:test@adobe.com" + * + * @return One of these error codes:\n + *
    \n + *
  • NO_ERROR - 0
  • \n + *
  • ERR_UNKNOWN - 1
  • \n + *
  • ERR_INVALID_PARAMS - 2
  • \n + *
  • ERR_INVALID_URL - 201
  • \n + *
\n + */ +CSInterface.prototype.openURLInDefaultBrowser = function(url) +{ + return cep.util.openURLInDefaultBrowser(url); +}; + +/** + * Retrieves extension ID. + * + * Since 4.2.0 + * + * @return extension ID. + */ +CSInterface.prototype.getExtensionID = function() +{ + return window.__adobe_cep__.getExtensionId(); +}; + +/** + * Retrieves the scale factor of screen. + * On Windows platform, the value of scale factor might be different from operating system's scale factor, + * since host application may use its self-defined scale factor. + * + * Since 4.2.0 + * + * @return One of the following float number. + *
    \n + *
  • -1.0 when error occurs
  • \n + *
  • 1.0 means normal screen
  • \n + *
  • >1.0 means HiDPI screen
  • \n + *
\n + */ +CSInterface.prototype.getScaleFactor = function() +{ + return window.__adobe_cep__.getScaleFactor(); +}; + +/** + * Set a handler to detect any changes of scale factor. This only works on Mac. + * + * Since 4.2.0 + * + * @param handler The function to be called when scale factor is changed. + * + */ +CSInterface.prototype.setScaleFactorChangedHandler = function(handler) +{ + window.__adobe_cep__.setScaleFactorChangedHandler(handler); +}; + +/** + * Retrieves current API version. + * + * Since 4.2.0 + * + * @return ApiVersion object. + * + */ +CSInterface.prototype.getCurrentApiVersion = function() +{ + var apiVersion = JSON.parse(window.__adobe_cep__.getCurrentApiVersion()); + return apiVersion; +}; + +/** + * Set panel flyout menu by an XML. + * + * Since 5.2.0 + * + * Register a callback function for "com.adobe.csxs.events.flyoutMenuClicked" to get notified when a + * menu item is clicked. + * The "data" attribute of event is an object which contains "menuId" and "menuName" attributes. + * + * Register callback functions for "com.adobe.csxs.events.flyoutMenuOpened" and "com.adobe.csxs.events.flyoutMenuClosed" + * respectively to get notified when flyout menu is opened or closed. + * + * @param menu A XML string which describes menu structure. + * An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setPanelFlyoutMenu = function(menu) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeSync("setPanelFlyoutMenu", menu); +}; + +/** + * Updates a menu item in the extension window's flyout menu, by setting the enabled + * and selection status. + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + * + * @return false when the host application does not support this functionality (HostCapabilities.EXTENDED_PANEL_MENU is false). + * Fails silently if menu label is invalid. + * + * @see HostCapabilities.EXTENDED_PANEL_MENU + */ +CSInterface.prototype.updatePanelMenuItem = function(menuItemLabel, enabled, checked) +{ + var ret = false; + if (this.getHostCapabilities().EXTENDED_PANEL_MENU) + { + var itemStatus = new MenuItemStatus(menuItemLabel, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updatePanelMenuItem", JSON.stringify(itemStatus)); + } + return ret; +}; + + +/** + * Set context menu by XML string. + * + * Since 5.2.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item name is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + https://developer.chrome.com/extensions/contextMenus + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + * + * @param menu A XML string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setContextMenu = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenu", menu, callback); +}; + +/** + * Set context menu by JSON string. + * + * Since 6.0.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item label is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + https://developer.chrome.com/extensions/contextMenus + * + * @param menu A JSON string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu JSON: + * + * { + * "menu": [ + * { + * "id": "menuItemId1", + * "label": "testExample1", + * "enabled": true, + * "checkable": true, + * "checked": false, + * "icon": "./image/small_16X16.png" + * }, + * { + * "id": "menuItemId2", + * "label": "testExample2", + * "menu": [ + * { + * "id": "menuItemId2-1", + * "label": "testExample2-1", + * "menu": [ + * { + * "id": "menuItemId2-1-1", + * "label": "testExample2-1-1", + * "enabled": false, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "id": "menuItemId2-2", + * "label": "testExample2-2", + * "enabled": true, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "label": "---" + * }, + * { + * "id": "menuItemId3", + * "label": "testExample3", + * "enabled": false, + * "checkable": true, + * "checked": false + * } + * ] + * } + * + */ +CSInterface.prototype.setContextMenuByJSON = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenuByJSON", menu, callback); +}; + +/** + * Updates a context menu item by setting the enabled and selection status. + * + * Since 5.2.0 + * + * @param menuItemID The menu item ID. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + */ +CSInterface.prototype.updateContextMenuItem = function(menuItemID, enabled, checked) +{ + var itemStatus = new ContextMenuItemStatus(menuItemID, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updateContextMenuItem", JSON.stringify(itemStatus)); +}; + +/** + * Get the visibility status of an extension window. + * + * Since 6.0.0 + * + * @return true if the extension window is visible; false if the extension window is hidden. + */ +CSInterface.prototype.isWindowVisible = function() +{ + return window.__adobe_cep__.invokeSync("isWindowVisible", ""); +}; + +/** + * Resize extension's content to the specified dimensions. + * 1. Works with modal and modeless extensions in all Adobe products. + * 2. Extension's manifest min/max size constraints apply and take precedence. + * 3. For panel extensions + * 3.1 This works in all Adobe products except: + * * Premiere Pro + * * Prelude + * * After Effects + * 3.2 When the panel is in certain states (especially when being docked), + * it will not change to the desired dimensions even when the + * specified size satisfies min/max constraints. + * + * Since 6.0.0 + * + * @param width The new width + * @param height The new height + */ +CSInterface.prototype.resizeContent = function(width, height) +{ + window.__adobe_cep__.resizeContent(width, height); +}; + +/** + * Register the invalid certificate callback for an extension. + * This callback will be triggered when the extension tries to access the web site that contains the invalid certificate on the main frame. + * But if the extension does not call this function and tries to access the web site containing the invalid certificate, a default error page will be shown. + * + * Since 6.1.0 + * + * @param callback the callback function + */ +CSInterface.prototype.registerInvalidCertificateCallback = function(callback) +{ + return window.__adobe_cep__.registerInvalidCertificateCallback(callback); +}; + +/** + * Register an interest in some key events to prevent them from being sent to the host application. + * + * This function works with modeless extensions and panel extensions. + * Generally all the key events will be sent to the host application for these two extensions if the current focused element + * is not text input or dropdown, + * If you want to intercept some key events and want them to be handled in the extension, please call this function + * in advance to prevent them being sent to the host application. + * + * Since 6.1.0 + * + * @param keyEventsInterest A JSON string describing those key events you are interested in. A null object or + an empty string will lead to removing the interest + * + * This JSON string should be an array, each object has following keys: + * + * keyCode: [Required] represents an OS system dependent virtual key code identifying + * the unmodified value of the pressed key. + * ctrlKey: [optional] a Boolean that indicates if the control key was pressed (true) or not (false) when the event occurred. + * altKey: [optional] a Boolean that indicates if the alt key was pressed (true) or not (false) when the event occurred. + * shiftKey: [optional] a Boolean that indicates if the shift key was pressed (true) or not (false) when the event occurred. + * metaKey: [optional] (Mac Only) a Boolean that indicates if the Meta key was pressed (true) or not (false) when the event occurred. + * On Macintosh keyboards, this is the command key. To detect Windows key on Windows, please use keyCode instead. + * An example JSON string: + * + * [ + * { + * "keyCode": 48 + * }, + * { + * "keyCode": 123, + * "ctrlKey": true + * }, + * { + * "keyCode": 123, + * "ctrlKey": true, + * "metaKey": true + * } + * ] + * + */ +CSInterface.prototype.registerKeyEventsInterest = function(keyEventsInterest) +{ + return window.__adobe_cep__.registerKeyEventsInterest(keyEventsInterest); +}; + +/** + * Set the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @param title The window title. + */ +CSInterface.prototype.setWindowTitle = function(title) +{ + window.__adobe_cep__.invokeSync("setWindowTitle", title); +}; + +/** + * Get the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @return The window title. + */ +CSInterface.prototype.getWindowTitle = function() +{ + return window.__adobe_cep__.invokeSync("getWindowTitle", ""); +}; diff --git a/openpype/hosts/photoshop/api/extension/client/client.js b/openpype/hosts/photoshop/api/extension/client/client.js new file mode 100644 index 0000000000..f4ba4cfe47 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/client.js @@ -0,0 +1,300 @@ + // client facing part of extension, creates WSRPC client (jsx cannot + // do that) + // consumes RPC calls from server (OpenPype) calls ./host/index.jsx and + // returns values back (in json format) + + var logReturn = function(result){ log.warn('Result: ' + result);}; + + var csInterface = new CSInterface(); + + log.warn("script start"); + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + function myCallBack(){ + log.warn("Triggered index.jsx"); + } + // importing through manifest.xml isn't working because relative paths + // possibly TODO + jsx.evalFile('./host/index.jsx', myCallBack); + + function runEvalScript(script) { + // because of asynchronous nature of functions in jsx + // this waits for response + return new Promise(function(resolve, reject){ + csInterface.evalScript(script, resolve); + }); + } + + /** main entry point **/ + startUp("WEBSOCKET_URL"); + + // get websocket server url from environment value + async function startUp(url){ + log.warn("url", url); + promis = runEvalScript("getEnv('" + url + "')"); + + var res = await promis; + // run rest only after resolved promise + main(res); + } + + function get_extension_version(){ + /** Returns version number from extension manifest.xml **/ + log.debug("get_extension_version") + var path = csInterface.getSystemPath(SystemPath.EXTENSION); + log.debug("extension path " + path); + + var result = window.cep.fs.readFile(path + "/CSXS/manifest.xml"); + var version = undefined; + if(result.err === 0){ + if (window.DOMParser) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(result.data.toString(), 'text/xml'); + const children = xmlDoc.children; + + for (let i = 0; i <= children.length; i++) { + if (children[i] && children[i].getAttribute('ExtensionBundleVersion')) { + version = children[i].getAttribute('ExtensionBundleVersion'); + } + } + } + } + return version + } + + function main(websocket_url){ + // creates connection to 'websocket_url', registers routes + log.warn("websocket_url", websocket_url); + var default_url = 'ws://localhost:8099/ws/'; + + if (websocket_url == ''){ + websocket_url = default_url; + } + log.warn("connecting to:", websocket_url); + RPC = new WSRPC(websocket_url, 5000); // spin connection + + RPC.connect(); + + log.warn("connected"); + + function EscapeStringForJSX(str){ + // Replaces: + // \ with \\ + // ' with \' + // " with \" + // See: https://stackoverflow.com/a/3967927/5285364 + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); + } + + RPC.addRoute('Photoshop.open', function (data) { + log.warn('Server called client route "open":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("fileOpen('" + escapedPath +"')") + .then(function(result){ + log.warn("open: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.read', function (data) { + log.warn('Server called client route "read":', data); + return runEvalScript("getHeadline()") + .then(function(result){ + log.warn("getHeadline: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_layers', function (data) { + log.warn('Server called client route "get_layers":', data); + return runEvalScript("getLayers()") + .then(function(result){ + log.warn("getLayers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.set_visible', function (data) { + log.warn('Server called client route "set_visible":', data); + return runEvalScript("setVisible(" + data.layer_id + ", " + + data.visibility + ")") + .then(function(result){ + log.warn("setVisible: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_active_document_name', function (data) { + log.warn('Server called client route "get_active_document_name":', + data); + return runEvalScript("getActiveDocumentName()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_active_document_full_name', function (data) { + log.warn('Server called client route ' + + '"get_active_document_full_name":', data); + return runEvalScript("getActiveDocumentFullName()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.save', function (data) { + log.warn('Server called client route "save":', data); + + return runEvalScript("save()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_selected_layers', function (data) { + log.warn('Server called client route "get_selected_layers":', data); + + return runEvalScript("getSelectedLayers()") + .then(function(result){ + log.warn("get_selected_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.create_group', function (data) { + log.warn('Server called client route "create_group":', data); + + return runEvalScript("createGroup('" + data.name + "')") + .then(function(result){ + log.warn("createGroup: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.group_selected_layers', function (data) { + log.warn('Server called client route "group_selected_layers":', + data); + + return runEvalScript("groupSelectedLayers(null, "+ + "'" + data.name +"')") + .then(function(result){ + log.warn("group_selected_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.import_smart_object', function (data) { + log.warn('Server called client "import_smart_object":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("importSmartObject('" + escapedPath +"', " + + "'"+ data.name +"',"+ + + data.as_reference +")") + .then(function(result){ + log.warn("import_smart_object: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.replace_smart_object', function (data) { + log.warn('Server called route "replace_smart_object":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("replaceSmartObjects("+data.layer_id+"," + + "'" + escapedPath +"',"+ + "'"+ data.name +"')") + .then(function(result){ + log.warn("replaceSmartObjects: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.delete_layer', function (data) { + log.warn('Server called route "delete_layer":', data); + return runEvalScript("deleteLayer("+data.layer_id+")") + .then(function(result){ + log.warn("delete_layer: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.rename_layer', function (data) { + log.warn('Server called route "rename_layer":', data); + return runEvalScript("renameLayer("+data.layer_id+", " + + "'"+ data.name +"')") + .then(function(result){ + log.warn("rename_layer: " + result); + return result; + }); +}); + + RPC.addRoute('Photoshop.select_layers', function (data) { + log.warn('Server called client route "select_layers":', data); + + return runEvalScript("selectLayers('" + data.layers +"')") + .then(function(result){ + log.warn("select_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.is_saved', function (data) { + log.warn('Server called client route "is_saved":', data); + + return runEvalScript("isSaved()") + .then(function(result){ + log.warn("is_saved: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.saveAs', function (data) { + log.warn('Server called client route "saveAsJPEG":', data); + var escapedPath = EscapeStringForJSX(data.image_path); + return runEvalScript("saveAs('" + escapedPath + "', " + + "'" + data.ext + "', " + + data.as_copy + ")") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.imprint', function (data) { + log.warn('Server called client route "imprint":', data); + var escaped = data.payload.replace(/\n/g, "\\n"); + return runEvalScript("imprint('" + escaped + "')") + .then(function(result){ + log.warn("imprint: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_extension_version', function (data) { + log.warn('Server called client route "get_extension_version":', data); + return get_extension_version(); + }); + + RPC.addRoute('Photoshop.close', function (data) { + log.warn('Server called client route "close":', data); + return runEvalScript("close()"); + }); + + RPC.call('Photoshop.ping').then(function (data) { + log.warn('Result for calling server route "ping": ', data); + return runEvalScript("ping()") + .then(function(result){ + log.warn("ping: " + result); + return result; + }); + + }, function (error) { + log.warn(error); + }); + + } + + log.warn("end script"); diff --git a/openpype/hosts/photoshop/api/extension/client/loglevel.min.js b/openpype/hosts/photoshop/api/extension/client/loglevel.min.js new file mode 100644 index 0000000000..648d7e9ff6 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/loglevel.min.js @@ -0,0 +1,2 @@ +/*! loglevel - v1.6.8 - https://github.com/pimterry/loglevel - (c) 2020 Tim Perry - licensed MIT */ +!function(a,b){"use strict";"function"==typeof define&&define.amd?define(b):"object"==typeof module&&module.exports?module.exports=b():a.log=b()}(this,function(){"use strict";function a(a,b){var c=a[b];if("function"==typeof c.bind)return c.bind(a);try{return Function.prototype.bind.call(c,a)}catch(b){return function(){return Function.prototype.apply.apply(c,[a,arguments])}}}function b(){console.log&&(console.log.apply?console.log.apply(console,arguments):Function.prototype.apply.apply(console.log,[console,arguments])),console.trace&&console.trace()}function c(c){return"debug"===c&&(c="log"),typeof console!==i&&("trace"===c&&j?b:void 0!==console[c]?a(console,c):void 0!==console.log?a(console,"log"):h)}function d(a,b){for(var c=0;c=0&&b<=j.levels.SILENT))throw"log.setLevel() called with invalid level: "+b;if(h=b,!1!==c&&e(b),d.call(j,b,a),typeof console===i&&b 1 && arguments[1] !== undefined ? arguments[1] : 1000; + + _classCallCheck(this, WSRPC); + + var self = this; + URL = getAbsoluteWsUrl(URL); + self.id = 1; + self.eventId = 0; + self.socketStarted = false; + self.eventStore = { + onconnect: {}, + onerror: {}, + onclose: {}, + onchange: {} + }; + self.connectionNumber = 0; + self.oneTimeEventStore = { + onconnect: [], + onerror: [], + onclose: [], + onchange: [] + }; + self.callQueue = []; + + function createSocket() { + var ws = new WebSocket(URL); + + var rejectQueue = function rejectQueue() { + self.connectionNumber++; // rejects incoming calls + + var deferred; //reject all pending calls + + while (0 < self.callQueue.length) { + var callObj = self.callQueue.shift(); + deferred = self.store[callObj.id]; + delete self.store[callObj.id]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } // reject all from the store + + + for (var key in self.store) { + if (!self.store.hasOwnProperty(key)) continue; + deferred = self.store[key]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } + }; + + function reconnect(callEvents) { + setTimeout(function () { + try { + self.socket = createSocket(); + self.id = 1; + } catch (exc) { + callEvents('onerror', exc); + delete self.socket; + console.error(exc); + } + }, reconnectTimeout); + } + + ws.onclose = function (err) { + log('ONCLOSE CALLED', 'STATE', self.public.state()); + trace(err); + + for (var serial in self.store) { + if (!self.store.hasOwnProperty(serial)) continue; + + if (self.store[serial].hasOwnProperty('reject')) { + self.store[serial].reject('Connection closed'); + } + } + + rejectQueue(); + callEvents('onclose', err); + callEvents('onchange', err); + reconnect(callEvents); + }; + + ws.onerror = function (err) { + log('ONERROR CALLED', 'STATE', self.public.state()); + trace(err); + rejectQueue(); + callEvents('onerror', err); + callEvents('onchange', err); + log('WebSocket has been closed by error: ', err); + }; + + function tryCallEvent(func, event) { + try { + return func(event); + } catch (e) { + if (e.hasOwnProperty('stack')) { + log(e.stack); + } else { + log('Event function', func, 'raised unknown error:', e); + } + + console.error(e); + } + } + + function callEvents(evName, event) { + while (0 < self.oneTimeEventStore[evName].length) { + var deferred = self.oneTimeEventStore[evName].shift(); + if (deferred.hasOwnProperty('resolve') && deferred.promise.isPending()) deferred.resolve(); + } + + for (var i in self.eventStore[evName]) { + if (!self.eventStore[evName].hasOwnProperty(i)) continue; + var cur = self.eventStore[evName][i]; + tryCallEvent(cur, event); + } + } + + ws.onopen = function (ev) { + log('ONOPEN CALLED', 'STATE', self.public.state()); + trace(ev); + + while (0 < self.callQueue.length) { + // noinspection JSUnresolvedFunction + self.socket.send(JSON.stringify(self.callQueue.shift(), 0, 1)); + } + + callEvents('onconnect', ev); + callEvents('onchange', ev); + }; + + function handleCall(self, data) { + if (!self.routes.hasOwnProperty(data.method)) throw new Error('Route not found'); + var connectionNumber = self.connectionNumber; + var deferred = new Deferred(); + deferred.promise.then(function (result) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + result: result + })); + }, function (error) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + error: error + })); + }); + var func = self.routes[data.method]; + if (self.asyncRoutes[data.method]) return func.apply(deferred, [data.params]); + + function badPromise() { + throw new Error("You should register route with async flag."); + } + + var promiseMock = { + resolve: badPromise, + reject: badPromise + }; + + try { + deferred.resolve(func.apply(promiseMock, [data.params])); + } catch (e) { + deferred.reject(e); + console.error(e); + } + } + + function handleError(self, data) { + if (!self.store.hasOwnProperty(data.id)) return log('Unknown callback'); + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + log('REJECTING', data.error); + deferred.reject(data.error); + } + + function handleResult(self, data) { + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + + if (data.hasOwnProperty('result')) { + return deferred.resolve(data.result); + } + + return deferred.reject(data.error); + } + + ws.onmessage = function (message) { + log('ONMESSAGE CALLED', 'STATE', self.public.state()); + trace(message); + if (message.type !== 'message') return; + var data; + + try { + data = JSON.parse(message.data); + log(data); + + if (data.hasOwnProperty('method')) { + return handleCall(self, data); + } else if (data.hasOwnProperty('error') && data.error === null) { + return handleError(self, data); + } else { + return handleResult(self, data); + } + } catch (exception) { + var err = { + error: exception.message, + result: null, + id: data ? data.id : null + }; + self.socket.send(JSON.stringify(err)); + console.error(exception); + } + }; + + return ws; + } + + function makeCall(func, args, params) { + self.id += 2; + var deferred = new Deferred(); + var callObj = Object.freeze({ + id: self.id, + method: func, + params: args + }); + var state = self.public.state(); + + if (state === 'OPEN') { + self.store[self.id] = deferred; + self.socket.send(JSON.stringify(callObj)); + } else if (state === 'CONNECTING') { + log('SOCKET IS', state); + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } else { + log('SOCKET IS', state); + + if (params && params['noWait']) { + deferred.reject("Socket is: ".concat(state)); + } else { + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } + } + + return deferred.promise; + } + + self.asyncRoutes = {}; + self.routes = {}; + self.store = {}; + self.public = Object.freeze({ + call: function call(func, args, params) { + return makeCall(func, args, params); + }, + addRoute: function addRoute(route, callback, isAsync) { + self.asyncRoutes[route] = isAsync || false; + self.routes[route] = callback; + }, + deleteRoute: function deleteRoute(route) { + delete self.asyncRoutes[route]; + return delete self.routes[route]; + }, + addEventListener: function addEventListener(event, func) { + var eventId = self.eventId++; + self.eventStore[event][eventId] = func; + return eventId; + }, + removeEventListener: function removeEventListener(event, index) { + if (self.eventStore[event].hasOwnProperty(index)) { + delete self.eventStore[event][index]; + return true; + } else { + return false; + } + }, + onEvent: function onEvent(event) { + var deferred = new Deferred(); + self.oneTimeEventStore[event].push(deferred); + return deferred.promise; + }, + destroy: function destroy() { + return self.socket.close(); + }, + state: function state() { + return readyState[this.stateCode()]; + }, + stateCode: function stateCode() { + if (self.socketStarted && self.socket) return self.socket.readyState; + return 3; + }, + connect: function connect() { + self.socketStarted = true; + self.socket = createSocket(); + } + }); + self.public.addRoute('log', function (argsObj) { + //console.info("Websocket sent: ".concat(argsObj)); + }); + self.public.addRoute('ping', function (data) { + return data; + }); + return self.public; + }; + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + return WSRPC; + +})); +//# sourceMappingURL=wsrpc.js.map diff --git a/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js b/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js new file mode 100644 index 0000000000..f1264b91c4 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js @@ -0,0 +1 @@ +!function(global,factory){"object"==typeof exports&&"undefined"!=typeof module?module.exports=factory():"function"==typeof define&&define.amd?define(factory):(global=global||self).WSRPC=factory()}(this,function(){"use strict";function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}function Deferred(){_classCallCheck(this,Deferred);var self=this;function wrapper(func){return function(){if(!self.done)return self.done=!0,func.apply(this,arguments);console.error(new Error("Promise already done"))}}return self.resolve=null,self.reject=null,self.done=!1,self.promise=new Promise(function(resolve,reject){self.resolve=wrapper(resolve),self.reject=wrapper(reject)}),self.promise.isPending=function(){return!self.done},self}function logGroup(group,level,args){console.group(group),console[level].apply(this,args),console.groupEnd()}function log(){WSRPC.DEBUG&&logGroup("WSRPC.DEBUG","trace",arguments)}function trace(msg){if(WSRPC.TRACE){var payload=msg;"data"in msg&&(payload=JSON.parse(msg.data)),logGroup("WSRPC.TRACE","trace",[payload])}}var readyState=Object.freeze({0:"CONNECTING",1:"OPEN",2:"CLOSING",3:"CLOSED"}),WSRPC=function WSRPC(URL){var reconnectTimeout=1 // +// forceEval is now by default true // +// It wraps the scripts in a try catch and an eval providing useful error handling // +// One can set in the jsx engine $.includeStack = true to return the call stack in the event of an error // +/////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/////////////////////////////////////////////////////////////////////////////////////////////////////////// +// JSX.js for calling jsx code from the js engine // +// 2 methods included // +// 1) jsx.evalScript AKA jsx.eval // +// 2) jsx.evalFile AKA jsx.file // +// Special features // +// 1) Allows all changes in your jsx code to be reloaded into your extension at the click of a button // +// 2) Can enable the $.fileName property to work and provides a $.__fileName() method as an alternative // +// 3) Can force a callBack result from InDesign // +// 4) No more csInterface.evalScript('alert("hello "' + title + " " + name + '");') // +// use jsx.evalScript('alert("hello __title__ __name__");', {title: title, name: name}); // +// 5) execute jsx files from your jsx folder like this jsx.evalFile('myFabJsxScript.jsx'); // +// or from a relative path jsx.evalFile('../myFabScripts/myFabJsxScript.jsx'); // +// or from an absolute url jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) // +// or from an absolute url jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) // +// 6) Parameter can be entered in the from of a parameter list which can be in any order or as an object // +// 7) Not camelCase sensitive (very useful for the illiterate) // +// Dead easy to use BUT SPEND THE 3 TO 5 MINUTES IT SHOULD TAKE TO READ THE INSTRUCTIONS // +/////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/* jshint undef:true, unused:true, esversion:6 */ + +////////////////////////////////////// +// jsx is the interface for the API // +////////////////////////////////////// + +var jsx; + +// Wrap everything in an anonymous function to prevent leeks +(function() { + ///////////////////////////////////////////////////////////////////// + // Substitute some CSInterface functions to avoid dependency on it // + ///////////////////////////////////////////////////////////////////// + + var __dirname = (function() { + var path, isMac; + path = decodeURI(window.__adobe_cep__.getSystemPath('extension')); + isMac = navigator.platform[0] === 'M'; // [M]ac + path = path.replace('file://' + (isMac ? '' : '/'), ''); + return path; + })(); + + var evalScript = function(script, callback) { + callback = callback || function() {}; + window.__adobe_cep__.evalScript(script, callback); + }; + + + //////////////////////////////////////////// + // In place of using the node path module // + //////////////////////////////////////////// + + // jshint undef: true, unused: true + + // A very minified version of the NodeJs Path module!! + // For use outside of NodeJs + // Majorly nicked by Trevor from Joyent + var path = (function() { + + var isString = function(arg) { + return typeof arg === 'string'; + }; + + // var isObject = function(arg) { + // return typeof arg === 'object' && arg !== null; + // }; + + var basename = function(path) { + if (!isString(path)) { + throw new TypeError('Argument to path.basename must be a string'); + } + var bits = path.split(/[\/\\]/g); + return bits[bits.length - 1]; + }; + + // jshint undef: true + // Regex to split a windows path into three parts: [*, device, slash, + // tail] windows-only + var splitDeviceRe = + /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; + + // Regex to split the tail part of the above into [*, dir, basename, ext] + // var splitTailRe = + // /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; + + var win32 = {}; + // Function to split a filename into [root, dir, basename, ext] + // var win32SplitPath = function(filename) { + // // Separate device+slash from tail + // var result = splitDeviceRe.exec(filename), + // device = (result[1] || '') + (result[2] || ''), + // tail = result[3] || ''; + // // Split the tail into dir, basename and extension + // var result2 = splitTailRe.exec(tail), + // dir = result2[1], + // basename = result2[2], + // ext = result2[3]; + // return [device, dir, basename, ext]; + // }; + + var win32StatPath = function(path) { + var result = splitDeviceRe.exec(path), + device = result[1] || '', + isUnc = !!device && device[1] !== ':'; + return { + device: device, + isUnc: isUnc, + isAbsolute: isUnc || !!result[2], // UNC paths are always absolute + tail: result[3] + }; + }; + + var normalizeUNCRoot = function(device) { + return '\\\\' + device.replace(/^[\\\/]+/, '').replace(/[\\\/]+/g, '\\'); + }; + + var normalizeArray = function(parts, allowAboveRoot) { + var res = []; + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + + // ignore empty parts + if (!p || p === '.') + continue; + + if (p === '..') { + if (res.length && res[res.length - 1] !== '..') { + res.pop(); + } else if (allowAboveRoot) { + res.push('..'); + } + } else { + res.push(p); + } + } + + return res; + }; + + win32.normalize = function(path) { + var result = win32StatPath(path), + device = result.device, + isUnc = result.isUnc, + isAbsolute = result.isAbsolute, + tail = result.tail, + trailingSlash = /[\\\/]$/.test(tail); + + // Normalize the tail path + tail = normalizeArray(tail.split(/[\\\/]+/), !isAbsolute).join('\\'); + + if (!tail && !isAbsolute) { + tail = '.'; + } + if (tail && trailingSlash) { + tail += '\\'; + } + + // Convert slashes to backslashes when `device` points to an UNC root. + // Also squash multiple slashes into a single one where appropriate. + if (isUnc) { + device = normalizeUNCRoot(device); + } + + return device + (isAbsolute ? '\\' : '') + tail; + }; + win32.join = function() { + var paths = []; + for (var i = 0; i < arguments.length; i++) { + var arg = arguments[i]; + if (!isString(arg)) { + throw new TypeError('Arguments to path.join must be strings'); + } + if (arg) { + paths.push(arg); + } + } + + var joined = paths.join('\\'); + + // Make sure that the joined path doesn't start with two slashes, because + // normalize() will mistake it for an UNC path then. + // + // This step is skipped when it is very clear that the user actually + // intended to point at an UNC path. This is assumed when the first + // non-empty string arguments starts with exactly two slashes followed by + // at least one more non-slash character. + // + // Note that for normalize() to treat a path as an UNC path it needs to + // have at least 2 components, so we don't filter for that here. + // This means that the user can use join to construct UNC paths from + // a server name and a share name; for example: + // path.join('//server', 'share') -> '\\\\server\\share\') + if (!/^[\\\/]{2}[^\\\/]/.test(paths[0])) { + joined = joined.replace(/^[\\\/]{2,}/, '\\'); + } + return win32.normalize(joined); + }; + + var posix = {}; + + // posix version + posix.join = function() { + var path = ''; + for (var i = 0; i < arguments.length; i++) { + var segment = arguments[i]; + if (!isString(segment)) { + throw new TypeError('Arguments to path.join must be strings'); + } + if (segment) { + if (!path) { + path += segment; + } else { + path += '/' + segment; + } + } + } + return posix.normalize(path); + }; + + // path.normalize(path) + // posix version + posix.normalize = function(path) { + var isAbsolute = path.charAt(0) === '/', + trailingSlash = path && path[path.length - 1] === '/'; + + // Normalize the path + path = normalizeArray(path.split('/'), !isAbsolute).join('/'); + + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + + return (isAbsolute ? '/' : '') + path; + }; + + win32.basename = posix.basename = basename; + + this.win32 = win32; + this.posix = posix; + return (navigator.platform[0] === 'M') ? posix : win32; + })(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + // The is the "main" function which is to be prototyped // + // It run a small snippet in the jsx engine that // + // 1) Assigns $.__dirname with the value of the extensions __dirname base path // + // 2) Sets up a method $.__fileName() for retrieving from within the jsx script it's $.fileName value // + // more on that method later // + // At the end of the script the global declaration jsx = new Jsx(); has been made. // + // If you like you can remove that and include in your relevant functions // + // var jsx = new Jsx(); You would never call the Jsx function without the "new" declaration // + //////////////////////////////////////////////////////////////////////////////////////////////////////// + var Jsx = function() { + var jsxScript; + // Setup jsx function to enable the jsx scripts to easily retrieve their file location + jsxScript = [ + '$.level = 0;', + 'if(!$.__fileNames){', + ' $.__fileNames = {};', + ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), + ' $.__fileName = function(name){', + ' name = name || $.fileName;', + ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', + ' };', + '}' + ].join(''); + evalScript(jsxScript); + return this; + }; + + /** + * [evalScript] For calling jsx scripts from the js engine + * + * The jsx.evalScript method is used for calling jsx scripts directly from the js engine + * Allows for easy replacement i.e. variable insertions and for forcing eval. + * For convenience jsx.eval or jsx.script or jsx.evalscript can be used instead of calling jsx.evalScript + * + * @param {String} jsxScript + * The string that makes up the jsx script + * it can contain a simple template like syntax for replacements + * 'alert("__foo__");' + * the __foo__ will be replaced as per the replacements parameter + * + * @param {Function} callback + * The callback function you want the jsx script to trigger on completion + * The result of the jsx script is passed as the argument to that function + * The function can exist in some other file. + * Note that InDesign does not automatically pass the callBack as a string. + * Either write your InDesign in a way that it returns a sting the form of + * return 'this is my result surrounded by quotes' + * or use the force eval option + * [Optional DEFAULT no callBack] + * + * @param {Object} replacements + * The replacements to make on the jsx script + * given the following script (template) + * 'alert("__message__: " + __val__);' + * and we want to change the script to + * 'alert("I was born in the year: " + 1234);' + * we would pass the following object + * {"message": 'I was born in the year', "val": 1234} + * or if not using reserved words like do we can leave out the key quotes + * {message: 'I was born in the year', val: 1234} + * [Optional DEFAULT no replacements] + * + * @param {Bolean} forceEval + * If the script should be wrapped in an eval and try catch + * This will 1) provide useful error feedback if heaven forbid it is needed + * 2) The result will be a string which is required for callback results in InDesign + * [Optional DEFAULT true] + * + * Note 1) The order of the parameters is irrelevant + * Note 2) One can pass the arguments as an object if desired + * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); + * is the same as + * jsx.evalScript({ + * script: 'alert("__myMessage__");', + * replacements: {myMessage: 'Hi there'}, + * callBack: myCallBackFunction, + * eval: true + * }); + * note that either lower or camelCase key names are valid + * i.e. both callback or callBack will work + * + * The following keys are the same jsx || script || jsxScript || jsxscript || file + * The following keys are the same callBack || callback + * The following keys are the same replacements || replace + * The following keys are the same eval || forceEval || forceeval + * The following keys are the same forceEvalScript || forceevalscript || evalScript || evalscript; + * + * @return {Boolean} if the jsxScript was executed or not + */ + + Jsx.prototype.evalScript = function() { + var arg, i, key, replaceThis, withThis, args, callback, forceEval, replacements, jsxScript, isBin; + + ////////////////////////////////////////////////////////////////////////////////////// + // sort out order which arguments into jsxScript, callback, replacements, forceEval // + ////////////////////////////////////////////////////////////////////////////////////// + + args = arguments; + + // Detect if the parameters were passed as an object and if so allow for various keys + if (args.length === 1 && (arg = args[0]) instanceof Object) { + jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; + callback = arg.callBack || arg.callback; + replacements = arg.replacements || arg.replace; + forceEval = arg.eval || arg.forceEval || arg.forceeval; + } else { + for (i = 0; i < 4; i++) { + arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg.constructor === String) { + jsxScript = arg; + continue; + } + if (arg.constructor === Object) { + replacements = arg; + continue; + } + if (arg.constructor === Function) { + callback = arg; + continue; + } + if (arg === false) { + forceEval = false; + } + } + } + + // If no script provide then not too much to do! + if (!jsxScript) { + return false; + } + + // Have changed the forceEval default to be true as I prefer the error handling + if (forceEval !== false) { + forceEval = true; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // On Illustrator and other apps the result of the jsx script is automatically passed as a string // + // if you have a "script" containing the single number 1 and nothing else then the callBack will register as "1" // + // On InDesign that same script will provide a blank callBack // + // Let's say we have a callBack function var callBack = function(result){alert(result);} // + // On Ai your see the 1 in the alert // + // On ID your just see a blank alert // + // To see the 1 in the alert you need to convert the result to a string and then it will show // + // So if we rewrite out 1 byte script to '1' i.e. surround the 1 in quotes then the call back alert will show 1 // + // If the scripts planed one can make sure that the results always passed as a string (including errors) // + // otherwise one can wrap the script in an eval and then have the result passed as a string // + // I have not gone through all the apps but can say // + // for Ai you never need to set the forceEval to true // + // for ID you if you have not coded your script appropriately and your want to send a result to the callBack then set forceEval to true // + // I changed this that even on Illustrator it applies the try catch, Note the try catch will fail if $.level is set to 1 // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + if (forceEval) { + + isBin = (jsxScript.substring(0, 10) === '@JSXBIN@ES') ? '' : '\n'; + jsxScript = ( + // "\n''') + '';} catch(e){(function(e){var n, a=[]; for (n in e){a.push(n + ': ' + e[n])}; return a.join('\n')})(e)}"); + // "\n''') + '';} catch(e){e + (e.line ? ('\\nLine ' + (+e.line - 1)) : '')}"); + [ + "$.level = 0;", + "try{eval('''" + isBin, // need to add an extra line otherwise #targetengine doesn't work ;-] + jsxScript.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"') + "\n''') + '';", + "} catch (e) {", + " (function(e) {", + " var line, sourceLine, name, description, ErrorMessage, fileName, start, end, bug;", + " line = +e.line" + (isBin === '' ? ';' : ' - 1;'), // To take into account the extra line added + " fileName = File(e.fileName).fsName;", + " sourceLine = line && e.source.split(/[\\r\\n]/)[line];", + " name = e.name;", + " description = e.description;", + " ErrorMessage = name + ' ' + e.number + ': ' + description;", + " if (fileName.length && !(/[\\/\\\\]\\d+$/.test(fileName))) {", + " ErrorMessage += '\\nFile: ' + fileName;", + " line++;", + " }", + " if (line){", + " ErrorMessage += '\\nLine: ' + line +", + " '-> ' + ((sourceLine.length < 300) ? sourceLine : sourceLine.substring(0,300) + '...');", + " }", + " if (e.start) {ErrorMessage += '\\nBug: ' + e.source.substring(e.start - 1, e.end)}", + " if ($.includeStack) {ErrorMessage += '\\nStack:' + $.stack;}", + " return ErrorMessage;", + " })(e);", + "}" + ].join('') + ); + + } + + ///////////////////////////////////////////////////////////// + // deal with the replacements // + // Note it's probably better to use ${template} `literals` // + ///////////////////////////////////////////////////////////// + + if (replacements) { + for (key in replacements) { + if (replacements.hasOwnProperty(key)) { + replaceThis = new RegExp('__' + key + '__', 'g'); + withThis = replacements[key]; + jsxScript = jsxScript.replace(replaceThis, withThis + ''); + } + } + } + + + try { + evalScript(jsxScript, callback); + return true; + } catch (err) { + //////////////////////////////////////////////// + // Do whatever error handling you want here ! // + //////////////////////////////////////////////// + var newErr; + newErr = new Error(err); + alert('Error Eek: ' + newErr.stack); + return false; + } + + }; + + + /** + * [evalFile] For calling jsx scripts from the js engine + * + * The jsx.evalFiles method is used for executing saved jsx scripts + * where the jsxScript parameter is a string of the jsx scripts file location. + * For convenience jsx.file or jsx.evalfile can be used instead of jsx.evalFile + * + * @param {String} file + * The path to jsx script + * If only the base name is provided then the path will be presumed to be the + * To execute files stored in the jsx folder located in the __dirname folder use + * jsx.evalFile('myFabJsxScript.jsx'); + * To execute files stored in the a folder myFabScripts located in the __dirname folder use + * jsx.evalFile('./myFabScripts/myFabJsxScript.jsx'); + * To execute files stored in the a folder myFabScripts located at an absolute url use + * jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) + * or jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) + * + * @param {Function} callback + * The callback function you want the jsx script to trigger on completion + * The result of the jsx script is passed as the argument to that function + * The function can exist in some other file. + * Note that InDesign does not automatically pass the callBack as a string. + * Either write your InDesign in a way that it returns a sting the form of + * return 'this is my result surrounded by quotes' + * or use the force eval option + * [Optional DEFAULT no callBack] + * + * @param {Object} replacements + * The replacements to make on the jsx script + * give the following script (template) + * 'alert("__message__: " + __val__);' + * and we want to change the script to + * 'alert("I was born in the year: " + 1234);' + * we would pass the following object + * {"message": 'I was born in the year', "val": 1234} + * or if not using reserved words like do we can leave out the key quotes + * {message: 'I was born in the year', val: 1234} + * By default when possible the forceEvalScript will be set to true + * The forceEvalScript option cannot be true when there are replacements + * To force the forceEvalScript to be false you can send a blank set of replacements + * jsx.evalFile('myFabScript.jsx', {}); Will NOT be executed using the $.evalScript method + * jsx.evalFile('myFabScript.jsx'); Will YES be executed using the $.evalScript method + * see the forceEvalScript parameter for details on this + * [Optional DEFAULT no replacements] + * + * @param {Bolean} forceEval + * If the script should be wrapped in an eval and try catch + * This will 1) provide useful error feedback if heaven forbid it is needed + * 2) The result will be a string which is required for callback results in InDesign + * [Optional DEFAULT true] + * + * If no replacements are needed then the jsx script is be executed by using the $.evalFile method + * This exposes the true value of the $.fileName property + * In such a case it's best to avoid using the $.__fileName() with no base name as it won't work + * BUT one can still use the $.__fileName('baseName') method which is more accurate than the standard $.fileName property + * Let's say you have a Drive called "Graphics" AND YOU HAVE a root folder on your "main" drive called "Graphics" + * You call a script jsx.evalFile('/Volumes/Graphics/myFabScript.jsx'); + * $.fileName will give you '/Graphics/myFabScript.jsx' which is wrong + * $.__fileName('myFabScript.jsx') will give you '/Volumes/Graphics/myFabScript.jsx' which is correct + * $.__fileName() will not give you a reliable result + * Note that if your calling multiple versions of myFabScript.jsx stored in multiple folders then you can get stuffed! + * i.e. if the fileName is important to you then don't do that. + * It also will force the result of the jsx file as a string which is particularly useful for InDesign callBacks + * + * Note 1) The order of the parameters is irrelevant + * Note 2) One can pass the arguments as an object if desired + * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); + * is the same as + * jsx.evalScript({ + * script: 'alert("__myMessage__");', + * replacements: {myMessage: 'Hi there'}, + * callBack: myCallBackFunction, + * eval: false, + * }); + * note that either lower or camelCase key names or valid + * i.e. both callback or callBack will work + * + * The following keys are the same file || jsx || script || jsxScript || jsxscript + * The following keys are the same callBack || callback + * The following keys are the same replacements || replace + * The following keys are the same eval || forceEval || forceeval + * + * @return {Boolean} if the jsxScript was executed or not + */ + + Jsx.prototype.evalFile = function() { + var arg, args, callback, fileName, fileNameScript, forceEval, forceEvalScript, + i, jsxFolder, jsxScript, newLine, replacements, success; + + success = true; // optimistic + args = arguments; + + jsxFolder = path.join(__dirname, 'jsx'); + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // $.fileName does not return it's correct path in the jsx engine for files called from the js engine // + // In Illustrator it returns an integer in InDesign it returns an empty string // + // This script injection allows for the script to know it's path by calling // + // $.__fileName(); // + // on Illustrator this works pretty well // + // on InDesign it's best to use with a bit of care // + // If the a second script has been called the InDesing will "forget" the path to the first script // + // 2 work-arounds for this // + // 1) at the beginning of your script add var thePathToMeIs = $.fileName(); // + // thePathToMeIs will not be forgotten after running the second script // + // 2) $.__fileName('myBaseName.jsx'); // + // for example you have file with the following path // + // /path/to/me.jsx // + // Call $.__fileName('me.jsx') and you will get /path/to/me.jsx even after executing a second script // + // Note When the forceEvalScript option is used then you just use the regular $.fileName property // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + fileNameScript = [ + // The if statement should not normally be executed + 'if(!$.__fileNames){', + ' $.__fileNames = {};', + ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), + ' $.__fileName = function(name){', + ' name = name || $.fileName;', + ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', + ' };', + '}', + '$.__fileNames["__basename__"] = $.__fileNames["" + $.fileName] = "__fileName__";' + ].join(''); + + ////////////////////////////////////////////////////////////////////////////////////// + // sort out order which arguments into jsxScript, callback, replacements, forceEval // + ////////////////////////////////////////////////////////////////////////////////////// + + + // Detect if the parameters were passed as an object and if so allow for various keys + if (args.length === 1 && (arg = args[0]) instanceof Object) { + jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; + callback = arg.callBack || arg.callback; + replacements = arg.replacements || arg.replace; + forceEval = arg.eval || arg.forceEval || arg.forceeval; + } else { + for (i = 0; i < 5; i++) { + arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg.constructor.name === 'String') { + jsxScript = arg; + continue; + } + if (arg.constructor.name === 'Object') { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // If no replacements are provided then the $.evalScript method will be used // + // This will allow directly for the $.fileName property to be used // + // If one does not want the $.evalScript method to be used then // + // either send a blank object as the replacements {} // + // or explicitly set the forceEvalScript option to false // + // This can only be done if the parameters are passed as an object // + // i.e. jsx.evalFile({file:'myFabScript.jsx', forceEvalScript: false}); // + // if the file was called using // + // i.e. jsx.evalFile('myFabScript.jsx'); // + // then the following jsx code is called $.evalFile(new File('Path/to/myFabScript.jsx', 10000000000)) + ''; // + // forceEval is never needed if the forceEvalScript is triggered // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + replacements = arg; + continue; + } + if (arg.constructor === Function) { + callback = arg; + continue; + } + if (arg === false) { + forceEval = false; + } + } + } + + // If no script provide then not too much to do! + if (!jsxScript) { + return false; + } + + forceEvalScript = !replacements; + + + ////////////////////////////////////////////////////// + // Get path of script // + // Check if it's literal, relative or in jsx folder // + ////////////////////////////////////////////////////// + + if (/^\/|[a-zA-Z]+:/.test(jsxScript)) { // absolute path Mac | Windows + jsxScript = path.normalize(jsxScript); + } else if (/^\.+\//.test(jsxScript)) { + jsxScript = path.join(__dirname, jsxScript); // relative path + } else { + jsxScript = path.join(jsxFolder, jsxScript); // files in the jsxFolder + } + + if (forceEvalScript) { + jsxScript = jsxScript.replace(/"/g, '\\"'); + // Check that the path exist, should change this to asynchronous at some point + if (!window.cep.fs.stat(jsxScript).err) { + jsxScript = fileNameScript.replace(/__fileName__/, jsxScript).replace(/__basename__/, path.basename(jsxScript)) + + '$.evalFile(new File("' + jsxScript.replace(/\\/g, '\\\\') + '")) + "";'; + return this.evalScript(jsxScript, callback, forceEval); + } else { + throw new Error(`The file: {jsxScript} could not be found / read`); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Replacements made so we can't use $.evalFile and need to read the jsx script for ourselves // + //////////////////////////////////////////////////////////////////////////////////////////////// + + fileName = jsxScript.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + try { + jsxScript = window.cep.fs.readFile(jsxScript).data; + } catch (er) { + throw new Error(`The file: ${fileName} could not be read`); + } + // It is desirable that the injected fileNameScript is on the same line as the 1st line of the script + // This is so that the $.line or error.line returns the same value as the actual file + // However if the 1st line contains a # directive then we need to insert a new line and stuff the above problem + // When possible i.e. when there's no replacements then $.evalFile will be used and then the whole issue is avoided + newLine = /^\s*#/.test(jsxScript) ? '\n' : ''; + jsxScript = fileNameScript.replace(/__fileName__/, fileName).replace(/__basename__/, path.basename(fileName)) + newLine + jsxScript; + + try { + // evalScript(jsxScript, callback); + return this.evalScript(jsxScript, callback, replacements, forceEval); + } catch (err) { + //////////////////////////////////////////////// + // Do whatever error handling you want here ! // + //////////////////////////////////////////////// + var newErr; + newErr = new Error(err); + alert('Error Eek: ' + newErr.stack); + return false; + } + + return success; // success should be an array but for now it's a Boolean + }; + + + //////////////////////////////////// + // Setup alternative method names // + //////////////////////////////////// + Jsx.prototype.eval = Jsx.prototype.script = Jsx.prototype.evalscript = Jsx.prototype.evalScript; + Jsx.prototype.file = Jsx.prototype.evalfile = Jsx.prototype.evalFile; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Examples // + // jsx.evalScript('alert("foo");'); // + // jsx.evalFile('foo.jsx'); // where foo.jsx is stored in the jsx folder at the base of the extensions directory // + // jsx.evalFile('../myFolder/foo.jsx'); // where a relative or absolute file path is given // + // // + // using conventional methods one would use in the case were the values to swap were supplied by variables // + // csInterface.evalScript('var q = "' + name + '"; alert("' + myString + '" ' + myOp + ' q);q;', callback); // + // Using all the '' + foo + '' is very error prone // + // jsx.evalScript('var q = "__name__"; alert(__string__ __opp__ q);q;',{'name':'Fred', 'string':'Hello ', 'opp':'+'}, callBack); // + // is much simpler and less error prone // + // // + // more readable to use object // + // jsx.evalFile({ // + // file: 'yetAnotherFabScript.jsx', // + // replacements: {"this": foo, That: bar, and: "&&", the: foo2, other: bar2}, // + // eval: true // + // }) // + // Enjoy // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + + jsx = new Jsx(); +})(); diff --git a/openpype/hosts/photoshop/api/extension/host/index.jsx b/openpype/hosts/photoshop/api/extension/host/index.jsx new file mode 100644 index 0000000000..2acec1ebc1 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/host/index.jsx @@ -0,0 +1,484 @@ +ο»Ώ#include "json.js"; +#target photoshop + +var LogFactory=function(file,write,store,level,defaultStatus,continuing){if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}else if(!file)file={file:{}};write=(file.write!==undefined)?file.write:write;if(write===undefined){write=true;}store=(file.store!==undefined)?file.store||false:store||false;level=(file.level!==undefined)?file.level:level;defaultStatus=(file.defaultStatus!==undefined)?file.defaultStatus:defaultStatus;if(defaultStatus===undefined){defaultStatus='LOG';}continuing=(file.continuing!==undefined)?file.continuing:continuing||false;file=file.file||{};var stack,times,logTime,logPoint,icons,statuses,LOG_LEVEL,LOG_STATUS;stack=[];times=[];logTime=new Date();logPoint='Log Factory Start';icons={"1":"\ud83d\udd50","130":"\ud83d\udd5c","2":"\ud83d\udd51","230":"\ud83d\udd5d","3":"\ud83d\udd52","330":"\ud83d\udd5e","4":"\ud83d\udd53","430":"\ud83d\udd5f","5":"\ud83d\udd54","530":"\ud83d\udd60","6":"\ud83d\udd55","630":"\ud83d\udd61","7":"\ud83d\udd56","730":"\ud83d\udd62","8":"\ud83d\udd57","830":"\ud83d\udd63","9":"\ud83d\udd58","930":"\ud83d\udd64","10":"\ud83d\udd59","1030":"\ud83d\udd65","11":"\ud83d\udd5a","1130":"\ud83d\udd66","12":"\ud83d\udd5b","1230":"\ud83d\udd67","AIRPLANE":"\ud83d\udee9","ALARM":"\u23f0","AMBULANCE":"\ud83d\ude91","ANCHOR":"\u2693","ANGRY":"\ud83d\ude20","ANGUISHED":"\ud83d\ude27","ANT":"\ud83d\udc1c","ANTENNA":"\ud83d\udce1","APPLE":"\ud83c\udf4f","APPLE2":"\ud83c\udf4e","ATM":"\ud83c\udfe7","ATOM":"\u269b","BABYBOTTLE":"\ud83c\udf7c","BAD:":"\ud83d\udc4e","BANANA":"\ud83c\udf4c","BANDAGE":"\ud83e\udd15","BANK":"\ud83c\udfe6","BATTERY":"\ud83d\udd0b","BED":"\ud83d\udecf","BEE":"\ud83d\udc1d","BEER":"\ud83c\udf7a","BELL":"\ud83d\udd14","BELLOFF":"\ud83d\udd15","BIRD":"\ud83d\udc26","BLACKFLAG":"\ud83c\udff4","BLUSH":"\ud83d\ude0a","BOMB":"\ud83d\udca3","BOOK":"\ud83d\udcd5","BOOKMARK":"\ud83d\udd16","BOOKS":"\ud83d\udcda","BOW":"\ud83c\udff9","BOWLING":"\ud83c\udfb3","BRIEFCASE":"\ud83d\udcbc","BROKEN":"\ud83d\udc94","BUG":"\ud83d\udc1b","BUILDING":"\ud83c\udfdb","BUILDINGS":"\ud83c\udfd8","BULB":"\ud83d\udca1","BUS":"\ud83d\ude8c","CACTUS":"\ud83c\udf35","CALENDAR":"\ud83d\udcc5","CAMEL":"\ud83d\udc2a","CAMERA":"\ud83d\udcf7","CANDLE":"\ud83d\udd6f","CAR":"\ud83d\ude98","CAROUSEL":"\ud83c\udfa0","CASTLE":"\ud83c\udff0","CATEYES":"\ud83d\ude3b","CATJOY":"\ud83d\ude39","CATMOUTH":"\ud83d\ude3a","CATSMILE":"\ud83d\ude3c","CD":"\ud83d\udcbf","CHECK":"\u2714","CHEQFLAG":"\ud83c\udfc1","CHICK":"\ud83d\udc25","CHICKEN":"\ud83d\udc14","CHICKHEAD":"\ud83d\udc24","CIRCLEBLACK":"\u26ab","CIRCLEBLUE":"\ud83d\udd35","CIRCLERED":"\ud83d\udd34","CIRCLEWHITE":"\u26aa","CIRCUS":"\ud83c\udfaa","CLAPPER":"\ud83c\udfac","CLAPPING":"\ud83d\udc4f","CLIP":"\ud83d\udcce","CLIPBOARD":"\ud83d\udccb","CLOUD":"\ud83c\udf28","CLOVER":"\ud83c\udf40","CLOWN":"\ud83e\udd21","COLDSWEAT":"\ud83d\ude13","COLDSWEAT2":"\ud83d\ude30","COMPRESS":"\ud83d\udddc","CONFOUNDED":"\ud83d\ude16","CONFUSED":"\ud83d\ude15","CONSTRUCTION":"\ud83d\udea7","CONTROL":"\ud83c\udf9b","COOKIE":"\ud83c\udf6a","COOKING":"\ud83c\udf73","COOL":"\ud83d\ude0e","COOLBOX":"\ud83c\udd92","COPYRIGHT":"\u00a9","CRANE":"\ud83c\udfd7","CRAYON":"\ud83d\udd8d","CREDITCARD":"\ud83d\udcb3","CROSS":"\u2716","CROSSBOX:":"\u274e","CRY":"\ud83d\ude22","CRYCAT":"\ud83d\ude3f","CRYSTALBALL":"\ud83d\udd2e","CUSTOMS":"\ud83d\udec3","DELICIOUS":"\ud83d\ude0b","DERELICT":"\ud83c\udfda","DESKTOP":"\ud83d\udda5","DIAMONDLB":"\ud83d\udd37","DIAMONDLO":"\ud83d\udd36","DIAMONDSB":"\ud83d\udd39","DIAMONDSO":"\ud83d\udd38","DICE":"\ud83c\udfb2","DISAPPOINTED":"\ud83d\ude1e","CRY2":"\ud83d\ude25","DIVISION":"\u2797","DIZZY":"\ud83d\ude35","DOLLAR":"\ud83d\udcb5","DOLLAR2":"\ud83d\udcb2","DOWNARROW":"\u2b07","DVD":"\ud83d\udcc0","EJECT":"\u23cf","ELEPHANT":"\ud83d\udc18","EMAIL":"\ud83d\udce7","ENVELOPE":"\ud83d\udce8","ENVELOPE2":"\u2709","ENVELOPE_DOWN":"\ud83d\udce9","EURO":"\ud83d\udcb6","EVIL":"\ud83d\ude08","EXPRESSIONLESS":"\ud83d\ude11","EYES":"\ud83d\udc40","FACTORY":"\ud83c\udfed","FAX":"\ud83d\udce0","FEARFUL":"\ud83d\ude28","FILEBOX":"\ud83d\uddc3","FILECABINET":"\ud83d\uddc4","FIRE":"\ud83d\udd25","FIREENGINE":"\ud83d\ude92","FIST":"\ud83d\udc4a","FLOWER":"\ud83c\udf37","FLOWER2":"\ud83c\udf38","FLUSHED":"\ud83d\ude33","FOLDER":"\ud83d\udcc1","FOLDER2":"\ud83d\udcc2","FREE":"\ud83c\udd93","FROG":"\ud83d\udc38","FROWN":"\ud83d\ude41","GEAR":"\u2699","GLOBE":"\ud83c\udf0d","GLOWINGSTAR":"\ud83c\udf1f","GOOD:":"\ud83d\udc4d","GRIMACING":"\ud83d\ude2c","GRIN":"\ud83d\ude00","GRINNINGCAT":"\ud83d\ude38","HALO":"\ud83d\ude07","HAMMER":"\ud83d\udd28","HAMSTER":"\ud83d\udc39","HAND":"\u270b","HANDDOWN":"\ud83d\udc47","HANDLEFT":"\ud83d\udc48","HANDRIGHT":"\ud83d\udc49","HANDUP":"\ud83d\udc46","HATCHING":"\ud83d\udc23","HAZARD":"\u2623","HEADPHONE":"\ud83c\udfa7","HEARNOEVIL":"\ud83d\ude49","HEARTBLUE":"\ud83d\udc99","HEARTEYES":"\ud83d\ude0d","HEARTGREEN":"\ud83d\udc9a","HEARTYELLOW":"\ud83d\udc9b","HELICOPTER":"\ud83d\ude81","HERB":"\ud83c\udf3f","HIGH_BRIGHTNESS":"\ud83d\udd06","HIGHVOLTAGE":"\u26a1","HIT":"\ud83c\udfaf","HONEY":"\ud83c\udf6f","HOT":"\ud83c\udf36","HOURGLASS":"\u23f3","HOUSE":"\ud83c\udfe0","HUGGINGFACE":"\ud83e\udd17","HUNDRED":"\ud83d\udcaf","HUSHED":"\ud83d\ude2f","ID":"\ud83c\udd94","INBOX":"\ud83d\udce5","INDEX":"\ud83d\uddc2","JOY":"\ud83d\ude02","KEY":"\ud83d\udd11","KISS":"\ud83d\ude18","KISS2":"\ud83d\ude17","KISS3":"\ud83d\ude19","KISS4":"\ud83d\ude1a","KISSINGCAT":"\ud83d\ude3d","KNIFE":"\ud83d\udd2a","LABEL":"\ud83c\udff7","LADYBIRD":"\ud83d\udc1e","LANDING":"\ud83d\udeec","LAPTOP":"\ud83d\udcbb","LEFTARROW":"\u2b05","LEMON":"\ud83c\udf4b","LIGHTNINGCLOUD":"\ud83c\udf29","LINK":"\ud83d\udd17","LITTER":"\ud83d\udeae","LOCK":"\ud83d\udd12","LOLLIPOP":"\ud83c\udf6d","LOUDSPEAKER":"\ud83d\udce2","LOW_BRIGHTNESS":"\ud83d\udd05","MAD":"\ud83d\ude1c","MAGNIFYING_GLASS":"\ud83d\udd0d","MASK":"\ud83d\ude37","MEDAL":"\ud83c\udf96","MEMO":"\ud83d\udcdd","MIC":"\ud83c\udfa4","MICROSCOPE":"\ud83d\udd2c","MINUS":"\u2796","MOBILE":"\ud83d\udcf1","MONEY":"\ud83d\udcb0","MONEYMOUTH":"\ud83e\udd11","MONKEY":"\ud83d\udc35","MOUSE":"\ud83d\udc2d","MOUSE2":"\ud83d\udc01","MOUTHLESS":"\ud83d\ude36","MOVIE":"\ud83c\udfa5","MUGS":"\ud83c\udf7b","NERD":"\ud83e\udd13","NEUTRAL":"\ud83d\ude10","NEW":"\ud83c\udd95","NOENTRY":"\ud83d\udeab","NOTEBOOK":"\ud83d\udcd4","NOTEPAD":"\ud83d\uddd2","NUTANDBOLT":"\ud83d\udd29","O":"\u2b55","OFFICE":"\ud83c\udfe2","OK":"\ud83c\udd97","OKHAND":"\ud83d\udc4c","OLDKEY":"\ud83d\udddd","OPENLOCK":"\ud83d\udd13","OPENMOUTH":"\ud83d\ude2e","OUTBOX":"\ud83d\udce4","PACKAGE":"\ud83d\udce6","PAGE":"\ud83d\udcc4","PAINTBRUSH":"\ud83d\udd8c","PALETTE":"\ud83c\udfa8","PANDA":"\ud83d\udc3c","PASSPORT":"\ud83d\udec2","PAWS":"\ud83d\udc3e","PEN":"\ud83d\udd8a","PEN2":"\ud83d\udd8b","PENSIVE":"\ud83d\ude14","PERFORMING":"\ud83c\udfad","PHONE":"\ud83d\udcde","PILL":"\ud83d\udc8a","PING":"\u2757","PLATE":"\ud83c\udf7d","PLUG":"\ud83d\udd0c","PLUS":"\u2795","POLICE":"\ud83d\ude93","POLICELIGHT":"\ud83d\udea8","POSTOFFICE":"\ud83c\udfe4","POUND":"\ud83d\udcb7","POUTING":"\ud83d\ude21","POUTINGCAT":"\ud83d\ude3e","PRESENT":"\ud83c\udf81","PRINTER":"\ud83d\udda8","PROJECTOR":"\ud83d\udcfd","PUSHPIN":"\ud83d\udccc","QUESTION":"\u2753","RABBIT":"\ud83d\udc30","RADIOACTIVE":"\u2622","RADIOBUTTON":"\ud83d\udd18","RAINCLOUD":"\ud83c\udf27","RAT":"\ud83d\udc00","RECYCLE":"\u267b","REGISTERED":"\u00ae","RELIEVED":"\ud83d\ude0c","ROBOT":"\ud83e\udd16","ROCKET":"\ud83d\ude80","ROLLING":"\ud83d\ude44","ROOSTER":"\ud83d\udc13","RULER":"\ud83d\udccf","SATELLITE":"\ud83d\udef0","SAVE":"\ud83d\udcbe","SCHOOL":"\ud83c\udfeb","SCISSORS":"\u2702","SCREAMING":"\ud83d\ude31","SCROLL":"\ud83d\udcdc","SEAT":"\ud83d\udcba","SEEDLING":"\ud83c\udf31","SEENOEVIL":"\ud83d\ude48","SHIELD":"\ud83d\udee1","SHIP":"\ud83d\udea2","SHOCKED":"\ud83d\ude32","SHOWER":"\ud83d\udebf","SLEEPING":"\ud83d\ude34","SLEEPY":"\ud83d\ude2a","SLIDER":"\ud83c\udf9a","SLOT":"\ud83c\udfb0","SMILE":"\ud83d\ude42","SMILING":"\ud83d\ude03","SMILINGCLOSEDEYES":"\ud83d\ude06","SMILINGEYES":"\ud83d\ude04","SMILINGSWEAT":"\ud83d\ude05","SMIRK":"\ud83d\ude0f","SNAIL":"\ud83d\udc0c","SNAKE":"\ud83d\udc0d","SOCCER":"\u26bd","SOS":"\ud83c\udd98","SPEAKER":"\ud83d\udd08","SPEAKEROFF":"\ud83d\udd07","SPEAKNOEVIL":"\ud83d\ude4a","SPIDER":"\ud83d\udd77","SPIDERWEB":"\ud83d\udd78","STAR":"\u2b50","STOP":"\u26d4","STOPWATCH":"\u23f1","SULK":"\ud83d\ude26","SUNFLOWER":"\ud83c\udf3b","SUNGLASSES":"\ud83d\udd76","SYRINGE":"\ud83d\udc89","TAKEOFF":"\ud83d\udeeb","TAXI":"\ud83d\ude95","TELESCOPE":"\ud83d\udd2d","TEMPORATURE":"\ud83e\udd12","TENNIS":"\ud83c\udfbe","THERMOMETER":"\ud83c\udf21","THINKING":"\ud83e\udd14","THUNDERCLOUD":"\u26c8","TICKBOX":"\u2705","TICKET":"\ud83c\udf9f","TIRED":"\ud83d\ude2b","TOILET":"\ud83d\udebd","TOMATO":"\ud83c\udf45","TONGUE":"\ud83d\ude1b","TOOLS":"\ud83d\udee0","TORCH":"\ud83d\udd26","TORNADO":"\ud83c\udf2a","TOUNG2":"\ud83d\ude1d","TRADEMARK":"\u2122","TRAFFICLIGHT":"\ud83d\udea6","TRASH":"\ud83d\uddd1","TREE":"\ud83c\udf32","TRIANGLE_LEFT":"\u25c0","TRIANGLE_RIGHT":"\u25b6","TRIANGLEDOWN":"\ud83d\udd3b","TRIANGLEUP":"\ud83d\udd3a","TRIANGULARFLAG":"\ud83d\udea9","TROPHY":"\ud83c\udfc6","TRUCK":"\ud83d\ude9a","TRUMPET":"\ud83c\udfba","TURKEY":"\ud83e\udd83","TURTLE":"\ud83d\udc22","UMBRELLA":"\u26f1","UNAMUSED":"\ud83d\ude12","UPARROW":"\u2b06","UPSIDEDOWN":"\ud83d\ude43","WARNING":"\u26a0","WATCH":"\u231a","WAVING":"\ud83d\udc4b","WEARY":"\ud83d\ude29","WEARYCAT":"\ud83d\ude40","WHITEFLAG":"\ud83c\udff3","WINEGLASS":"\ud83c\udf77","WINK":"\ud83d\ude09","WORRIED":"\ud83d\ude1f","WRENCH":"\ud83d\udd27","X":"\u274c","YEN":"\ud83d\udcb4","ZIPPERFACE":"\ud83e\udd10","UNDEFINED":"","":""};statuses={F:'FATAL',B:'BUG',C:'CRITICAL',E:'ERROR',W:'WARNING',I:'INFO',IM:'IMPORTANT',D:'DEBUG',L:'LOG',CO:'CONSTANT',FU:'FUNCTION',R:'RETURN',V:'VARIABLE',S:'STACK',RE:'RESULT',ST:'STOPPER',TI:'TIMER',T:'TRACE'};LOG_LEVEL={NONE:7,OFF:7,FATAL:6,ERROR:5,WARN:4,INFO:3,UNDEFINED:2,'':2,DEFAULT:2,DEBUG:2,TRACE:1,ON:0,ALL:0,};LOG_STATUS={OFF:LOG_LEVEL.OFF,NONE:LOG_LEVEL.OFF,NO:LOG_LEVEL.OFF,NOPE:LOG_LEVEL.OFF,FALSE:LOG_LEVEL.OFF,FATAL:LOG_LEVEL.FATAL,BUG:LOG_LEVEL.ERROR,CRITICAL:LOG_LEVEL.ERROR,ERROR:LOG_LEVEL.ERROR,WARNING:LOG_LEVEL.WARN,INFO:LOG_LEVEL.INFO,IMPORTANT:LOG_LEVEL.INFO,DEBUG:LOG_LEVEL.DEBUG,LOG:LOG_LEVEL.DEBUG,STACK:LOG_LEVEL.DEBUG,CONSTANT:LOG_LEVEL.DEBUG,FUNCTION:LOG_LEVEL.DEBUG,VARIABLE:LOG_LEVEL.DEBUG,RETURN:LOG_LEVEL.DEBUG,RESULT:LOG_LEVEL.TRACE,STOPPER:LOG_LEVEL.TRACE,TIMER:LOG_LEVEL.TRACE,TRACE:LOG_LEVEL.TRACE,ALL:LOG_LEVEL.ALL,YES:LOG_LEVEL.ALL,YEP:LOG_LEVEL.ALL,TRUE:LOG_LEVEL.ALL};var logFile,logFolder;var LOG=function(message,status,icon){if(LOG.level!==LOG_LEVEL.OFF&&(LOG.write||LOG.store)&&LOG.arguments.length)return LOG.addMessage(message,status,icon);};LOG.logDecodeLevel=function(level){if(level==~~level)return Math.abs(level);var lev;level+='';level=level.toUpperCase();if(level in statuses){level=statuses[level];}lev=LOG_LEVEL[level];if(lev!==undefined)return lev;lev=LOG_STATUS[level];if(lev!==undefined)return lev;return LOG_LEVEL.DEFAULT;};LOG.write=write;LOG.store=store;LOG.level=LOG.logDecodeLevel(level);LOG.status=defaultStatus;LOG.addMessage=function(message,status,icon){var date=new Date(),count,bool,logStatus;if(status&&status.constructor.name==='String'){status=status.toUpperCase();status=statuses[status]||status;}else status=LOG.status;logStatus=LOG_STATUS[status]||LOG_STATUS.ALL;if(logStatus999)?'['+LOG.count+'] ':(' ['+LOG.count+'] ').slice(-7);message=count+status+icon+(message instanceof Object?message.toSource():message)+date;if(LOG.store){stack.push(message);}if(LOG.write){bool=file&&file.writable&&logFile.writeln(message);if(!bool){file.writable=true;LOG.setFile(logFile);logFile.writeln(message);}}LOG.count++;return true;};var logNewFile=function(file,isCookie,overwrite){file.encoding='UTF-8';file.lineFeed=($.os[0]=='M')?'Macintosh':' Windows';if(isCookie)return file.open(overwrite?'w':'e')&&file;file.writable=LOG.write;logFile=file;logFolder=file.parent;if(continuing){LOG.count=LOG.setCount(file);}return(!LOG.write&&file||(file.open('a')&&file));};LOG.setFile=function(file,isCookie,overwrite){var bool,folder,fileName,suffix,newFileName,f,d,safeFileName;d=new Date();f=$.stack.split("\n")[0].replace(/^\[\(?/,'').replace(/\)?\]$/,'');if(f==~~f){f=$.fileName.replace(/[^\/]+\//g,'');}safeFileName=File.encode((isCookie?'/COOKIE_':'/LOG_')+f.replace(/^\//,'')+'_'+(1900+d.getYear())+(''+d).replace(/...(...)(..).+/,'_$1_$2')+(isCookie?'.txt':'.log'));if(file&&file.constructor.name=='String'){file=(file.match('/'))?new File(file):new File((logFolder||Folder.temp)+'/'+file);}if(file instanceof File){folder=file.parent;bool=folder.exists||folder.create();if(!bool)folder=Folder.temp;fileName=File.decode(file.name);suffix=fileName.match(/\.[^.]+$/);suffix=suffix?suffix[0]:'';fileName='/'+fileName;newFileName=fileName.replace(/\.[^.]+$/,'')+'_'+(+(new Date())+suffix);f=logNewFile(file,isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+newFileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+safeFileName),isCookie,overwrite);if(f)return f;if(folder!=Folder.temp){f=logNewFile(new File(Folder.temp+fileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(Folder.temp+safeFileName),isCookie,overwrite);return f||new File(Folder.temp+safeFileName);}}return LOG.setFile(((logFile&&!isCookie)?new File(logFile):new File(Folder.temp+safeFileName)),isCookie,overwrite );};LOG.setCount=function(file){if(~~file===file){LOG.count=file;return LOG.count;}if(file===undefined){file=logFile;}if(file&&file.constructor===String){file=new File(file);}var logNumbers,contents;if(!file.length||!file.exists){LOG.count=1;return 1;}file.open('r');file.encoding='utf-8';file.seek(10000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}if(file.length<10001){file.close();LOG.count=1;return 1;}file.seek(10000000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}file.close();LOG.count=1;return 1;};LOG.setLevel=function(level){LOG.level=LOG.logDecodeLevel(level);return LOG.level;};LOG.setStatus=function(status){status=(''+status).toUpperCase();LOG.status=statuses[status]||status;return LOG.status;};LOG.cookie=function(file,level,overwrite,setLevel){var log,cookie;if(!file){file={file:file};}if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}log=file;if(log.level===undefined){log.level=(level!==undefined)?level:'NONE';}if(log.overwrite===undefined){log.overwrite=(overwrite!==undefined)?overwrite:false;}if(log.setLevel===undefined){log.setLevel=(setLevel!==undefined)?setLevel:true;}setLevel=log.setLevel;overwrite=log.overwrite;level=log.level;file=log.file;file=LOG.setFile(file,true,overwrite);if(overwrite){file.write(level);}else{cookie=file.read();if(cookie.length){level=cookie;}else{file.write(level);}}file.close();if(setLevel){LOG.setLevel(level);}return{path:file,level:level};};LOG.args=function(args,funct,line){if(LOG.level>LOG_STATUS.FUNCTION)return;if(!(args&&(''+args.constructor).replace(/\s+/g,'')==='functionObject(){[nativecode]}'))return;if(!LOG.args.STRIP_COMMENTS){LOG.args.STRIP_COMMENTS=/((\/.*$)|(\/\*[\s\S]*?\*\/))/mg;}if(!LOG.args.ARGUMENT_NAMES){LOG.args.ARGUMENT_NAMES=/([^\s,]+)/g;}if(!LOG.args.OUTER_BRACKETS){LOG.args.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.args.NEW_SOMETHING){LOG.args.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}var functionString,argumentNames,stackInfo,report,functionName,arg,argsL,n,argName,argValue,argsTotal;if(funct===~~funct){line=funct;}if(!(funct instanceof Function)){funct=args.callee;}if(!(funct instanceof Function))return;functionName=funct.name;functionString=(''+funct).replace(LOG.args.STRIP_COMMENTS,'');argumentNames=functionString.slice(functionString.indexOf('(')+1,functionString.indexOf(')')).match(LOG.args.ARGUMENT_NAMES);argumentNames=argumentNames||[];report=[];report.push('--------------');report.push('Function Data:');report.push('--------------');report.push('Function Name:'+functionName);argsL=args.length;stackInfo=$.stack.split(/[\n\r]/);stackInfo.pop();stackInfo=stackInfo.join('\n ');report.push('Call stack:'+stackInfo);if(line){report.push('Function Line around:'+line);}report.push('Arguments Provided:'+argsL);report.push('Named Arguments:'+argumentNames.length);if(argumentNames.length){report.push('Arguments Names:'+argumentNames.join(','));}if(argsL){report.push('----------------');report.push('Argument Values:');report.push('----------------');}argsTotal=Math.max(argsL,argumentNames.length);for(n=0;n=argsL){argValue='NO VALUE PROVIDED';}else if(arg===undefined){argValue='undefined';}else if(arg===null){argValue='null';}else{argValue=arg.toSource().replace(LOG.args.OUTER_BRACKETS,'$1').replace(LOG.args.NEW_SOMETHING,'$1');}report.push((argName?argName:'arguments['+n+']')+':'+argValue);}report.push('');report=report.join('\n ');LOG(report,'f');return report;};LOG.stack=function(reverse){var st=$.stack.split('\n');st.pop();st.pop();if(reverse){st.reverse();}return LOG(st.join('\n '),'s');};LOG.values=function(values){var n,value,map=[];if(!(values instanceof Object||values instanceof Array)){return;}if(!LOG.values.OUTER_BRACKETS){LOG.values.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.values.NEW_SOMETHING){LOG.values.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}for(n in values){try{value=values[n];if(value===undefined){value='undefined';}else if(value===null){value='null';}else{value=value.toSource().replace(LOG.values.OUTER_BRACKETS,'$1').replace(LOG.values.NEW_SOMETHING,'$1');}}catch(e){value='\uD83D\uDEAB '+e;}map.push(n+':'+value);}if(map.length){map=map.join('\n ')+'\n ';return LOG(map,'v');}};LOG.reset=function(all){stack.length=0;LOG.count=1;if(all!==false){if(logFile instanceof File){logFile.close();}logFile=LOG.store=LOG.writeToFile=undefined;LOG.write=true;logFolder=Folder.temp;logTime=new Date();logPoint='After Log Reset';}};LOG.stopper=function(message){var newLogTime,t,m,newLogPoint;newLogTime=new Date();newLogPoint=(LOG.count!==undefined)?'LOG#'+LOG.count:'BEFORE LOG#1';LOG.time=t=newLogTime-logTime;if(message===false){return;}message=message||'Stopper start point';t=LOG.prettyTime(t);m=message+'\n '+'From '+logPoint+' to '+newLogPoint+' took '+t+' Starting '+logTime+' '+logTime.getMilliseconds()+'ms'+' Ending '+newLogTime+' '+newLogTime.getMilliseconds()+'ms';LOG(m,'st');logPoint=newLogPoint;logTime=newLogTime;return m;};LOG.start=function(message){var t=new Date();times.push([t,(message!==undefined)?message+'':'']);};LOG.stop=function(message){if(!times.length)return;message=(message)?message+' ':'';var nt,startLog,ot,om,td,m;nt=new Date();startLog=times.pop();ot=startLog[0];om=startLog[1];td=nt-ot;if(om.length){om+=' ';}m=om+'STARTED ['+ot+' '+ot.getMilliseconds()+'ms]\n '+message+'FINISHED ['+nt+' '+nt.getMilliseconds()+'ms]\n TOTAL TIME ['+LOG.prettyTime(td)+']';LOG(m,'ti');return m;};LOG.prettyTime=function(t){var h,m,s,ms;h=Math.floor(t / 3600000);m=Math.floor((t % 3600000)/ 60000);s=Math.floor((t % 60000)/ 1000);ms=t % 1000;t=(!t)?'<1ms':((h)?h+' hours ':'')+((m)?m+' minutes ':'')+((s)?s+' seconds ':'')+((ms&&(h||m||s))?'&':'')+((ms)?ms+'ms':'');return t;};LOG.get=function(){if(!stack.length)return 'THE LOG IS NOT SET TO STORE';var a=fetchLogLines(arguments);return a?'\n'+a.join('\n'):'NO LOGS AVAILABLE';};var fetchLogLines=function(){var args=arguments[0];if(!args.length)return stack;var c,n,l,a=[],ln,start,end,j,sl;l=args.length;sl=stack.length-1;n=0;for(c=0;cln)?sl+ln+1:ln-1;if(ln>=0&&ln<=sl)a[n++]=stack[ln];}else if(ln instanceof Array&&ln.length===2){start=ln[0];end=ln[1];if(!(~~start===start&&~~end===end))continue;start=(0>start)?sl+start+1:start-1;end=(0>end)?sl+end+1:end-1;start=Math.max(Math.min(sl,start),0);end=Math.min(Math.max(end,0),sl);if(start<=end)for(j=start;j<=end;j++)a[n++]=stack[j];else for(j=start;j>=end;j--)a[n++]=stack[j];}}return(n)?a:false;};LOG.file=function(){return logFile;};LOG.openFolder=function(){if(logFolder)return logFolder.execute();};LOG.show=LOG.execute=function(){if(logFile)return logFile.execute();};LOG.close=function(){if(logFile)return logFile.close();};LOG.setFile(file);if(!$.summary.difference){$.summary.difference=function(){return $.summary().replace(/ *([0-9]+)([^ ]+)(\n?)/g,$.summary.updateSnapshot );};}if(!$.summary.updateSnapshot){$.summary.updateSnapshot=function(full,count,name,lf){var snapshot=$.summary.snapshot;count=Number(count);var prev=snapshot[name]?snapshot[name]:0;snapshot[name]=count;var diff=count-prev;if(diff===0)return "";return " ".substring(String(diff).length)+diff+" "+name+lf;};}if(!$.summary.snapshot){$.summary.snapshot=[];$.summary.difference();}$.gc();$.gc();$.summary.difference();LOG.sumDiff=function(message){$.gc();$.gc();var diff=$.summary.difference();if(diff.length<8){diff=' - NONE -';}if(message===undefined){message='';}message+=diff;return LOG('$.summary.difference():'+message,'v');};return LOG;}; + +var log = new LogFactory('myLog.log'); // =>; creates the new log factory - put full path where + +function getEnv(variable){ + return $.getenv(variable); +} + +function fileOpen(path){ + return app.open(new File(path)); +} + +function getLayerTypeWithName(layerName) { + var type = 'NA'; + var nameParts = layerName.split('_'); + var namePrefix = nameParts[0]; + namePrefix = namePrefix.toLowerCase(); + switch (namePrefix) { + case 'guide': + case 'tl': + case 'tr': + case 'bl': + case 'br': + type = 'GUIDE'; + break; + case 'fg': + type = 'FG'; + break; + case 'bg': + type = 'BG'; + break; + case 'obj': + default: + type = 'OBJ'; + break; + } + + return type; +} + +function getLayers() { + /** + * Get json representation of list of layers. + * Much faster this way than in DOM traversal (2s vs 45s on same file) + * + * Format of single layer info: + * id : number + * name: string + * group: boolean - true if layer is a group + * parents:array - list of ids of parent groups, useful for selection + * all children layers from parent layerSet (eg. group) + * type: string - type of layer guessed from its name + * visible:boolean - true if visible + **/ + if (documents.length == 0){ + return '[]'; + } + var ref1 = new ActionReference(); + ref1.putEnumerated(charIDToTypeID('Dcmn'), charIDToTypeID('Ordn'), + charIDToTypeID('Trgt')); + var count = executeActionGet(ref1).getInteger(charIDToTypeID('NmbL')); + + // get all layer names + var layers = []; + var layer = {}; + + var parents = []; + for (var i = count; i >= 1; i--) { + var layer = {}; + var ref2 = new ActionReference(); + ref2.putIndex(charIDToTypeID('Lyr '), i); + + var desc = executeActionGet(ref2); // Access layer index #i + var layerSection = typeIDToStringID(desc.getEnumerationValue( + stringIDToTypeID('layerSection'))); + + layer.id = desc.getInteger(stringIDToTypeID("layerID")); + layer.name = desc.getString(stringIDToTypeID("name")); + layer.color_code = typeIDToStringID(desc.getEnumerationValue(stringIDToTypeID('color'))); + layer.group = false; + layer.parents = parents.slice(); + layer.type = getLayerTypeWithName(layer.name); + layer.visible = desc.getBoolean(stringIDToTypeID("visible")); + //log(" name: " + layer.name + " groupId " + layer.groupId + + //" group " + layer.group); + if (layerSection == 'layerSectionStart') { // Group start and end + parents.push(layer.id); + layer.group = true; + } + if (layerSection == 'layerSectionEnd') { + parents.pop(); + continue; + } + layers.push(JSON.stringify(layer)); + } + try{ + var bck = activeDocument.backgroundLayer; + layer.id = bck.id; + layer.name = bck.name; + layer.group = false; + layer.parents = []; + layer.type = 'background'; + layer.visible = bck.visible; + layers.push(JSON.stringify(layer)); + }catch(e){ + // do nothing, no background layer + }; + //log("layers " + layers); + return '[' + layers + ']'; +} + +function setVisible(layer_id, visibility){ + /** + * Sets particular 'layer_id' to 'visibility' if true > show + **/ + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putIdentifier(stringIDToTypeID("layer"), layer_id); + desc.putReference(stringIDToTypeID("null"), ref); + + executeAction(visibility?stringIDToTypeID("show"):stringIDToTypeID("hide"), + desc, DialogModes.NO); + +} + +function getHeadline(){ + /** + * Returns headline of current document with metadata + * + **/ + if (documents.length == 0){ + return ''; + } + var headline = app.activeDocument.info.headline; + + return headline; +} + +function isSaved(){ + return app.activeDocument.saved; +} + +function save(){ + /** Saves active document **/ + return app.activeDocument.save(); +} + +function saveAs(output_path, ext, as_copy){ + /** Exports scene to various formats + * + * Currently implemented: 'jpg', 'png', 'psd' + * + * output_path - escaped file path on local system + * ext - extension for export + * as_copy - create copy, do not overwrite + * + * */ + var saveName = output_path; + var saveOptions; + if (ext == 'jpg'){ + saveOptions = new JPEGSaveOptions(); + saveOptions.quality = 12; + saveOptions.embedColorProfile = true; + saveOptions.formatOptions = FormatOptions.PROGRESSIVE; + if(saveOptions.formatOptions == FormatOptions.PROGRESSIVE){ + saveOptions.scans = 5}; + saveOptions.matte = MatteType.NONE; + } + if (ext == 'png'){ + saveOptions = new PNGSaveOptions(); + saveOptions.interlaced = true; + saveOptions.transparency = true; + } + if (ext == 'psd'){ + saveOptions = null; + return app.activeDocument.saveAs(new File(saveName)); + } + if (ext == 'psb'){ + return savePSB(output_path); + } + + return app.activeDocument.saveAs(new File(saveName), saveOptions, as_copy); + +} + +function getActiveDocumentName(){ + /** + * Returns file name of active document + * */ + if (documents.length == 0){ + return null; + } + return app.activeDocument.name; +} + +function getActiveDocumentFullName(){ + /** + * Returns file name of active document with file path. + * activeDocument.fullName returns path in URI (eg /c/.. insted of c:/) + * */ + if (documents.length == 0){ + return null; + } + var f = new File(app.activeDocument.fullName); + var path = f.fsName; + f.close(); + return path; +} + +function imprint(payload){ + /** + * Sets headline content of current document with metadata. Stores + * information about assets created through Avalon. + * Content accessible in PS through File > File Info + * + **/ + app.activeDocument.info.headline = payload; +} + +function getSelectedLayers(doc) { + /** + * Returns json representation of currently selected layers. + * Works in three steps - 1) creates new group with selected layers + * 2) traverses this group + * 3) deletes newly created group, not neede + * Bit weird, but Adobe.. + **/ + if (doc == null){ + doc = app.activeDocument; + } + + var selLayers = []; + _grp = groupSelectedLayers(doc); + + var group = doc.activeLayer; + var layers = group.layers; + + // // group is fake at this point + // var itself_name = ''; + // if (layers){ + // itself_name = layers[0].name; + // } + + + for (var i = 0; i < layers.length; i++) { + var layer = {}; + layer.id = layers[i].id; + layer.name = layers[i].name; + long_names =_get_parents_names(group.parent, layers[i].name); + var t = layers[i].kind; + if ((typeof t !== 'undefined') && + (layers[i].kind.toString() == 'LayerKind.NORMAL')){ + layer.group = false; + }else{ + layer.group = true; + } + layer.long_name = long_names; + + selLayers.push(layer); + } + + _undo(); + + return JSON.stringify(selLayers); +}; + +function selectLayers(selectedLayers){ + /** + * Selects layers from list of ids + **/ + selectedLayers = JSON.parse(selectedLayers); + var layers = new Array(); + var id54 = charIDToTypeID( "slct" ); + var desc12 = new ActionDescriptor(); + var id55 = charIDToTypeID( "null" ); + var ref9 = new ActionReference(); + + var existing_layers = JSON.parse(getLayers()); + var existing_ids = []; + for (var y = 0; y < existing_layers.length; y++){ + existing_ids.push(existing_layers[y]["id"]); + } + for (var i = 0; i < selectedLayers.length; i++) { + // a check to see if the id stil exists + var id = selectedLayers[i]; + if(existing_ids.toString().indexOf(id)>=0){ + layers[i] = charIDToTypeID( "Lyr " ); + ref9.putIdentifier(layers[i], id); + } + } + desc12.putReference( id55, ref9 ); + var id58 = charIDToTypeID( "MkVs" ); + desc12.putBoolean( id58, false ); + executeAction( id54, desc12, DialogModes.NO ); +} + +function groupSelectedLayers(doc, name) { + /** + * Groups selected layers into new group. + * Returns json representation of Layer for server to consume + * + * Args: + * doc(activeDocument) + * name (str): new name of created group + **/ + if (doc == null){ + doc = app.activeDocument; + } + + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putClass( stringIDToTypeID('layerSection') ); + desc.putReference( charIDToTypeID('null'), ref ); + var lref = new ActionReference(); + lref.putEnumerated( charIDToTypeID('Lyr '), charIDToTypeID('Ordn'), + charIDToTypeID('Trgt') ); + desc.putReference( charIDToTypeID('From'), lref); + executeAction( charIDToTypeID('Mk '), desc, DialogModes.NO ); + + var group = doc.activeLayer; + if (name){ + // Add special character to highlight group that will be published + group.name = name; + } + var layer = {}; + layer.id = group.id; + layer.name = name; // keep name clean + layer.group = true; + + layer.long_name = _get_parents_names(group, name); + + return JSON.stringify(layer); +}; + +function importSmartObject(path, name, link){ + /** + * Creates new layer with an image from 'path' + * + * path: absolute path to loaded file + * name: sets name of newly created laye + * + **/ + var desc1 = new ActionDescriptor(); + desc1.putPath( app.charIDToTypeID("null"), new File(path) ); + link = link || false; + if (link) { + desc1.putBoolean( app.charIDToTypeID('Lnkd'), true ); + } + + desc1.putEnumerated(app.charIDToTypeID("FTcs"), app.charIDToTypeID("QCSt"), + app.charIDToTypeID("Qcsa")); + var desc2 = new ActionDescriptor(); + desc2.putUnitDouble(app.charIDToTypeID("Hrzn"), + app.charIDToTypeID("#Pxl"), 0.0); + desc2.putUnitDouble(app.charIDToTypeID("Vrtc"), + app.charIDToTypeID("#Pxl"), 0.0); + + desc1.putObject(charIDToTypeID("Ofst"), charIDToTypeID("Ofst"), desc2); + executeAction(charIDToTypeID("Plc " ), desc1, DialogModes.NO); + + var docRef = app.activeDocument + var currentActivelayer = app.activeDocument.activeLayer; + if (name){ + currentActivelayer.name = name; + } + var layer = {} + layer.id = currentActivelayer.id; + layer.name = currentActivelayer.name; + return JSON.stringify(layer); +} + +function replaceSmartObjects(layer_id, path, name){ + /** + * Updates content of 'layer' with an image from 'path' + * + **/ + + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putIdentifier(stringIDToTypeID("layer"), layer_id); + desc.putReference(stringIDToTypeID("null"), ref); + + desc.putPath(charIDToTypeID('null'), new File(path) ); + desc.putInteger(charIDToTypeID("PgNm"), 1); + + executeAction(stringIDToTypeID('placedLayerReplaceContents'), + desc, DialogModes.NO ); + var currentActivelayer = app.activeDocument.activeLayer; + if (name){ + currentActivelayer.name = name; + } +} + +function createGroup(name){ + /** + * Creates new group with a 'name' + * Because of asynchronous nature, only group.id is available + **/ + group = app.activeDocument.layerSets.add(); + // Add special character to highlight group that will be published + group.name = name; + + return group.id; // only id available at this time :| +} + +function deleteLayer(layer_id){ + /*** + * Deletes layer by its layer_id + * + * layer_id (int) + **/ + var d = new ActionDescriptor(); + var r = new ActionReference(); + + r.putIdentifier(stringIDToTypeID("layer"), layer_id); + d.putReference(stringIDToTypeID("null"), r); + executeAction(stringIDToTypeID("delete"), d, DialogModes.NO); +} + +function _undo() { + executeAction(charIDToTypeID("undo", undefined, DialogModes.NO)); +}; + +function savePSB(output_path){ + /*** + * Saves file as .psb to 'output_path' + * + * output_path (str) + **/ + var desc1 = new ActionDescriptor(); + var desc2 = new ActionDescriptor(); + desc2.putBoolean( stringIDToTypeID('maximizeCompatibility'), true ); + desc1.putObject( charIDToTypeID('As '), charIDToTypeID('Pht8'), desc2 ); + desc1.putPath( charIDToTypeID('In '), new File(output_path) ); + desc1.putBoolean( charIDToTypeID('LwCs'), true ); + executeAction( charIDToTypeID('save'), desc1, DialogModes.NO ); +} + +function close(){ + executeAction(stringIDToTypeID("quit"), undefined, DialogModes.NO ); +} + +function renameLayer(layer_id, new_name){ + /*** + * Renames 'layer_id' to 'new_name' + * + * Via Action (fast) + * + * Args: + * layer_id(int) + * new_name(str) + * + * output_path (str) + **/ + doc = app.activeDocument; + selectLayers('['+layer_id+']'); + + doc.activeLayer.name = new_name; +} + +function _get_parents_names(layer, itself_name){ + var long_names = [itself_name]; + while (layer.parent){ + if (layer.typename != "LayerSet"){ + break; + } + long_names.push(layer.name); + layer = layer.parent; + } + return long_names; +} + +// triggers when panel is opened, good for debugging +//log(getActiveDocumentName()); +// log.show(); +// var a = app.activeDocument.activeLayer; +// log(a); +//getSelectedLayers(); +// importSmartObject("c:/projects/test.jpg", "a aaNewLayer", true); +// log("dpc"); +// replaceSmartObjects(153, "β–ΌJungle_imageTest_001", "c:/projects/test_project_test_asset_TestTask_v001.png"); \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/host/json.js b/openpype/hosts/photoshop/api/extension/host/json.js new file mode 100644 index 0000000000..397349bbfd --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/host/json.js @@ -0,0 +1,530 @@ +// json2.js +// 2017-06-12 +// Public Domain. +// NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. + +// USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO +// NOT CONTROL. + +// This file creates a global JSON object containing two methods: stringify +// and parse. This file provides the ES5 JSON capability to ES3 systems. +// If a project might run on IE8 or earlier, then this file should be included. +// This file does nothing on ES5 systems. + +// JSON.stringify(value, replacer, space) +// value any JavaScript value, usually an object or array. +// replacer an optional parameter that determines how object +// values are stringified for objects. It can be a +// function or an array of strings. +// space an optional parameter that specifies the indentation +// of nested structures. If it is omitted, the text will +// be packed without extra whitespace. If it is a number, +// it will specify the number of spaces to indent at each +// level. If it is a string (such as "\t" or " "), +// it contains the characters used to indent at each level. +// This method produces a JSON text from a JavaScript value. +// When an object value is found, if the object contains a toJSON +// method, its toJSON method will be called and the result will be +// stringified. A toJSON method does not serialize: it returns the +// value represented by the name/value pair that should be serialized, +// or undefined if nothing should be serialized. The toJSON method +// will be passed the key associated with the value, and this will be +// bound to the value. + +// For example, this would serialize Dates as ISO strings. + +// Date.prototype.toJSON = function (key) { +// function f(n) { +// // Format integers to have at least two digits. +// return (n < 10) +// ? "0" + n +// : n; +// } +// return this.getUTCFullYear() + "-" + +// f(this.getUTCMonth() + 1) + "-" + +// f(this.getUTCDate()) + "T" + +// f(this.getUTCHours()) + ":" + +// f(this.getUTCMinutes()) + ":" + +// f(this.getUTCSeconds()) + "Z"; +// }; + +// You can provide an optional replacer method. It will be passed the +// key and value of each member, with this bound to the containing +// object. The value that is returned from your method will be +// serialized. If your method returns undefined, then the member will +// be excluded from the serialization. + +// If the replacer parameter is an array of strings, then it will be +// used to select the members to be serialized. It filters the results +// such that only members with keys listed in the replacer array are +// stringified. + +// Values that do not have JSON representations, such as undefined or +// functions, will not be serialized. Such values in objects will be +// dropped; in arrays they will be replaced with null. You can use +// a replacer function to replace those with JSON values. + +// JSON.stringify(undefined) returns undefined. + +// The optional space parameter produces a stringification of the +// value that is filled with line breaks and indentation to make it +// easier to read. + +// If the space parameter is a non-empty string, then that string will +// be used for indentation. If the space parameter is a number, then +// the indentation will be that many spaces. + +// Example: + +// text = JSON.stringify(["e", {pluribus: "unum"}]); +// // text is '["e",{"pluribus":"unum"}]' + +// text = JSON.stringify(["e", {pluribus: "unum"}], null, "\t"); +// // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' + +// text = JSON.stringify([new Date()], function (key, value) { +// return this[key] instanceof Date +// ? "Date(" + this[key] + ")" +// : value; +// }); +// // text is '["Date(---current time---)"]' + +// JSON.parse(text, reviver) +// This method parses a JSON text to produce an object or array. +// It can throw a SyntaxError exception. + +// The optional reviver parameter is a function that can filter and +// transform the results. It receives each of the keys and values, +// and its return value is used instead of the original value. +// If it returns what it received, then the structure is not modified. +// If it returns undefined then the member is deleted. + +// Example: + +// // Parse the text. Values that look like ISO date strings will +// // be converted to Date objects. + +// myData = JSON.parse(text, function (key, value) { +// var a; +// if (typeof value === "string") { +// a = +// /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); +// if (a) { +// return new Date(Date.UTC( +// +a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6] +// )); +// } +// return value; +// } +// }); + +// myData = JSON.parse( +// "[\"Date(09/09/2001)\"]", +// function (key, value) { +// var d; +// if ( +// typeof value === "string" +// && value.slice(0, 5) === "Date(" +// && value.slice(-1) === ")" +// ) { +// d = new Date(value.slice(5, -1)); +// if (d) { +// return d; +// } +// } +// return value; +// } +// ); + +// This is a reference implementation. You are free to copy, modify, or +// redistribute. + +/*jslint + eval, for, this +*/ + +/*property + JSON, apply, call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, + getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, + lastIndex, length, parse, prototype, push, replace, slice, stringify, + test, toJSON, toString, valueOf +*/ + + +// Create a JSON object only if one does not already exist. We create the +// methods in a closure to avoid creating global variables. + +if (typeof JSON !== "object") { + JSON = {}; +} + +(function () { + "use strict"; + + var rx_one = /^[\],:{}\s]*$/; + var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g; + var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g; + var rx_four = /(?:^|:|,)(?:\s*\[)+/g; + var rx_escapable = /[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; + var rx_dangerous = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; + + function f(n) { + // Format integers to have at least two digits. + return (n < 10) + ? "0" + n + : n; + } + + function this_value() { + return this.valueOf(); + } + + if (typeof Date.prototype.toJSON !== "function") { + + Date.prototype.toJSON = function () { + + return isFinite(this.valueOf()) + ? ( + this.getUTCFullYear() + + "-" + + f(this.getUTCMonth() + 1) + + "-" + + f(this.getUTCDate()) + + "T" + + f(this.getUTCHours()) + + ":" + + f(this.getUTCMinutes()) + + ":" + + f(this.getUTCSeconds()) + + "Z" + ) + : null; + }; + + Boolean.prototype.toJSON = this_value; + Number.prototype.toJSON = this_value; + String.prototype.toJSON = this_value; + } + + var gap; + var indent; + var meta; + var rep; + + + function quote(string) { + +// If the string contains no control characters, no quote characters, and no +// backslash characters, then we can safely slap some quotes around it. +// Otherwise we must also replace the offending characters with safe escape +// sequences. + + rx_escapable.lastIndex = 0; + return rx_escapable.test(string) + ? "\"" + string.replace(rx_escapable, function (a) { + var c = meta[a]; + return typeof c === "string" + ? c + : "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4); + }) + "\"" + : "\"" + string + "\""; + } + + + function str(key, holder) { + +// Produce a string from holder[key]. + + var i; // The loop counter. + var k; // The member key. + var v; // The member value. + var length; + var mind = gap; + var partial; + var value = holder[key]; + +// If the value has a toJSON method, call it to obtain a replacement value. + + if ( + value + && typeof value === "object" + && typeof value.toJSON === "function" + ) { + value = value.toJSON(key); + } + +// If we were called with a replacer function, then call the replacer to +// obtain a replacement value. + + if (typeof rep === "function") { + value = rep.call(holder, key, value); + } + +// What happens next depends on the value's type. + + switch (typeof value) { + case "string": + return quote(value); + + case "number": + +// JSON numbers must be finite. Encode non-finite numbers as null. + + return (isFinite(value)) + ? String(value) + : "null"; + + case "boolean": + case "null": + +// If the value is a boolean or null, convert it to a string. Note: +// typeof null does not produce "null". The case is included here in +// the remote chance that this gets fixed someday. + + return String(value); + +// If the type is "object", we might be dealing with an object or an array or +// null. + + case "object": + +// Due to a specification blunder in ECMAScript, typeof null is "object", +// so watch out for that case. + + if (!value) { + return "null"; + } + +// Make an array to hold the partial results of stringifying this object value. + + gap += indent; + partial = []; + +// Is the value an array? + + if (Object.prototype.toString.apply(value) === "[object Array]") { + +// The value is an array. Stringify every element. Use null as a placeholder +// for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || "null"; + } + +// Join all of the elements together, separated with commas, and wrap them in +// brackets. + + v = partial.length === 0 + ? "[]" + : gap + ? ( + "[\n" + + gap + + partial.join(",\n" + gap) + + "\n" + + mind + + "]" + ) + : "[" + partial.join(",") + "]"; + gap = mind; + return v; + } + +// If the replacer is an array, use it to select the members to be stringified. + + if (rep && typeof rep === "object") { + length = rep.length; + for (i = 0; i < length; i += 1) { + if (typeof rep[i] === "string") { + k = rep[i]; + v = str(k, value); + if (v) { + partial.push(quote(k) + ( + (gap) + ? ": " + : ":" + ) + v); + } + } + } + } else { + +// Otherwise, iterate through all of the keys in the object. + + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + ( + (gap) + ? ": " + : ":" + ) + v); + } + } + } + } + +// Join all of the member texts together, separated with commas, +// and wrap them in braces. + + v = partial.length === 0 + ? "{}" + : gap + ? "{\n" + gap + partial.join(",\n" + gap) + "\n" + mind + "}" + : "{" + partial.join(",") + "}"; + gap = mind; + return v; + } + } + +// If the JSON object does not yet have a stringify method, give it one. + + if (typeof JSON.stringify !== "function") { + meta = { // table of character substitutions + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", + "\"": "\\\"", + "\\": "\\\\" + }; + JSON.stringify = function (value, replacer, space) { + +// The stringify method takes a value and an optional replacer, and an optional +// space parameter, and returns a JSON text. The replacer can be a function +// that can replace values, or an array of strings that will select the keys. +// A default replacer method can be provided. Use of the space parameter can +// produce text that is more easily readable. + + var i; + gap = ""; + indent = ""; + +// If the space parameter is a number, make an indent string containing that +// many spaces. + + if (typeof space === "number") { + for (i = 0; i < space; i += 1) { + indent += " "; + } + +// If the space parameter is a string, it will be used as the indent string. + + } else if (typeof space === "string") { + indent = space; + } + +// If there is a replacer, it must be a function or an array. +// Otherwise, throw an error. + + rep = replacer; + if (replacer && typeof replacer !== "function" && ( + typeof replacer !== "object" + || typeof replacer.length !== "number" + )) { + throw new Error("JSON.stringify"); + } + +// Make a fake root object containing our value under the key of "". +// Return the result of stringifying the value. + + return str("", {"": value}); + }; + } + + +// If the JSON object does not yet have a parse method, give it one. + + if (typeof JSON.parse !== "function") { + JSON.parse = function (text, reviver) { + +// The parse method takes a text and an optional reviver function, and returns +// a JavaScript value if the text is a valid JSON text. + + var j; + + function walk(holder, key) { + +// The walk method is used to recursively walk the resulting structure so +// that modifications can be made. + + var k; + var v; + var value = holder[key]; + if (value && typeof value === "object") { + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = walk(value, k); + if (v !== undefined) { + value[k] = v; + } else { + delete value[k]; + } + } + } + } + return reviver.call(holder, key, value); + } + + +// Parsing happens in four stages. In the first stage, we replace certain +// Unicode characters with escape sequences. JavaScript handles many characters +// incorrectly, either silently deleting them, or treating them as line endings. + + text = String(text); + rx_dangerous.lastIndex = 0; + if (rx_dangerous.test(text)) { + text = text.replace(rx_dangerous, function (a) { + return ( + "\\u" + + ("0000" + a.charCodeAt(0).toString(16)).slice(-4) + ); + }); + } + +// In the second stage, we run the text against regular expressions that look +// for non-JSON patterns. We are especially concerned with "()" and "new" +// because they can cause invocation, and "=" because it can cause mutation. +// But just to be safe, we want to reject all unexpected forms. + +// We split the second stage into 4 regexp operations in order to work around +// crippling inefficiencies in IE's and Safari's regexp engines. First we +// replace the JSON backslash pairs with "@" (a non-JSON character). Second, we +// replace all simple value tokens with "]" characters. Third, we delete all +// open brackets that follow a colon or comma or that begin the text. Finally, +// we look to see that the remaining characters are only whitespace or "]" or +// "," or ":" or "{" or "}". If that is so, then the text is safe for eval. + + if ( + rx_one.test( + text + .replace(rx_two, "@") + .replace(rx_three, "]") + .replace(rx_four, "") + ) + ) { + +// In the third stage we use the eval function to compile the text into a +// JavaScript structure. The "{" operator is subject to a syntactic ambiguity +// in JavaScript: it can begin a block or an object literal. We wrap the text +// in parens to eliminate the ambiguity. + + j = eval("(" + text + ")"); + +// In the optional fourth stage, we recursively walk the new structure, passing +// each name/value pair to a reviver function for possible transformation. + + return (typeof reviver === "function") + ? walk({"": j}, "") + : j; + } + +// If the text is not JSON parseable, then a SyntaxError is thrown. + + throw new SyntaxError("JSON.parse"); + }; + } +}()); \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png b/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png new file mode 100644 index 0000000000000000000000000000000000000000..33fe2a606bd1ac9d285eb0d6a90b9b14150ca3c4 GIT binary patch literal 1362 zcmV-Y1+DstP)5JpvKiH|A7SK4P~kT{6`M*9LrdQ!Ns9=$qj3(E_W@4gmP#=ht)#^0_psT^(5Px6qr0)mBB%5&-P}{Y*9ph@Pcn`!ete zwiE<#115v#ScdV2GBk!NTFTzWbF>Xip`p8%&KqcqI~Jb6tC``Vaf&07o~axnzSGF( z(ok|5&-4zgtV5rc$qke?7a8cU$D55m^%IcuOgXaxfTb~yegblyEaWJw%`Qe=-M%S@ zhOXSbt2KkcJv{&)s&PL6vC{g1Y-aKYBs(yc@x{whhk_0fK#=N=)Uup zs)>qe=dc=h3&3Gwr10?^8zc#g%1L4Xs{p!rj(uw=)9Szs&#`@sH{=+ zG+fz{pjE0VR%8l+hOX;W8`PbV32glOJ!~I2VXJkTz5Ufkuk(!F8z4>Ok_kkI+Kb}3)n06_ssJy4_*!y{BAe4)9jbBbSR!>UnLxyMT9bL9_?YdfL@K^^G6aZ)C$Qje z(NzKf2bZq2#ed1=gx1ZJQM{TNMk>CBw!wSvUjy@gS4qs1_a85GREVYsFz!+tU$`&M%7iR@HuBiw5bSa5S}|?)>G0PCUMb-Q{Pf zZt0{hEhroOCi1l=h%&q$mkBdG$MzLns~iea1>hEds{qcP5QbL){0`u*@Qfwke+13^ UGpuMiD*ylh07*qoM6N<$g1d2qT>t<8 literal 0 HcmV?d00001 diff --git a/openpype/hosts/photoshop/api/extension/index.html b/openpype/hosts/photoshop/api/extension/index.html new file mode 100644 index 0000000000..501e753c0b --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/index.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py new file mode 100644 index 0000000000..36347b8ce0 --- /dev/null +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -0,0 +1,315 @@ +import os +import subprocess +import collections +import logging +import asyncio +import functools + +from wsrpc_aiohttp import ( + WebSocketRoute, + WebSocketAsync +) + +from Qt import QtCore + +from openpype.tools.utils import host_tools + +from avalon import api +from avalon.tools.webserver.app import WebServerTool + +from .ws_stub import PhotoshopServerStub + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +class ConnectionNotEstablishedYet(Exception): + pass + + +def stub(): + """ + Convenience function to get server RPC stub to call methods directed + for host (Photoshop). + It expects already created connection, started from client. + Currently created when panel is opened (PS: Window>Extensions>Avalon) + :return: where functions could be called from + """ + ps_stub = PhotoshopServerStub() + if not ps_stub.client: + raise ConnectionNotEstablishedYet("Connection is not created yet") + + return ps_stub + + +def show_tool_by_name(tool_name): + kwargs = {} + if tool_name == "loader": + kwargs["use_context"] = True + + host_tools.show_tool_by_name(tool_name, **kwargs) + + +class ProcessLauncher(QtCore.QObject): + route_name = "Photoshop" + _main_thread_callbacks = collections.deque() + + def __init__(self, subprocess_args): + self._subprocess_args = subprocess_args + self._log = None + + super(ProcessLauncher, self).__init__() + + # Keep track if launcher was already started + self._started = False + + self._process = None + self._websocket_server = None + + start_process_timer = QtCore.QTimer() + start_process_timer.setInterval(100) + + loop_timer = QtCore.QTimer() + loop_timer.setInterval(200) + + start_process_timer.timeout.connect(self._on_start_process_timer) + loop_timer.timeout.connect(self._on_loop_timer) + + self._start_process_timer = start_process_timer + self._loop_timer = loop_timer + + @property + def log(self): + if self._log is None: + from openpype.api import Logger + + self._log = Logger.get_logger("{}-launcher".format( + self.route_name)) + return self._log + + @property + def websocket_server_is_running(self): + if self._websocket_server is not None: + return self._websocket_server.is_running + return False + + @property + def is_process_running(self): + if self._process is not None: + return self._process.poll() is None + return False + + @property + def is_host_connected(self): + """Returns True if connected, False if app is not running at all.""" + if not self.is_process_running: + return False + + try: + + _stub = stub() + if _stub: + return True + except Exception: + pass + + return None + + @classmethod + def execute_in_main_thread(cls, callback): + cls._main_thread_callbacks.append(callback) + + def start(self): + if self._started: + return + self.log.info("Started launch logic of AfterEffects") + self._started = True + self._start_process_timer.start() + + def exit(self): + """ Exit whole application. """ + if self._start_process_timer.isActive(): + self._start_process_timer.stop() + if self._loop_timer.isActive(): + self._loop_timer.stop() + + if self._websocket_server is not None: + self._websocket_server.stop() + + if self._process: + self._process.kill() + self._process.wait() + + QtCore.QCoreApplication.exit() + + def _on_loop_timer(self): + # TODO find better way and catch errors + # Run only callbacks that are in queue at the moment + cls = self.__class__ + for _ in range(len(cls._main_thread_callbacks)): + if cls._main_thread_callbacks: + callback = cls._main_thread_callbacks.popleft() + callback() + + if not self.is_process_running: + self.log.info("Host process is not running. Closing") + self.exit() + + elif not self.websocket_server_is_running: + self.log.info("Websocket server is not running. Closing") + self.exit() + + def _on_start_process_timer(self): + # TODO add try except validations for each part in this method + # Start server as first thing + if self._websocket_server is None: + self._init_server() + return + + # TODO add waiting time + # Wait for webserver + if not self.websocket_server_is_running: + return + + # Start application process + if self._process is None: + self._start_process() + self.log.info("Waiting for host to connect") + return + + # TODO add waiting time + # Wait until host is connected + if self.is_host_connected: + self._start_process_timer.stop() + self._loop_timer.start() + elif ( + not self.is_process_running + or not self.websocket_server_is_running + ): + self.exit() + + def _init_server(self): + if self._websocket_server is not None: + return + + self.log.debug( + "Initialization of websocket server for host communication" + ) + + self._websocket_server = websocket_server = WebServerTool() + if websocket_server.port_occupied( + websocket_server.host_name, + websocket_server.port + ): + self.log.info( + "Server already running, sending actual context and exit." + ) + asyncio.run(websocket_server.send_context_change(self.route_name)) + self.exit() + return + + # Add Websocket route + websocket_server.add_route("*", "/ws/", WebSocketAsync) + # Add after effects route to websocket handler + + print("Adding {} route".format(self.route_name)) + WebSocketAsync.add_route( + self.route_name, PhotoshopRoute + ) + self.log.info("Starting websocket server for host communication") + websocket_server.start_server() + + def _start_process(self): + if self._process is not None: + return + self.log.info("Starting host process") + try: + self._process = subprocess.Popen( + self._subprocess_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except Exception: + self.log.info("exce", exc_info=True) + self.exit() + + +class PhotoshopRoute(WebSocketRoute): + """ + One route, mimicking external application (like Harmony, etc). + All functions could be called from client. + 'do_notify' function calls function on the client - mimicking + notification after long running job on the server or similar + """ + instance = None + + def init(self, **kwargs): + # Python __init__ must be return "self". + # This method might return anything. + log.debug("someone called Photoshop route") + self.instance = self + return kwargs + + # server functions + async def ping(self): + log.debug("someone called Photoshop route ping") + + # This method calls function on the client side + # client functions + async def set_context(self, project, asset, task): + """ + Sets 'project' and 'asset' to envs, eg. setting context + + Args: + project (str) + asset (str) + """ + log.info("Setting context change") + log.info("project {} asset {} ".format(project, asset)) + if project: + api.Session["AVALON_PROJECT"] = project + os.environ["AVALON_PROJECT"] = project + if asset: + api.Session["AVALON_ASSET"] = asset + os.environ["AVALON_ASSET"] = asset + if task: + api.Session["AVALON_TASK"] = task + os.environ["AVALON_TASK"] = task + + async def read(self): + log.debug("photoshop.read client calls server server calls " + "photoshop client") + return await self.socket.call('photoshop.read') + + # panel routes for tools + async def creator_route(self): + self._tool_route("creator") + + async def workfiles_route(self): + self._tool_route("workfiles") + + async def loader_route(self): + self._tool_route("loader") + + async def publish_route(self): + self._tool_route("publish") + + async def sceneinventory_route(self): + self._tool_route("sceneinventory") + + async def subsetmanager_route(self): + self._tool_route("subsetmanager") + + async def experimental_tools_route(self): + self._tool_route("experimental_tools") + + def _tool_route(self, _tool_name): + """The address accessed when clicking on the buttons.""" + + partial_method = functools.partial(show_tool_by_name, + _tool_name) + + ProcessLauncher.execute_in_main_thread(partial_method) + + # Required return statement. + return "nothing" diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py new file mode 100644 index 0000000000..bc1fb36cf3 --- /dev/null +++ b/openpype/hosts/photoshop/api/lib.py @@ -0,0 +1,76 @@ +import os +import sys +import contextlib +import logging +import traceback + +from Qt import QtWidgets + +from openpype.tools.utils import host_tools + +from openpype.lib.remote_publish import headless_publish + +from .launch_logic import ProcessLauncher, stub + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +def safe_excepthook(*args): + traceback.print_exception(*args) + + +def main(*subprocess_args): + from avalon import api, photoshop + + api.install(photoshop) + sys.excepthook = safe_excepthook + + # coloring in ConsoleTrayApp + os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" + app = QtWidgets.QApplication([]) + app.setQuitOnLastWindowClosed(False) + + launcher = ProcessLauncher(subprocess_args) + launcher.start() + + if os.environ.get("HEADLESS_PUBLISH"): + launcher.execute_in_main_thread(lambda: headless_publish( + log, + "ClosePS", + os.environ.get("IS_TEST"))) + elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): + save = False + if os.getenv("WORKFILES_SAVE_AS"): + save = True + + launcher.execute_in_main_thread( + lambda: host_tools.show_workfiles(save=save) + ) + + sys.exit(app.exec_()) + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context.""" + selection = stub().get_selected_layers() + try: + yield selection + finally: + stub().select_layers(selection) + + +@contextlib.contextmanager +def maintained_visibility(): + """Maintain visibility during context.""" + visibility = {} + layers = stub().get_layers() + for layer in layers: + visibility[layer.id] = layer.visible + try: + yield + finally: + for layer in layers: + stub().set_visible(layer.id, visibility[layer.id]) + pass diff --git a/openpype/hosts/photoshop/api/panel.PNG b/openpype/hosts/photoshop/api/panel.PNG new file mode 100644 index 0000000000000000000000000000000000000000..be5db3b8df08aa426b92de61eacc9afd00e93d45 GIT binary patch literal 8756 zcmcI~cT|(zvOYzmcaYvenu>I!gAcEQbafq2EG1+_8~;E{ld%orr(=8VmJ|>!M=dj#}z*^FfDN zGXc@iSO!!TWOab1yE%SoCb|KAQBlEp(KuY%B^a`bKDH#P6e%REmW-;C>os~>ywlU` zc5kQIk5tOaq^nvs?FQO7Nj^S&59rRcyf66U8fNJAn)LnvyWoH!mXAEJ^XJlHtr>i$ zeRO}pZ(}59U}5QcuU>k1p*`rr^*&tUR2{saF){v04GXQ>b=E39=KcG72hF1aAXpOo zJzN9fjV|3P) z!y!I6C3IuMx+YrC(b3WT>~MW$khk&Pn$gi#PL>)t&#>7PaK|(j^ms?No#sm z_^CTknkm3a7Dx)^k_FNPBYSbdLulcm>LjLjncG}LkpgJ2_*BEL)(n}ymJlv3F0M@J zo9QkD{l2pN?vU~Eqn`bMXX$}&=#8vB5cv&z0y(&>C0vUR!%CTVYK`Bi#Kx(Vy}LWW znXA)3IzK-@&{D(PCKHO(vmSxf^JT8rR{9S-05tODIA-iO!x<~5?@k0dRoh3x-YpR< zh&O{O>POGrh+A_dObNpquO*RRF>AN^3Jih!9T$7rn(jm4r!5H3(XVr}-|@c=-=*hD3kg14 zF7%AJchTL2m}AeEc{{KF2Izk%sGpUspEumC7LaP zLYdL0PVBcDfWp2J@O+x;^=X3+;l4u<%@Sequ6x*Khlkh2LNnPZT=&UFq`rBd@s21q z>cm~JOm7pi{u;n4XzK~H^iLZAN#940EL%Ifr($w#yK%(PN1c}zH61vujh{fUdY0y+P7J0a$13WNIGkh_eJ`G~5L6L@?!ZjihB=y2dJc!xBHk7V`*>aVLjvzZ4?6G_ zCrFhukRmZ`OK#a>#|2X>vfDT+tKYT>*Jk!}W% zDzV*_vhHMMrqsI-w*`qMwh`k44%g$aYy)aY{Q+U)9q7IAbTn86;1~x~qy^^f`n7fa zn>@(vj4C^QNcs@Iw5s8k+q8B*-z_}P$O&<+_A??e#R&I480UhuThCjVZ!JL|BmmaS z*-K`J)=AC|&$_8et_xFpaXwZd@k9>DKJv90XHn8E}fUYfbI zHTWxnYIfx$V#o06!!3Qe*7Zs zi>?lYoTa~26TRYW{@8P?-V(U}0rVp1YcSt2-acn1;Qq+VlUZY#MCoe<*X;=X(nQ#d zkvdTMaOZ+;tnG@%?Rm`Nd?=C(MGD%L$iSU>`IYd~>{R?sSI&0j2L``Sw=Kl(`z#fA zvgj@TVs8H2{l^BeQhgxca4MJbdg4b4A>CA;%QLt0zWHveoYNHhr++*dpXwWN+GW=K z?Se_*0cYEVL-kJy?~=s6wkykn6G7>c>(8^-#{Sz8Wj!2A&8fyO%ugGb+wRZ8FL+B$2MG8T5kz8B(V~~_aiTmS9^R;qGF5;HCbiu}-pxHwyHiS4PR1GJ zlKJxbiog00-brcWe|Ti-!ck+rb&Rv0EdyM&>@XGS+-+Cu&eXqbZu(^uu(JJ4IrV_< zCm)ZPaLUA^mZ$!gt=`H1#q2Y@%FE7jsONQYcTdn%VIcOeEBj6ksPAB=Y5dwxHAsw= zNhd2L4Fy#kz7WF7iwKNM*Wbek8q=7|%+vCBkS|L@;)ub@Sc{mTN51s1(kI&$!L z<=N&c^gTXY)yC|Bp;M4)c0CW1>Ddsj4d}Aw*Cg>i=j~y1yZqoe-m!q|gG4li5W-$# zFmJAu#_w)lzHBlEsQh+r3>ewrKJaO!aEX!PJ0%n{h$1jrsKj2A{3VPy2#p!f;qjM< z)2!UAN^E{YzFK^JESGdbu%Owkp*U`g%wj!Tm@Ip!ngVCm5H z-+T+u}o+F}SG}_+j@_9?+Cp(~X=att% zFP$?Q36==;6xeUJW_{3-d5NSwZbPAC%y-B!DrIy@)A?yJ6xiN8qjrR-ecx4 za3Z2ZSm_TBg_s2~1ann=`{Ha#IJn9pc}s`;G8=Yy2aJq{XAQ%Jvgl zl;gW(BlOMXYpL@_;1Q?OM$X4jrYDAb^D9@ouM!C5SD1w>Y4=*{L0b3f^*>N&-R})X zRX!>SQ(&X>(j!x!b;?(A4eQe84!@IZQ(=hg{zToiJnA{kp*GrT*rG-pI#w&P88H8aUYNg zE!cM1sk1_QWeOEx}~O6!!&Y!4!iAa-{I& z*)$#THrSIjQWxmlab|t6@|G{uBxhS`e?c*hBd&gknf=?0{rO2&a9!dj;Rzmp_3v*j z3It2vFk}bwka$cBD1LYcfc25e7<0Qa%lBR_2-kZ zI%l1TiWhM$o_sr0SBR8r&nxpzU&$|B5>vDgfIxb#Jnjdm{fkzBw1 zV;i>-M!|A$tK+=L!R66tST&L7D%IF{=V&PtB|}ZnQOQK{EMzHV(mPtKcq9jr*c$M# zz}b7c*cJXk3jaj6#2yDS%XvO2nkHR6S`kk{2USvz@V2#8p%?eSiQ7hc#fb-`1{5ep zax@g-$;(eqER z<4P9wHK|U!DW1dhW4aijwo$25`k7e}_(zA}X70`ux)XxZZ{nJvKx)f=YaRjF=!fj? zrph`}No0$EsU+|Vcc9hJuS%0TY*C+==Jim=eAUMPtS$o!0!Qa9-cQiy15_SO&wLF}m5HmX-(jAPGs zO(t_|lz*1I%DS2q7JBL&KoD*7(dTZQgC6>#q+Fa-tr7NDh9DK}vNx+L zdg(?M3J&UwqNjW6_FiR3r+aK(39l^+jRCk&`G(P*%y6eh|7SU)*(@ezQ%&|Np`NT! zCcxal^EXM6Vbm`O%9saTJbpjgsco{D1-g$~&WX+zc1dJUTW<2(w8wV^#y=^1+uFcL zJo=cs#VB=d+KE5&qzAtYd2)LCw)mqjUNF}yN@z!i^ub!H1_lWh3P0WC8mN>m2csHL zUdEtHSG0m)U7(+@Z?{FF)Lj{hD*XNZpx=JC|F*Wa_7o;cS0Ti#JGK! znaRL=jK=?!r_F^jB;ysOX;ZAsvno73V@2lsGZE=-Eofg!^8ZI>l`IEf6~E! z`1=HJ4Dnmis}%)tYu~C?P*pS=L+5Xj#zmgXx-yH3D5DnCIcr8V_IFaSm4os1ldY5p zN?eGdY1}S3_ys%EhM#>0uD-jHv;JN*T510f&WRVGyf<6k*e_8zs{snqk7y?Cy0OFn zLsSzN?@CTp^Yhf|8}j=QO3MrST+L1Tr&a2O!#S24pX@oQMUzb=MEiiTv6pK6;74s~ z6CC2P{<4Z0=h^f9M_3Vitc|?~MC+0YbFOJe_@p+IBSn^b8H`686gm9nw+41ORfrtq zC{UuR5gCNfQc^bGh$~x7+KG(8V#5`${^ROnJbIOrtV2XMDvn@XOa5yX$khw%v-BHsg7LVOEb~6i0Za>8O3HzvRA3daHyj4(mCtA| zW;_Yj8I(Ob{qBQRb*4Nim~LI0=!H8FY#GQhuA7#0ivLDbYXeK*$T&Bd{U;F(JgK!o zN;$A|*gdTbcTq8%m)q;Fh<)F0(Z#WRtc_<3$T#bE$2QQ=3~}c=V$E!E$^G$MAs)+q z{%FRXpzN!fEkN7h4~}k~MMAyl82;Qw~xcr3JpznALsFmwHlufL)A zN5##-NuzPSV4t!dp}B1U@RIFXUUns}V$W~!7%X;7KNQMTrTv|UFR~fG0^z3P~hwUMhnIY@RdH6}X3%2gT zy-zTXUK!6@<6W%VWq#E|#}Z>biv|5SR~gHG-GdUc*=5O7SVI@H;kB z$0p|bE&6gC#mQjtIQq7uUd%FN?-tLvdj2qzr6iyivnU6UpvL8LJ>09c<`Wv~S#HGT zoq)$!=&}Gx-X#~0$LS+=y{i6tHh;!3}Q2E}5t zn|+_#XoNMUvm>d`R;{v?T0Yl>#XKno)0LIk=4yviHzvBBH$hn-qCeZH7qjnlpNDL$ zaMkSPeOIy}OW*{;Q|?^&ta9WiZC|=|{;;B8K}lAWsrdNUF{!i$ig&Bq_!qaqAjdJ> zqf$c3%E_+STg-3d5;gZ8g!J{(<;G8N=_AaXA5`JNYAa|wzksCMA-qrGp|QqJQq+bvKOXW>N|U-F}&?8myl^x)CNgE>** z)a%?Vdc}N+=$H11+8NYLDSug(qvZ2b9K^&;gbK2qzSb9V*8utV!x|JVk#=4br0NAO z4|kac{T0sRdRC`$lr|io2E%9LD8JukA6J!n0S{xrFn1bLZJHZJ=GEnKq-Rr{no1=C z81W3)8dS^#@14lnd2oiP_S5+igZb}i@^2Xb6Qe%Gy0h+q9(+r7C}97iihq+KC~Ez$ z$)p75;Q{-`(yQc-`Lm6VJ&{ced4eLrgXUPd@)=$v<}SpPg@fT}8=h*ONLE68n}y~f zC--RLj7l6gWda>c#hCAYTFkS^rp7!*xfg|AgE)zPPm;6YXp>bzbL+u4UQW$)>c_F9 zsv8XB(uF%#-pg{V_GH>NnxD?Brteyz3X_mPFReDx$(`sGr>Z z%TLZslO;q{rI11K4T<|pPEAc4Nx4jN)#>nr!W#Rqnp?!hU_$s5;sM1{N|cK-Q;;Me zBK*p+-MrG5UOZv2f^ZP!JO0*K_Cfu%mJs7OH7-vgEGY9^!>(hR_4D{vF8#tBIfEdm zt4@GpC(~`Vdr@+ir*a`FQ5GVw@Y-dY4I$W;6ztP1X{U6v9pUWZ(=C^8J^B#lkXK?vb|746!&uUYZ*0g;t@p`HB)S4g~No64T><`w3smgV2v z_}?ObTaNz?Nd6aS6t!DcjQZJIgi-e${sv9@h3WU%`OgWSPprN=xjmB58(>Uz1S?@B zLKDONwl7fI(ea>uNP->d0mMEXyItoz2`a$b5r8c!#+?mMX{F_#*1hB;Yg0Hzv3H>t zrS7X?TczIU>NL5;Bm^KUqOgB))HivV{R{73K#lUcbN1^aiyC1<0S5MXxyjQ!9u(Wu zmX(kdYRhYnVNv)-2v{Syc*;2!RbD+p0p?~qt|(1Ks-y#)Pp0{C+~mDA2?gNx`&KjZ zqGTEPf2l3*f0#y!5Fy7dB#Lh5oHF=CM`rwXI!g@Ugdc~ zK$Ou@`OHL_BVIlo?j)*L1nVjkO35V@M~5PDcJ)Ha#hJok;as)R?7?RrkIq3$lqCv69M{p5!x?2@Zqhh30)_PcV#(%YJo(XmD1{Bg!8jT=SiEOmodS{AuD? z%w~StU5YOo!U71dIC2XPO`FI@TM#NfX`ljEa;U5MgKx(a-?;)*Hy;S>v8>Y72+uua zhud8q+tiJBi^opV7!xPccyIVgvl#)LD0p=UOYq8&N?SYvjCsSHYvygT2<&c;!3KtR{ z1fsbtf`@7gdMOEsSf&grLn5 zfC)RXIS*Gfvb-0&&_4EhZs#m>+jd3>528=9&v~Py>XdDxrr%Nj+2qRxOV*CCl5FiG z1LtX>5jJOO=GT-r;lpxb#@ofm^H(6Iqa655T6}+>Lit4Alytyfj*@>QZv(pc*}0J@j-b+&!T^wrV8qK&+>%5HYui*Y7LZmvbIn$M z({3_R##e%_drMZ}ETOh;>WI=t2gXc_#R>t3E<0n1aFMr8$79oH>iNaFC-6<-#jGDb zY0KitZpUP`pI@Y((qww>cBDiHDE;Zz{*<+U0`u*x@!xU(4iD!~A9qW4|ES_$%pm`w zxW@PDT3#v8MRv>DEu?(WjOPD=L^(nT#7aKcKA23twqguvXd!8@sjht;~4dm<)Ih`PyGlC4w9mN}pcI7w-1bQHX{3FC|4B z5?Q(TXGvY1e+br3&D$`vNzNqSv)Op>#O)_fqHGQx1V)86!>;c+gS?pQf9@XJONwe&iDpoum^;3V3(iTa6=J);OZpvz=qsBL zE;Z{XYl3kRXO7z~?(lA=v=L-MuzdP(QSN7R&B1aUnK|E~{a5N6sFsladN-36?lkdO_^vgo z2?y)eWdL6~=#EYs^>nHKZeL0r&HK>ZuFh0KcuV!mE(dDWV?NyFw#}-N3e%yp|DUe^ zuMln`1S;5K8M}8a5-&gB5cadpKwvDhT>rbi4yN1UU|=C`wW5g{>UQmO@59d^_fTp~ zcN9FR;>&D1i7E-+I1P)3$I{D3(Yc=t2C|V45<05x-s9IQwdOao59_*_AFRI3ax%!N z0;7nMR>#@cr3Y*!Nf)-Q_Rki_d6yy$7q1-54!^&y%uJW?_WC?%mL3stbJ;Nz zYT++gfq8Lc(TkOqknEJF<$1KWfsHudW0B%K#(kG%G3Gu+6Dz5}xMwhjfS`R?Qnf@NwXrv?)*E%(JV;q zTMNj()V87vb;I4al;Ga@0MVu`oNUv`?wa)Y8^V@K$P{M6 z|L|yYFh=7jQ7P5IrEISuBd7^JUIg`2y3`*z$ zMyv%|Ck}@xBm;5{ydZ@ftO9-#}Xg z#860euqZPcy|}H^_>n~wDqM7_kg&=L=$mW`WOZ+te4dsEpB1$A)?BZp>rARu+U9Y~ zk$;_2dN8eC&+QfDp0hWnzF6L-)?IH{{Gwy@O7OA$Cy2Jo=PrWVi4ZHw?S$erSnb?% zoNm6#b%sv=ni0apjtdW8jeX7Vg`y8VEw5eX?3zGuNs8F#y8?r!v@_`79u~w7{(Z2u z2nQr=pg(m7VLzH|mL5-)z4aJ1YE(DJ269ZX9By#-%8-gj+qZJYRQHDJ0$1HAX{dgj z?s4=6haa*^Ewqbl8Syq;SDMX7-%c^F``YQg54F~>d|o13{fbO#Dpd^3F&2q6OR0~? z{!edMXbqnc!W8B0e?%e+@`Y{Xfnz^t>sZ=sZ3*W{Ooi(1I^yur8it94QxR6k(t+Pb zHU!7F#Mon@M2x;FEq2$3qQoA)Ds);gltqc&tI`s6tzbrp}@!_r43fx~AgFZ>?}{?F$?^%OF3RXCM9ID7dTomj{3d>(Zt8=DMdf?< z!CrD?3&g?_%hFJR8U$MH6-ERyP3Ilx%zMmD?x7YZ4R`pHy6bcbP=}Kq{HXhUgG!mX zv`yitz@h9a(pJ;O7q8MqDU0F5c&|oG>8ZH39bRzy`~<$tr_^NsRLqqh$xuCgX89bJ$0$ku^XZd#hWMpNrO>NQw*m#FNEJFJ+& zOah|w!KlE4rS^wtqPINS;tvr+KRxDA+&P#YJY*lyLWy#M*!uGBO!f2l%I|o>3_76J zR!7irg;;jKNmhSnP*%S3zrhD->bBBvuf053KhVHN?xSDO3*H-c!*0IUp#Tq)rbaiO zJ=c>noc89*i8=2kWUCsi@ZgPVuC3KOTE1OpY(CnuJ^%5qGkTcyn&0Ad;xNC(=_j{I z(E8cigZX9D9$mFeM?Q1}qO>BF+om~M(l4~;bT}8;zqh_;qe1* zr|4$+8vH@7&m3ES{s_@kwP9y3U4vo?MJL3~Y*tw6qki`BhX3K=yHW^QeYYFG+D0lM za+c(e)8Yv7ajxp5l&1g_j#cN6G}XtirynINl2w{{jY@sZ1J@L{M~AWl%erzbU{QhH zww92Pg;lZ8r}=ABK?aAHs`J~8QQWhji$%hfce_owS6ekq=(Y{RJmZol;KK+Cv4m0A zT?uA=R_m6NwpcC%U3`0_#dyYfG^v0PHaFp6obh5=Ru|7t1FOTXf)|HRX*>Rm-|M2o z$m7vPL_}t9%sg?4!De}Rr*>oY)mg_w;fSZlY|mYJQAUz0lx(_cP4s>4GnJT^|> zHKj7fJ$%W=EKxB*@{@1>^3ZGbU3b`@yey{Gve!%a2f>(-m8pmjCcdj~x37P6w*Xz~ z*Ic>qLAGndD&Rhz#%ApWT7HxhypTvZzm|GblsG!jwv|$IZJ0hc3l){KbV6$KcQhjE zEbOP<#N=d8C{^eRbp%Cm$58}#uWa5yL8}1B$0X2&CtrC;%aWp;KHDpe7E055Jkm`f zLP)Oz60``xrf+|$38S&+skJFTS*w)jW-C6Fc9!1Zz+Iya#IU^~`w)J^em+T_R+n-4 z(c8T8qN8Qkh)Ob=cx2w>cEPfmOy+!`*!=ot_7|LDXikQ6|dEd z5GzN+sGkp3w2nJ=yACBr3ncr zMhv5)Mw4Q~qt2sm<*?agb_G`!5B@20w!Jr%Wq|fCx&H1p>g)SY!JfJplC|<)!5*jE zn~*>IXYJONB0e^H!Dk^W?QD+?udH$MF7)KXIkE$oKfsPX1G^?6$Osgl%L zg!}XVbH80|VWUdDn?bIXFtVBF^5H0g!w)xGv{LOpoS{EQ{6(3e7IxI=(Vbw$>;2by zQE8v`&%TfQ_o&I!#2$AXOIe&RepscBn0|nr+%L3kr3S*FfxP#{YI^718-Iz=Bi{Bp z`xDw`d$-i;PRj_05>d>}?6zQh=k<2%e>0^Z0{(rbIP`M3Zi8)qatZ2biDJC@Q(Bza z^H$-eZx~GfPtxIybBC8_^lTq9>@fNj)AE{JEhpO9ow_)DRda9j{gm=@^cgp+Xd&(I z_~RQVO$M&~gMtFE|KVniba?z(R@FUej3-TX*ac|A!{~FCiWN z!x7yF5w{^CE!-p6hd9F7@7ZoAjXBl7$)H7ltaI|B+2pU1CRpc4Y;%H|&O2Io;{(ZK z&I3BHSn?L`Z$)OtoKHsR^a2S*zX#>MA!PBVR$;2>qG&1%H?(<=^aqnp8%O^IO8$Ik z+&M_uXi zzh`)S_qDQ#Z(pbLwEOQuc?A@ce85TJp9{B+d)6)g6dFwh7gsC~doin%2meMEBm-zb zY_WC&sjr4~Y$?>G5wg+SF4m4+iHp1!p`^O>ouTt&S68_bK}8Z#q4>wHkglAC8$64; zUt5zMb+>Q8wR|2Zf%iS$r^pvZ=oUOht1x~@wFWJ5RIN7!Jp?|MZ z=*A%B(K-P-O}5s}RMJy`3G=p$t~Ub@)4h->m;L{%MkJ!FOg@ zr+M^walBP*ULM0yGsWR0+;;9etx4*H5Y}m;t&=|I+f1u_+>ixel)U+UQSb~P)8OzY zZIj-jvuT9gSBnqDxG{kh_s>Q~9$jnn*OX=C(ryhte283Xaxi3om9P9THmH2gr%~ zE&sd$jdg@35WoY^rG462F*38Z*E4TgNP8;6WcelkF)x&3yZ+XbO%Phhf)h_|cxod$ zZ%G6HonVG!J-;iq!xLbOyW_vWh~46E*lwn+rT52Z2Pm3f{iC?2;1(Zb6mzaR+ubDG zX2;(@jujd&D0(`y5<(kMnK~{-AR5k}Oi%cOO7C^6EhK!e5i|IZJ9H6Vd%4X0QmWrz3zxyW!rI*vB((m0Anm;2d<;49 zp)0}RYHtD%dM{#>La``VWK@7?1!Bb5IxI`ulSGmOeJd4+WfinZ17x&?I?(M=PA3PD z70F2t|Na#Na+c>)M1A`3<{%(JS?T&-fa5BVUGMTHAY8KJ_Esz%!014eHaoxlC&#M4 zu;^Tse5X%}|En$xF4Pn*UwOAo;|V8BcCiwe?+e$8TMGGFJOW9wWDCz@%Sce?>eh#P1%Y}|A`4wpH==bR(ywOc4Vqd_y z;9Pa!A$Z1?`Swx_7m249$l-)np`E8w-S6idHKW5|$34{nAz~PIBS4n?4Q3Ve$pX=t z5>UJEJVb=8|9MdeBe8rY<8G`!+W!O4o?J$**_9;2V))i2f`kRT4KAE}UxoTo&GzgUEzj2KEok~ifi-atO8l9Iy+>aypY@7n$lCrEY5 zQv9n_s#71nFg2_%y$4&TFq{r$j5kUct@AgBbn%3d1*e?y^~=?L$l#HyRGnW_HK+1} zkPhik{@fR;_&RH4#gdTRko1@%cq8zOS{y~RH)vg6;xFU&0!g4sHAT%Qw7@O^E^{A>o3jgtQYmo2H6N69NoirWvLETAbk6PfMMqaNxIqdmz6NYs6-HbJVo7doR~^Z z-LglvBXQ*x(AS1vmes=d5&pBKA~j<(Dy@OiM~U|sMx(R+OVb_@CT8Vo5P%z=bqb!) zCL9cL75{aQo=vA(n`7VTk4vpqR44Fo#ro0chf@dp6c!ZCd9Q{Uox?CJY zI8-k>D~OY(pB=Qa8l<#9J>4t7fK}o)^lS_y`pB4RSjT3Q3S+hoTCX63EhKzw+|H-BS z9Wy&;N#3=dcA}`#+m&uMIH9F#$gI1Jo3yApeTjvbARQh(=hbo_1Ko$TFR71IWhS$s z9YoMnAGHmY`7GGr%Z`s9s{FRdVI1b0mtHyKV6fU7G`Zkul*Pjz)Oio_L2%B^;n<_pAbbs6 z?Mlhw#H>~xM&jW?oUPMJp0?v*+e=C2qc6j8bFmQ*KB7_HT7w_=jRbevsz89BhGs4C zE`}+fmfXw>#}pRa;=N26=`IQR^3@QZIIMEz;UGQoOqupT9NwH#IuVd{%ODxG3A5wg z9`T^vL0uUcnT=o#WsO(JMMb^l7>JbRv7=HgykPeCx$+rw5}vM20CN;CVo4H8FPi}w zPd@ZC)$&-o@Mi(j$`-xJnj3`JSY_FRX-(ewI8(cQg}UkxUGj!L&Ya^zH);zEy?jlq zo|rfVO$BLjhRLE&KRYPtVDB-&Mi8-TDn~-&?ApoiwGrrnz<;gheS(^4jSaS?S%c&- z;bSUSLfApWzh~BaPe8+`6iAQXzOpI-S%m!crVH`X6QqZS5D0c6iwUzP?O72Cb)wWh z1Dl=4=x2_>Og-fu7oo%_c_}@@HQ$OmUG;t6;A1|3gS5EqCM#zx_zp=G8@=xHyqTPJ zFQLirk`C|s9xxNcWfkMJ)%1&tkPErU2C0EI1 z#mrWnLSc9H1%@@rm`*W}%GvrobS1mi+}0ZBvXypcyseSa_S($U*B*zUW}T^p8-2@QpVTUsJ7Pfl6+7@Ufl-$&*-`y|sEs zu!BwlXYbuZARiFVTeDEAYtw9?j49Q0$)R3~CscT!DDRd{&re1Hn>ci@cIxNGH|EGE zh4RHW2?mWoJj%OLzgGVOT;K^01pwm$``~gSI9mtwI+QEgL;|XPRQP{s+#`q0W8F|o z|9&PsS~Z_c&$0XxP@$cpX5C4B>)oUn3J+d-cZUTwkn=+?xeW7wcr6}CGXPU}p$ml8 zrLx5)mqNv573qB&*mE|#MnS5T4IH>S1r<64n5Tajq~_Oaw(ELcn(&#p)jYBHaW1A3 zc&2aUw}^DhocNRh!i^jF8w;f7=rx;R*i2ksy{v1;D2MK2wdtLHI>4miAmgi{G*|iE zo|*TUqs=3_E26dkXz&D$ySzKpRM?H4Y9Tq+1WHy;K++=EKXwS|D{k=3$#Nr#1t?7c z6STk8T>Gl9s|1p)2Q0wVpu-n-R9Z3q#b#X0S|EZn7wj2IiT=Dxh@6$R>;|UuuS&}{ zq_{<(q4B#{Aq#DXePGM^MK=4!{=rm{0wFCB#4-?>AxXf-Aqlgaq8!tqN2Nc;zc?3~ zU;8|0yBs~mEV~ri7ezelK8lV#_u$N^RRLCx0BNwei3;r~pVSLCI5DMD9{BssX6q8k zyrFspZf>}DNl2ZZKccjB-JnG&(%Wu8B^e25yQFo8TY!~+h|uVu{y!}59-X@(3n)zt z%Qygp|NpOX9v2;?{pCMA{B_G zSa#kTr+4yR-uGl+Knw*;OZ-Pg1$Yb2Q~4DSUJpcYyAY&^G1A|n!yZed|6N8!Y_4JF z1CJ`6PGMY>QsIUcuxfO)=sq0#0XtB?gclgyQ(0Gli{|U-%Fq1H%5`B3#8n(NazjW( zRX<%07K?PDnBIZC(p)8=g7Sr6XQ7`Ax|4lgrf8ph|50b*_kU=yrl|cboS(airgEVF zo`89Q6aZ1q1G#@b%~GXAkKRQhIe6>o2`?)loCea4VxpSa9;`^M-og9p>ak`O5~!1< z_$H>oqmJm)Gr*f_u+dm~I(uXlSbq<^-_m#ge)?Ce670zHc@t`})UDWfqMDV^EJPb~ z4qYewWfJuRP0j&~paYJ#tI}MIDtDrXwfa1LBcAQnK8ichZn8M#o;v$0Q90GC^b-*^ zQ(agxSPd-CjdlU<#*{VI74j>2J|=+(_e$kPdj2Z(d<0m?4OiGz;@(a-k#9i5uRkU8 z(G%^#M3Y9d6rTok1JYh%n_ijl!do!io$8&1iyfwqj(4Qty+Ej2$|5w%f&fGP?-}a< zMV!ntp}jf@u2{o}-jy6ET{GCt;Y^K`K6r5(zMk-D8xnrZD&=|2exZejbiW7cB#*;5 z*(`^}O!)jziaV#FZ$0Ju^X@Qdx3HMg#s*{FFR1L*K^79<)MFSk&O}<3AJ^+5*V}Wv zXDN(hK3Pk@tJ%{WG8ar{cPtsuWBcGuX=*2#tQS*!Wp>@4ZSvm2v<{0P$8`sDgE_aA zcTJ}K%?47&e@{Z(*QZJKxK?LqE-x!@xQX*5Q)p;g;vN7IP^+tOobF%38y$GdI(z)( zu<4T|SWZ4c<$2=!043jDt^Rv8O{<^go4Sii`}GL6N9sc$$Idu4{jmWFmnM$>b?C5B zZ=;4!MB>EF3udw)92f31lspimIPY4Xe%m*(%#=r>UJido_`hXi4XKF#Kq|7>{G zery;fXWa4vaQC3tbE(IEOs$kg2nJ`&DCC>LyWV@AF=Z!(8%<|Dg-r)>0m;Rj?KneT zGMqL>UxlPN=u_(*%rss_epr&o);Vw>HjW$7dT~2xOYf7PGe5CP7;hTN7eo1OLhBUz zV!b#f-7}GT-Bm-3M9w`PSrwi~hM zkaFGl`Cb`a!W>dT#_rl16g^f06_lkjt?f2pldUN~MYVGa&TI1cke@0LP-dH|1Q2a| zKPI*7C2l?`2*^EaK4ZGq{aoMtjDNPC0)q^3Z70*)-Ek_NQF~@81qA;nxZ@$(gN|7JpRsQYfv(hBzkBN((zEjK=_);JrzS|2_&E(@$}wD* z_^dJ=QeR#%?0X*Nar&#$&XCcNADs&^NmcvASE-q%_@e}OAoG>_ULDf_r_p@%(dX(i zZ_urZo2YeIvB@-zm@ICdZ513TWmGpZ+QzNw=$+5rD>|vCGW>#_rLOa4N7CQ_L%Zu&vddA=A1Lb!;*@h8! zi42jJ#uLG6!%OMbDCc&6&Y(FXBYezCU<>t38X;7`Zv**D{%VilL&x~nyEWYX{%~d< zwgg6TJ#HuedU{fY?9w26^Rb#M*Kpd?pZ8To6us;Y!JKrC-h@e3hu<~%dQQDi44m-W z#caDfZ)LvQ%ER`Yj_<0HG9zEJ(OGvfjUk4~sa1=k+tA#Y#8dXlvH6NgwX?2BH$uHl z;=rwM9(9{TM>fA$bRYDr^U*QvnaK$!iBS3Dd#p)&@P0A^F`fPTaVz8sZmV`1*A67$ zXi8k{1vCvKLWZ?e<*eNJ>DA>ruq-!LlU-+VuJpXtnEk93D)F1$f9%|(EHo%ZX=U!l ztKMF0Hq{C98FO;y)D`g{1ra_uI?Tc1+r3xCjR~C@KpeZXL6c*|OiXr58D~^qQwi{9 zN&KkDGRZPB_{Nc?*A>Ub7A+fKKvV~B`!!(t(KK+oS*(w%5?UAgMMWU)`*^ThtB1yB zZMUo%?@)$S4ARY%T;=zOs0?HiTkz#JnkM!xzr_a9de8s4SPa;-O(tZEAd90vozyhp3^s;-bsdq(zj3wa?$h>lj zBjc`u;X(wZ>O33=PQ-zFV=mw!!a=a^SHn^z|6+qTi+bEu3CkX%ujpL#@^gRHuTqBB>RzLX46d^iF3vQLgM)J}^BMp&BCexm7Q8R`b zgDgWz!D0mMNw}Z_J|)LGCNp!hl7rPZNa1<^)(+PBxu`x{1eq>TEekC~AuG0I+SYkb zL(x+P|6d_Ck#E1v8hwqRqN@&XgD)DaJdI-wz&=Z31Zk@sh=Ct!!FRtw!kb>U)#!pR#*4l!yqk2JyH zpM2kmh}S&`3le1 z-H;TC{hkEInnTPFT_6MOeucHhUWS2PL(Bobnr1dvI2um-|(^Jl_&YyJ|)FtW7Z5_q$P9g&p<1$TUwuVo8jp5kD8>t2I|Vb#7Q9< z_a{-T&a^k)0F=Z7oKFt=dxn=k$WT9|sDN!6!k?G3)V9bwYm`rG0~5kol?V%SQ)5G; zoV3OhQ}itwEZ$A|tf}7B0uQHMs9fa#SCn2rh|}C_-4K+06tC({NSgp|;2{_a%Z?gN zNDhcM^dwdK$c3Za>UK7!A1;E8G=!MMipJK3IZt7EXu68N++a)P9k8UMcyHtECyAE= zDdCTLa@nFi6t!vl0Vre|QzA`6>7{^u##Mq^M*u47WVLV9!* zD{9O?6@Sp3LKzH-zWJuTL;i_CjyT%GZPY%9#L4TKv8~bB=%=T{*706zfa?p;KN+Vt{MA(DxGjVU7j~Hh6IJ_ka zF+~FLJDCc@5UfA3>JLb^YQVkg%X47ulcn(dL$&D>L?>I#gBmCOuZhzo*H$8G@iY#ob5Ec z4(u@>&fNNaPQ{%13A^1uY-vKj5^KmX(0z|~MJQ?j?4Y=h_Z3Zmk8XC?!wI$&!FpU= z0m+VKxIdGPGtsG4`0NFZ>G6Z|wtZcY`Nj)t(E{NZ_o$A&(@L%X$cWk8ZsfqYvr8YA zpVH9lf<$wFz%Mee+dYgLO|rp+>J(jTXNftDXR!}X`h?jXHJ8f@V=_Mlp6T%n0=$#SjUd^-_~E~mnTisxo*Z4 zhP>v6R^f?=fg?9$_IR?2?XAz&^mJRE4VkI)a8| zx0TVsN~zNWV6T?lhinnaW#AArktki>VYY^QMj%ZG4q{YWDcL%f-l1WLpT%es?Ee+Z z0%Bw)A8>pVm|r=9i+0#22K=RC+GR7dv0UXHu2p{UK%-cer$40#lknlpTjMX zPWcOd$yxnb_5@Sh>ie6CUkp9PMj^5DG)OFYF{VknDq|!ZJdT+Vvy&rzPte)>9PKD~@*I zaYE0XcjKcD-Cgc8J%m`ch>iv9`81a0SB+i70?VeqwL-q0BkhMF8+&S{Z9siiF)nje3XD( zdl^12K<8%tPq4d|=7>g0J?=r7w9m3|9!IdWNXh7g`cV^5z&4Cq1hBi((DG9ne4h4?){7k@O&Mny<2ao^eZr}nt zD?rk8z$aZI=Fx_6d}RUUw(23P-4{U~wvTbDqpT22$IrI+58@(EJRTT*?UzxWkj#z! zyMLcU!gp7wa;Zx?Gj>V$Owvc4)ch`v6|2TS12o<9vY5X)TP$D+D@rnQrxg5;z{y#o zqK)6zP}xTcjE(KkK5I7v7P^i~#)aHhOS6y*HK6V|0J$9$8UcMd_C_Cyqc00&Pn+Dl z&(@FXkmERItl8EjQ)s8(PumqL+b71GxGr>nKI0%KiS^d*>5=;`uO6Vu#-^pj^o2ck ziXNFS;k*)V>gTUqs~7q;C-V^eb*9m)Xr7~~j2bXJN)3@hyxyk$af=8}L6!3W{qoE) zL{%QD?64uV)7c=WokvH=G?XY%k z=r4M~*QKv@Gw(=VaEwbrC5qedQZM9+5Kk$~@I+C^NvsM!Ey8(?;djqjTZI$m-K8J7${ z(Q6^6*qRl>%lkYYStv?0*>lINOR$(*bJ=z2c|~g8@=A!j^)C{*Cmxxr5rW<0V$+91t*g8IL{RbE6mXRvipf0MU0r5< zl9{xb-s#QqBrXc}5*%uG9!V(d3e_V?kNe&$#;y1O0YF^yJ3f=}XH`L&x| zve;vupWN3=Wm_aqEDrgnWRLB=t~{A@X)b&DHiDyCu4b65AIcQfA^BcM+c^!h-=&I* z>kVtv`(VGU@Lx4iAbdLUt&lk0;$#RKUwt`RPzNDdHQ3Wvq89@uLTZFiji%5O8z3sB z3ZtF5Qa||g>-Q zhNBHoJBdYUAHD<&_Ejf74Z)7#zo#PC#`GozZ!E$iOjeUB-geu$M)317#b-Nj0wjXC zfTf^)11;TQvWA{qf*1e(Qr&1hm1v&4XK|WzU_S6Lh0S8q(N_8n!YL6^l=snT8TbVF zxY#C3&OV&V-mQ(MH;nwATZi81`0~3LoGo_`G{1=l|Mb$K{ZLz1vLNpJ_F6^gtPgi62#0q>=nHV4Ec>5%%caa7k4uOik)W;r~TgixSoM6-#fM@vAnkw!bQZz z8}e^+sYfVbsN|fZ3bMCmh@{p@5$Cx3DneBs^7PsiM?@T}Q6;%GxpzeR|0rjXeg==g z^4T$ol%?&M8A1)ilgZ$E0vzkM6NhDE+seRGH^JY5S@@)-Sy!h6Tez>unbuf7+)V{-2Pj*D!K z+JH*Xy2GL+yd=cd*Zr_ay=lpIeiX%ZDV;bzYw9pS??qkd%J@zS6m{}T0?9G#_mK>i z8OmP~V*;U|bi*+K{eZH99nF7wz)tRz5+p<9Ey%cveeyhQ2odyfSd&9;MrES4OfXA3 zK-K)fVkWIeS}}QT8(pL9u2T*YC1z0kO`8TPz%mP&u6#v@I3#7gl*=1ma*3WLhh=xI z-OH(H=RrKvS4%nR?(7}SUI}L5MDYLFksg5F*abz1c^ko#8syk9?zq$SP8+=v3@_8}u#Bo2WOlgFn=~Fux^CI6! zKTh0RRj1XDq5MfF!fqKT0LebYgTNS&Sqyu|X|qZ+`g~J17&lE-^UR_W(@1gEfhYIN zsp-bCsz7x8$&}`9`q^Z08nR17v1L4+tj0v5aVSrE`tfX4q%h3~E-(^;gR9Hg=%4Ct zu1)8;y(0k|W)`N+%b_&aAx4_lHrQKjZnV_&=||g1zc2{#bPIfG!3MxwaQRS0E-8 z>t|!tuPf>2m#>nq2W(9LodGi^7S3Bp_I!kZ$DQB}ZKyWn&M;BHL|RN2o`RZCC@Qr0 z(PX)n_;KehGTDNO8f{;0I58c}&>{i6QKDJ-w(>;8+d~`lI?D)Zyz8*>quCaG_X-Xc z6lflGjLy|QZDArIp&h(zY}Q16|1yU2-=&b#>F*Z-Uie{5k%-bV+D oRJ`VV%{0p1m4XZBx{gQ=Mm4PlE=mAzEn#Uq(N(EYvWfoR0AMTO5C8xG literal 0 HcmV?d00001 diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py new file mode 100644 index 0000000000..ade144e6d4 --- /dev/null +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -0,0 +1,199 @@ +from .. import api, pipeline +from . import lib +from ..vendor import Qt + +import pyblish.api + + +def install(): + """Install Photoshop-specific functionality of avalon-core. + + This function is called automatically on calling `api.install(photoshop)`. + """ + print("Installing Avalon Photoshop...") + pyblish.api.register_host("photoshop") + + +def ls(): + """Yields containers from active Photoshop document + + This is the host-equivalent of api.ls(), but instead of listing + assets on disk, it lists assets already loaded in Photoshop; once loaded + they are called 'containers' + + Yields: + dict: container + + """ + try: + stub = lib.stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + layers_meta = stub.get_layers_metadata() # minimalize calls to PS + for layer in stub.get_layers(): + data = stub.read(layer, layers_meta) + + # Skip non-tagged layers. + if not data: + continue + + # Filter to only containers. + if "container" not in data["id"]: + continue + + # Append transient data + data["objectName"] = layer.name.replace(stub.LOADED_ICON, '') + data["layer"] = layer + + yield data + + +def list_instances(): + """ + List all created instances from current workfile which + will be published. + + Pulls from File > File Info + + For SubsetManager + + Returns: + (list) of dictionaries matching instances format + """ + stub = _get_stub() + + if not stub: + return [] + + instances = [] + layers_meta = stub.get_layers_metadata() + if layers_meta: + for key, instance in layers_meta.items(): + if instance.get("schema") and \ + "container" in instance.get("schema"): + continue + + instance['uuid'] = key + instances.append(instance) + + return instances + + +def remove_instance(instance): + """ + Remove instance from current workfile metadata. + + Updates metadata of current file in File > File Info and removes + icon highlight on group layer. + + For SubsetManager + + Args: + instance (dict): instance representation from subsetmanager model + """ + stub = _get_stub() + + if not stub: + return + + stub.remove_instance(instance.get("uuid")) + layer = stub.get_layer(instance.get("uuid")) + if layer: + stub.rename_layer(instance.get("uuid"), + layer.name.replace(stub.PUBLISH_ICON, '')) + + +def _get_stub(): + """ + Handle pulling stub from PS to run operations on host + Returns: + (PhotoshopServerStub) or None + """ + try: + stub = lib.stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + return stub + + +class Creator(api.Creator): + """Creator plugin to create instances in Photoshop + + A LayerSet is created to support any number of layers in an instance. If + the selection is used, these layers will be added to the LayerSet. + """ + + def process(self): + # Photoshop can have multiple LayerSets with the same name, which does + # not work with Avalon. + msg = "Instance with name \"{}\" already exists.".format(self.name) + stub = lib.stub() # only after Photoshop is up + for layer in stub.get_layers(): + if self.name.lower() == layer.Name.lower(): + msg = Qt.QtWidgets.QMessageBox() + msg.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg.setText(msg) + msg.exec_() + return False + + # Store selection because adding a group will change selection. + with lib.maintained_selection(): + + # Add selection to group. + if (self.options or {}).get("useSelection"): + group = stub.group_selected_layers(self.name) + else: + group = stub.create_group(self.name) + + stub.imprint(group, self.data) + + return group + + +def containerise(name, + namespace, + layer, + context, + loader=None, + suffix="_CON"): + """Imprint layer with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + layer (PSItem): Layer to containerise + context (dict): Asset information + loader (str, optional): Name of loader used to produce this container. + suffix (str, optional): Suffix of container, defaults to `_CON`. + + Returns: + container (str): Name of container assembly + """ + layer.name = name + suffix + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace, + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + "members": [str(layer.id)] + } + stub = lib.stub() + stub.imprint(layer, data) + + return layer diff --git a/openpype/hosts/photoshop/api/workio.py b/openpype/hosts/photoshop/api/workio.py new file mode 100644 index 0000000000..ddcd351b38 --- /dev/null +++ b/openpype/hosts/photoshop/api/workio.py @@ -0,0 +1,50 @@ +"""Host API required Work Files tool""" +import os + +from . import lib +from avalon import api + + +def _active_document(): + document_name = lib.stub().get_active_document_name() + if not document_name: + return None + + return document_name + + +def file_extensions(): + return api.HOST_WORKFILE_EXTENSIONS["photoshop"] + + +def has_unsaved_changes(): + if _active_document(): + return not lib.stub().is_saved() + + return False + + +def save_file(filepath): + _, ext = os.path.splitext(filepath) + lib.stub().saveAs(filepath, ext[1:], True) + + +def open_file(filepath): + lib.stub().open(filepath) + + return True + + +def current_file(): + try: + full_name = lib.stub().get_active_document_full_name() + if full_name and full_name != "null": + return os.path.normpath(full_name).replace("\\", "/") + except Exception: + pass + + return None + + +def work_root(session): + return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py new file mode 100644 index 0000000000..f7bd03cdab --- /dev/null +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -0,0 +1,470 @@ +""" + Stub handling connection from server to client. + Used anywhere solution is calling client methods. +""" +import json +import sys +from wsrpc_aiohttp import WebSocketAsync +import attr + +from avalon.tools.webserver.app import WebServerTool + + +@attr.s +class PSItem(object): + """ + Object denoting layer or group item in PS. Each item is created in + PS by any Loader, but contains same fields, which are being used + in later processing. + """ + # metadata + id = attr.ib() # id created by AE, could be used for querying + name = attr.ib() # name of item + group = attr.ib(default=None) # item type (footage, folder, comp) + parents = attr.ib(factory=list) + visible = attr.ib(default=True) + type = attr.ib(default=None) + # all imported elements, single for + members = attr.ib(factory=list) + long_name = attr.ib(default=None) + color_code = attr.ib(default=None) # color code of layer + + +class PhotoshopServerStub: + """ + Stub for calling function on client (Photoshop js) side. + Expects that client is already connected (started when avalon menu + is opened). + 'self.websocketserver.call' is used as async wrapper + """ + PUBLISH_ICON = '\u2117 ' + LOADED_ICON = '\u25bc' + + def __init__(self): + self.websocketserver = WebServerTool.get_instance() + self.client = self.get_client() + + @staticmethod + def get_client(): + """ + Return first connected client to WebSocket + TODO implement selection by Route + :return: client + """ + clients = WebSocketAsync.get_clients() + client = None + if len(clients) > 0: + key = list(clients.keys())[0] + client = clients.get(key) + + return client + + def open(self, path): + """ + Open file located at 'path' (local). + Args: + path(string): file path locally + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.open', path=path) + ) + + def read(self, layer, layers_meta=None): + """ + Parses layer metadata from Headline field of active document + Args: + layer: (PSItem) + layers_meta: full list from Headline (for performance in loops) + Returns: + """ + if layers_meta is None: + layers_meta = self.get_layers_metadata() + + return layers_meta.get(str(layer.id)) + + def imprint(self, layer, data, all_layers=None, layers_meta=None): + """ + Save layer metadata to Headline field of active document + + Stores metadata in format: + [{ + "active":true, + "subset":"imageBG", + "family":"image", + "id":"pyblish.avalon.instance", + "asset":"Town", + "uuid": "8" + }] - for created instances + OR + [{ + "schema": "openpype:container-2.0", + "id": "pyblish.avalon.instance", + "name": "imageMG", + "namespace": "Jungle_imageMG_001", + "loader": "ImageLoader", + "representation": "5fbfc0ee30a946093c6ff18a", + "members": [ + "40" + ] + }] - for loaded instances + + Args: + layer (PSItem): + data(string): json representation for single layer + all_layers (list of PSItem): for performance, could be + injected for usage in loop, if not, single call will be + triggered + layers_meta(string): json representation from Headline + (for performance - provide only if imprint is in + loop - value should be same) + Returns: None + """ + if not layers_meta: + layers_meta = self.get_layers_metadata() + + # json.dumps writes integer values in a dictionary to string, so + # anticipating it here. + if str(layer.id) in layers_meta and layers_meta[str(layer.id)]: + if data: + layers_meta[str(layer.id)].update(data) + else: + layers_meta.pop(str(layer.id)) + else: + layers_meta[str(layer.id)] = data + + # Ensure only valid ids are stored. + if not all_layers: + all_layers = self.get_layers() + layer_ids = [layer.id for layer in all_layers] + cleaned_data = [] + + for id in layers_meta: + if int(id) in layer_ids: + cleaned_data.append(layers_meta[id]) + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + + def get_layers(self): + """ + Returns JSON document with all(?) layers in active document. + + Returns: + Format of tuple: { 'id':'123', + 'name': 'My Layer 1', + 'type': 'GUIDE'|'FG'|'BG'|'OBJ' + 'visible': 'true'|'false' + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_layers')) + + return self._to_records(res) + + def get_layer(self, layer_id): + """ + Returns PSItem for specific 'layer_id' or None if not found + Args: + layer_id (string): unique layer id, stored in 'uuid' field + + Returns: + (PSItem) or None + """ + layers = self.get_layers() + for layer in layers: + if str(layer.id) == str(layer_id): + return layer + + def get_layers_in_layers(self, layers): + """ + Return all layers that belong to layers (might be groups). + Args: + layers : + Returns: + """ + all_layers = self.get_layers() + ret = [] + parent_ids = set([lay.id for lay in layers]) + + for layer in all_layers: + parents = set(layer.parents) + if len(parent_ids & parents) > 0: + ret.append(layer) + if layer.id in parent_ids: + ret.append(layer) + + return ret + + def create_group(self, name): + """ + Create new group (eg. LayerSet) + Returns: + """ + enhanced_name = self.PUBLISH_ICON + name + ret = self.websocketserver.call(self.client.call + ('Photoshop.create_group', + name=enhanced_name)) + # create group on PS is asynchronous, returns only id + return PSItem(id=ret, name=name, group=True) + + def group_selected_layers(self, name): + """ + Group selected layers into new LayerSet (eg. group) + Returns: (Layer) + """ + enhanced_name = self.PUBLISH_ICON + name + res = self.websocketserver.call(self.client.call + ('Photoshop.group_selected_layers', + name=enhanced_name) + ) + res = self._to_records(res) + if res: + rec = res.pop() + rec.name = rec.name.replace(self.PUBLISH_ICON, '') + return rec + raise ValueError("No group record returned") + + def get_selected_layers(self): + """ + Get a list of actually selected layers + Returns: + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_selected_layers')) + return self._to_records(res) + + def select_layers(self, layers): + """ + Selects specified layers in Photoshop by its ids + Args: + layers: + Returns: None + """ + layers_id = [str(lay.id) for lay in layers] + self.websocketserver.call(self.client.call + ('Photoshop.select_layers', + layers=json.dumps(layers_id)) + ) + + def get_active_document_full_name(self): + """ + Returns full name with path of active document via ws call + Returns(string): full path with name + """ + res = self.websocketserver.call( + self.client.call('Photoshop.get_active_document_full_name')) + + return res + + def get_active_document_name(self): + """ + Returns just a name of active document via ws call + Returns(string): file name + """ + res = self.websocketserver.call(self.client.call + ('Photoshop.get_active_document_name')) + + return res + + def is_saved(self): + """ + Returns true if no changes in active document + Returns: + """ + return self.websocketserver.call(self.client.call + ('Photoshop.is_saved')) + + def save(self): + """ + Saves active document + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.save')) + + def saveAs(self, image_path, ext, as_copy): + """ + Saves active document to psd (copy) or png or jpg + Args: + image_path(string): full local path + ext: + as_copy: + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy)) + + def set_visible(self, layer_id, visibility): + """ + Set layer with 'layer_id' to 'visibility' + Args: + layer_id: + visibility: + Returns: None + """ + self.websocketserver.call(self.client.call + ('Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility)) + + def get_layers_metadata(self): + """ + Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) + + Returns: + (string): - json documents + example: + {"8":{"active":true,"subset":"imageBG", + "family":"image","id":"pyblish.avalon.instance", + "asset":"Town"}} + 8 is layer(group) id - used for deletion, update etc. + """ + layers_data = {} + res = self.websocketserver.call(self.client.call('Photoshop.read')) + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + pass + # format of metadata changed from {} to [] because of standardization + # keep current implementation logic as its working + if not isinstance(layers_data, dict): + temp_layers_meta = {} + for layer_meta in layers_data: + layer_id = layer_meta.get("uuid") or \ + (layer_meta.get("members")[0]) + temp_layers_meta[layer_id] = layer_meta + layers_data = temp_layers_meta + else: + # legacy version of metadata + for layer_id, layer_meta in layers_data.items(): + if layer_meta.get("schema") != "openpype:container-2.0": + layer_meta["uuid"] = str(layer_id) + else: + layer_meta["members"] = [str(layer_id)] + + return layers_data + + def import_smart_object(self, path, layer_name, as_reference=False): + """ + Import the file at `path` as a smart object to active document. + + Args: + path (str): File path to import. + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded + as_reference (bool): pull in content or reference + """ + enhanced_name = self.LOADED_ICON + layer_name + res = self.websocketserver.call(self.client.call + ('Photoshop.import_smart_object', + path=path, name=enhanced_name, + as_reference=as_reference + )) + rec = self._to_records(res).pop() + if rec: + rec.name = rec.name.replace(self.LOADED_ICON, '') + return rec + + def replace_smart_object(self, layer, path, layer_name): + """ + Replace the smart object `layer` with file at `path` + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded + + Args: + layer (PSItem): + path (str): File to import. + """ + enhanced_name = self.LOADED_ICON + layer_name + self.websocketserver.call(self.client.call + ('Photoshop.replace_smart_object', + layer_id=layer.id, + path=path, name=enhanced_name)) + + def delete_layer(self, layer_id): + """ + Deletes specific layer by it's id. + Args: + layer_id (int): id of layer to delete + """ + self.websocketserver.call(self.client.call + ('Photoshop.delete_layer', + layer_id=layer_id)) + + def rename_layer(self, layer_id, name): + """ + Renames specific layer by it's id. + Args: + layer_id (int): id of layer to delete + name (str): new name + """ + self.websocketserver.call(self.client.call + ('Photoshop.rename_layer', + layer_id=layer_id, + name=name)) + + def remove_instance(self, instance_id): + cleaned_data = {} + + for key, instance in self.get_layers_metadata().items(): + if key != instance_id: + cleaned_data[key] = instance + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call(self.client.call + ('Photoshop.imprint', payload=payload) + ) + + def get_extension_version(self): + """Returns version number of installed extension.""" + return self.websocketserver.call(self.client.call + ('Photoshop.get_extension_version')) + + def close(self): + """Shutting down PS and process too. + + For webpublishing only. + """ + # TODO change client.call to method with checks for client + self.websocketserver.call(self.client.call('Photoshop.close')) + + def _to_records(self, res): + """ + Converts string json representation into list of PSItem for + dot notation access to work. + Args: + res (string): valid json + Returns: + + """ + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + raise ValueError("Received broken JSON {}".format(res)) + ret = [] + + # convert to AEItem to use dot donation + if isinstance(layers_data, dict): + layers_data = [layers_data] + for d in layers_data: + # currently implemented and expected fields + item = PSItem(d.get('id'), + d.get('name'), + d.get('group'), + d.get('parents'), + d.get('visible'), + d.get('type'), + d.get('members'), + d.get('long_name'), + d.get("color_code")) + + ret.append(item) + return ret From 3ca400949134fc604744086ae74c8b16bd0d8757 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:00:53 +0100 Subject: [PATCH 080/229] removed empty hooks --- openpype/hosts/photoshop/hooks/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 openpype/hosts/photoshop/hooks/__init__.py diff --git a/openpype/hosts/photoshop/hooks/__init__.py b/openpype/hosts/photoshop/hooks/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From c09d832c73db12455ec56d1429510f5c43772d56 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:04:35 +0100 Subject: [PATCH 081/229] use openpype logger --- openpype/hosts/photoshop/api/launch_logic.py | 13 +++++-------- openpype/hosts/photoshop/api/lib.py | 5 ++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 36347b8ce0..8e0d00636a 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -1,7 +1,6 @@ import os import subprocess import collections -import logging import asyncio import functools @@ -12,6 +11,7 @@ from wsrpc_aiohttp import ( from Qt import QtCore +from openpype.api import Logger from openpype.tools.utils import host_tools from avalon import api @@ -19,8 +19,7 @@ from avalon.tools.webserver.app import WebServerTool from .ws_stub import PhotoshopServerStub -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) +log = Logger.get_logger(__name__) class ConnectionNotEstablishedYet(Exception): @@ -81,10 +80,9 @@ class ProcessLauncher(QtCore.QObject): @property def log(self): if self._log is None: - from openpype.api import Logger - - self._log = Logger.get_logger("{}-launcher".format( - self.route_name)) + self._log = Logger.get_logger( + "{}-launcher".format(self.route_name) + ) return self._log @property @@ -106,7 +104,6 @@ class ProcessLauncher(QtCore.QObject): return False try: - _stub = stub() if _stub: return True diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index bc1fb36cf3..0938febf43 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,19 +1,18 @@ import os import sys import contextlib -import logging import traceback from Qt import QtWidgets from openpype.tools.utils import host_tools +from openpype.api import Logger from openpype.lib.remote_publish import headless_publish from .launch_logic import ProcessLauncher, stub -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) +log = Logger.get_logger(__name__) def safe_excepthook(*args): From 1a623d6ee2a17246a881a2d214e9191e7a54aeab Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:05:05 +0100 Subject: [PATCH 082/229] formatting changes --- openpype/hosts/photoshop/api/workio.py | 5 +- openpype/hosts/photoshop/api/ws_stub.py | 347 +++++++++++++----------- 2 files changed, 189 insertions(+), 163 deletions(-) diff --git a/openpype/hosts/photoshop/api/workio.py b/openpype/hosts/photoshop/api/workio.py index ddcd351b38..0bf3ed2bd9 100644 --- a/openpype/hosts/photoshop/api/workio.py +++ b/openpype/hosts/photoshop/api/workio.py @@ -1,8 +1,9 @@ """Host API required Work Files tool""" import os +import avalon.api + from . import lib -from avalon import api def _active_document(): @@ -14,7 +15,7 @@ def _active_document(): def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["photoshop"] + return avalon.api.HOST_WORKFILE_EXTENSIONS["photoshop"] def has_unsaved_changes(): diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index f7bd03cdab..b8f66332c6 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -2,10 +2,10 @@ Stub handling connection from server to client. Used anywhere solution is calling client methods. """ -import json import sys -from wsrpc_aiohttp import WebSocketAsync +import json import attr +from wsrpc_aiohttp import WebSocketAsync from avalon.tools.webserver.app import WebServerTool @@ -60,19 +60,19 @@ class PhotoshopServerStub: return client def open(self, path): - """ - Open file located at 'path' (local). + """Open file located at 'path' (local). + Args: path(string): file path locally Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.open', path=path) - ) + self.websocketserver.call( + self.client.call('Photoshop.open', path=path) + ) def read(self, layer, layers_meta=None): - """ - Parses layer metadata from Headline field of active document + """Parses layer metadata from Headline field of active document. + Args: layer: (PSItem) layers_meta: full list from Headline (for performance in loops) @@ -84,30 +84,29 @@ class PhotoshopServerStub: return layers_meta.get(str(layer.id)) def imprint(self, layer, data, all_layers=None, layers_meta=None): - """ - Save layer metadata to Headline field of active document + """Save layer metadata to Headline field of active document - Stores metadata in format: - [{ - "active":true, - "subset":"imageBG", - "family":"image", - "id":"pyblish.avalon.instance", - "asset":"Town", - "uuid": "8" - }] - for created instances - OR - [{ - "schema": "openpype:container-2.0", - "id": "pyblish.avalon.instance", - "name": "imageMG", - "namespace": "Jungle_imageMG_001", - "loader": "ImageLoader", - "representation": "5fbfc0ee30a946093c6ff18a", - "members": [ - "40" - ] - }] - for loaded instances + Stores metadata in format: + [{ + "active":true, + "subset":"imageBG", + "family":"image", + "id":"pyblish.avalon.instance", + "asset":"Town", + "uuid": "8" + }] - for created instances + OR + [{ + "schema": "openpype:container-2.0", + "id": "pyblish.avalon.instance", + "name": "imageMG", + "namespace": "Jungle_imageMG_001", + "loader": "ImageLoader", + "representation": "5fbfc0ee30a946093c6ff18a", + "members": [ + "40" + ] + }] - for loaded instances Args: layer (PSItem): @@ -139,19 +138,18 @@ class PhotoshopServerStub: layer_ids = [layer.id for layer in all_layers] cleaned_data = [] - for id in layers_meta: - if int(id) in layer_ids: - cleaned_data.append(layers_meta[id]) + for layer_id in layers_meta: + if int(layer_id) in layer_ids: + cleaned_data.append(layers_meta[layer_id]) payload = json.dumps(cleaned_data, indent=4) - self.websocketserver.call(self.client.call - ('Photoshop.imprint', payload=payload) - ) + self.websocketserver.call( + self.client.call('Photoshop.imprint', payload=payload) + ) def get_layers(self): - """ - Returns JSON document with all(?) layers in active document. + """Returns JSON document with all(?) layers in active document. Returns: Format of tuple: { 'id':'123', @@ -159,8 +157,9 @@ class PhotoshopServerStub: 'type': 'GUIDE'|'FG'|'BG'|'OBJ' 'visible': 'true'|'false' """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_layers')) + res = self.websocketserver.call( + self.client.call('Photoshop.get_layers') + ) return self._to_records(res) @@ -179,11 +178,13 @@ class PhotoshopServerStub: return layer def get_layers_in_layers(self, layers): - """ - Return all layers that belong to layers (might be groups). + """Return all layers that belong to layers (might be groups). + Args: layers : - Returns: + + Returns: + """ all_layers = self.get_layers() ret = [] @@ -199,27 +200,30 @@ class PhotoshopServerStub: return ret def create_group(self, name): - """ - Create new group (eg. LayerSet) - Returns: + """Create new group (eg. LayerSet) + + Returns: + """ enhanced_name = self.PUBLISH_ICON + name - ret = self.websocketserver.call(self.client.call - ('Photoshop.create_group', - name=enhanced_name)) + ret = self.websocketserver.call( + self.client.call('Photoshop.create_group', name=enhanced_name) + ) # create group on PS is asynchronous, returns only id return PSItem(id=ret, name=name, group=True) def group_selected_layers(self, name): - """ - Group selected layers into new LayerSet (eg. group) - Returns: (Layer) + """Group selected layers into new LayerSet (eg. group) + + Returns: + (Layer) """ enhanced_name = self.PUBLISH_ICON + name - res = self.websocketserver.call(self.client.call - ('Photoshop.group_selected_layers', - name=enhanced_name) - ) + res = self.websocketserver.call( + self.client.call( + 'Photoshop.group_selected_layers', name=enhanced_name + ) + ) res = self._to_records(res) if res: rec = res.pop() @@ -228,103 +232,112 @@ class PhotoshopServerStub: raise ValueError("No group record returned") def get_selected_layers(self): - """ - Get a list of actually selected layers + """Get a list of actually selected layers. + Returns: """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_selected_layers')) + res = self.websocketserver.call( + self.client.call('Photoshop.get_selected_layers') + ) return self._to_records(res) def select_layers(self, layers): - """ - Selects specified layers in Photoshop by its ids + """Selects specified layers in Photoshop by its ids. + Args: layers: - Returns: None """ layers_id = [str(lay.id) for lay in layers] - self.websocketserver.call(self.client.call - ('Photoshop.select_layers', - layers=json.dumps(layers_id)) - ) + self.websocketserver.call( + self.client.call( + 'Photoshop.select_layers', + layers=json.dumps(layers_id) + ) + ) def get_active_document_full_name(self): - """ - Returns full name with path of active document via ws call - Returns(string): full path with name + """Returns full name with path of active document via ws call + + Returns(string): + full path with name """ res = self.websocketserver.call( - self.client.call('Photoshop.get_active_document_full_name')) + self.client.call('Photoshop.get_active_document_full_name') + ) return res def get_active_document_name(self): - """ - Returns just a name of active document via ws call - Returns(string): file name - """ - res = self.websocketserver.call(self.client.call - ('Photoshop.get_active_document_name')) + """Returns just a name of active document via ws call - return res + Returns(string): + file name + """ + return self.websocketserver.call( + self.client.call('Photoshop.get_active_document_name') + ) def is_saved(self): + """Returns true if no changes in active document + + Returns: + """ - Returns true if no changes in active document - Returns: - """ - return self.websocketserver.call(self.client.call - ('Photoshop.is_saved')) + return self.websocketserver.call( + self.client.call('Photoshop.is_saved') + ) def save(self): - """ - Saves active document - Returns: None - """ - self.websocketserver.call(self.client.call - ('Photoshop.save')) + """Saves active document""" + self.websocketserver.call( + self.client.call('Photoshop.save') + ) def saveAs(self, image_path, ext, as_copy): - """ - Saves active document to psd (copy) or png or jpg + """Saves active document to psd (copy) or png or jpg + Args: image_path(string): full local path ext: as_copy: Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.saveAs', - image_path=image_path, - ext=ext, - as_copy=as_copy)) + self.websocketserver.call( + self.client.call( + 'Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy + ) + ) def set_visible(self, layer_id, visibility): - """ - Set layer with 'layer_id' to 'visibility' + """Set layer with 'layer_id' to 'visibility' + Args: layer_id: visibility: Returns: None """ - self.websocketserver.call(self.client.call - ('Photoshop.set_visible', - layer_id=layer_id, - visibility=visibility)) + self.websocketserver.call( + self.client.call( + 'Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility + ) + ) def get_layers_metadata(self): - """ - Reads layers metadata from Headline from active document in PS. - (Headline accessible by File > File Info) + """Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) - Returns: - (string): - json documents - example: - {"8":{"active":true,"subset":"imageBG", - "family":"image","id":"pyblish.avalon.instance", - "asset":"Town"}} - 8 is layer(group) id - used for deletion, update etc. + Returns: + (string): - json documents + example: + {"8":{"active":true,"subset":"imageBG", + "family":"image","id":"pyblish.avalon.instance", + "asset":"Town"}} + 8 is layer(group) id - used for deletion, update etc. """ layers_data = {} res = self.websocketserver.call(self.client.call('Photoshop.read')) @@ -337,8 +350,10 @@ class PhotoshopServerStub: if not isinstance(layers_data, dict): temp_layers_meta = {} for layer_meta in layers_data: - layer_id = layer_meta.get("uuid") or \ - (layer_meta.get("members")[0]) + layer_id = layer_meta.get("uuid") + if not layer_id: + layer_id = layer_meta.get("members")[0] + temp_layers_meta[layer_id] = layer_meta layers_data = temp_layers_meta else: @@ -352,8 +367,7 @@ class PhotoshopServerStub: return layers_data def import_smart_object(self, path, layer_name, as_reference=False): - """ - Import the file at `path` as a smart object to active document. + """Import the file at `path` as a smart object to active document. Args: path (str): File path to import. @@ -362,53 +376,62 @@ class PhotoshopServerStub: as_reference (bool): pull in content or reference """ enhanced_name = self.LOADED_ICON + layer_name - res = self.websocketserver.call(self.client.call - ('Photoshop.import_smart_object', - path=path, name=enhanced_name, - as_reference=as_reference - )) + res = self.websocketserver.call( + self.client.call( + 'Photoshop.import_smart_object', + path=path, + name=enhanced_name, + as_reference=as_reference + ) + ) rec = self._to_records(res).pop() if rec: rec.name = rec.name.replace(self.LOADED_ICON, '') return rec def replace_smart_object(self, layer, path, layer_name): - """ - Replace the smart object `layer` with file at `path` - layer_name (str): Unique layer name to differentiate how many times - same smart object was loaded + """Replace the smart object `layer` with file at `path` Args: layer (PSItem): path (str): File to import. + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded """ enhanced_name = self.LOADED_ICON + layer_name - self.websocketserver.call(self.client.call - ('Photoshop.replace_smart_object', - layer_id=layer.id, - path=path, name=enhanced_name)) + self.websocketserver.call( + self.client.call( + 'Photoshop.replace_smart_object', + layer_id=layer.id, + path=path, + name=enhanced_name + ) + ) def delete_layer(self, layer_id): - """ - Deletes specific layer by it's id. + """Deletes specific layer by it's id. + Args: layer_id (int): id of layer to delete """ - self.websocketserver.call(self.client.call - ('Photoshop.delete_layer', - layer_id=layer_id)) + self.websocketserver.call( + self.client.call('Photoshop.delete_layer', layer_id=layer_id) + ) def rename_layer(self, layer_id, name): - """ - Renames specific layer by it's id. + """Renames specific layer by it's id. + Args: layer_id (int): id of layer to delete name (str): new name """ - self.websocketserver.call(self.client.call - ('Photoshop.rename_layer', - layer_id=layer_id, - name=name)) + self.websocketserver.call( + self.client.call( + 'Photoshop.rename_layer', + layer_id=layer_id, + name=name + ) + ) def remove_instance(self, instance_id): cleaned_data = {} @@ -419,14 +442,15 @@ class PhotoshopServerStub: payload = json.dumps(cleaned_data, indent=4) - self.websocketserver.call(self.client.call - ('Photoshop.imprint', payload=payload) - ) + self.websocketserver.call( + self.client.call('Photoshop.imprint', payload=payload) + ) def get_extension_version(self): """Returns version number of installed extension.""" - return self.websocketserver.call(self.client.call - ('Photoshop.get_extension_version')) + return self.websocketserver.call( + self.client.call('Photoshop.get_extension_version') + ) def close(self): """Shutting down PS and process too. @@ -437,11 +461,12 @@ class PhotoshopServerStub: self.websocketserver.call(self.client.call('Photoshop.close')) def _to_records(self, res): - """ - Converts string json representation into list of PSItem for - dot notation access to work. + """Converts string json representation into list of PSItem for + dot notation access to work. + Args: res (string): valid json + Returns: """ @@ -456,15 +481,15 @@ class PhotoshopServerStub: layers_data = [layers_data] for d in layers_data: # currently implemented and expected fields - item = PSItem(d.get('id'), - d.get('name'), - d.get('group'), - d.get('parents'), - d.get('visible'), - d.get('type'), - d.get('members'), - d.get('long_name'), - d.get("color_code")) - - ret.append(item) + ret.append(PSItem( + d.get('id'), + d.get('name'), + d.get('group'), + d.get('parents'), + d.get('visible'), + d.get('type'), + d.get('members'), + d.get('long_name'), + d.get("color_code") + )) return ret From faf7e7bfebb0412ca360ab22373e75b53db612f9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:06:41 +0100 Subject: [PATCH 083/229] extended main thread exection --- openpype/hosts/photoshop/api/launch_logic.py | 71 +++++++++++++++++--- openpype/hosts/photoshop/api/lib.py | 8 ++- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 8e0d00636a..16a1d23244 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -2,7 +2,6 @@ import os import subprocess import collections import asyncio -import functools from wsrpc_aiohttp import ( WebSocketRoute, @@ -26,6 +25,61 @@ class ConnectionNotEstablishedYet(Exception): pass +class MainThreadItem: + """Structure to store information about callback in main thread. + + Item should be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + + def __init__(self, callback, *args, **kwargs): + self._done = False + self._exception = self.not_set + self._result = self.not_set + self._callback = callback + self._args = args + self._kwargs = kwargs + + @property + def done(self): + return self._done + + @property + def exception(self): + return self._exception + + @property + def result(self): + return self._result + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + log.debug("Executing process in main thread") + if self.done: + log.warning("- item is already processed") + return + + log.info("Running callback: {}".format(str(self._callback))) + try: + result = self._callback(*self._args, **self._kwargs) + self._result = result + + except Exception as exc: + self._exception = exc + + finally: + self._done = True + + def stub(): """ Convenience function to get server RPC stub to call methods directed @@ -113,8 +167,10 @@ class ProcessLauncher(QtCore.QObject): return None @classmethod - def execute_in_main_thread(cls, callback): - cls._main_thread_callbacks.append(callback) + def execute_in_main_thread(cls, callback, *args, **kwargs): + item = MainThreadItem(callback, *args, **kwargs) + cls._main_thread_callbacks.append(item) + return item def start(self): if self._started: @@ -145,8 +201,8 @@ class ProcessLauncher(QtCore.QObject): cls = self.__class__ for _ in range(len(cls._main_thread_callbacks)): if cls._main_thread_callbacks: - callback = cls._main_thread_callbacks.popleft() - callback() + item = cls._main_thread_callbacks.popleft() + item.execute() if not self.is_process_running: self.log.info("Host process is not running. Closing") @@ -303,10 +359,7 @@ class PhotoshopRoute(WebSocketRoute): def _tool_route(self, _tool_name): """The address accessed when clicking on the buttons.""" - partial_method = functools.partial(show_tool_by_name, - _tool_name) - - ProcessLauncher.execute_in_main_thread(partial_method) + ProcessLauncher.execute_in_main_thread(show_tool_by_name, _tool_name) # Required return statement. return "nothing" diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 0938febf43..4c80c04cae 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -34,17 +34,19 @@ def main(*subprocess_args): launcher.start() if os.environ.get("HEADLESS_PUBLISH"): - launcher.execute_in_main_thread(lambda: headless_publish( + launcher.execute_in_main_thread( + headless_publish, log, "ClosePS", - os.environ.get("IS_TEST"))) + os.environ.get("IS_TEST") + ) elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): save = False if os.getenv("WORKFILES_SAVE_AS"): save = True launcher.execute_in_main_thread( - lambda: host_tools.show_workfiles(save=save) + host_tools.show_workfiles, save=save ) sys.exit(app.exec_()) From 1b36e7e73039169164b98cf1408134f43b634541 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:07:29 +0100 Subject: [PATCH 084/229] added code from openpype __init__.py to pipeline.py --- openpype/hosts/photoshop/api/pipeline.py | 126 +++++++++++++++++------ 1 file changed, 95 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index ade144e6d4..ed7b94e249 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -1,8 +1,61 @@ -from .. import api, pipeline -from . import lib -from ..vendor import Qt +import os +import sys +from Qt import QtWidgets import pyblish.api +import avalon.api +from avalon import pipeline, io + +from openpype.api import Logger +import openpype.hosts.photoshop + +from . import lib + +log = Logger.get_logger(__name__) + +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.photoshop.__file__)) +PLUGINS_DIR = os.path.join(HOST_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") + + +def check_inventory(): + if not lib.any_outdated(): + return + + host = avalon.api.registered_host() + outdated_containers = [] + for container in host.ls(): + representation = container['representation'] + representation_doc = io.find_one( + { + "_id": io.ObjectId(representation), + "type": "representation" + }, + projection={"parent": True} + ) + if representation_doc and not lib.is_latest(representation_doc): + outdated_containers.append(container) + + # Warn about outdated containers. + print("Starting new QApplication..") + + message_box = QtWidgets.QMessageBox() + message_box.setIcon(QtWidgets.QMessageBox.Warning) + msg = "There are outdated containers in the scene." + message_box.setText(msg) + message_box.exec_() + + +def on_application_launch(): + check_inventory() + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle layer visibility on instance toggles.""" + instance[0].Visible = new_value def install(): @@ -10,9 +63,26 @@ def install(): This function is called automatically on calling `api.install(photoshop)`. """ - print("Installing Avalon Photoshop...") + log.info("Installing OpenPype Photoshop...") pyblish.api.register_host("photoshop") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + log.info(PUBLISH_PATH) + + pyblish.api.register_callback( + "instanceToggled", on_pyblish_instance_toggled + ) + + avalon.api.on("application.launched", on_application_launch) + + +def uninstall(): + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + def ls(): """Yields containers from active Photoshop document @@ -54,16 +124,14 @@ def ls(): def list_instances(): - """ - List all created instances from current workfile which - will be published. + """List all created instances to publish from current workfile. - Pulls from File > File Info + Pulls from File > File Info - For SubsetManager + For SubsetManager - Returns: - (list) of dictionaries matching instances format + Returns: + (list) of dictionaries matching instances format """ stub = _get_stub() @@ -74,8 +142,8 @@ def list_instances(): layers_meta = stub.get_layers_metadata() if layers_meta: for key, instance in layers_meta.items(): - if instance.get("schema") and \ - "container" in instance.get("schema"): + schema = instance.get("schema") + if schema and "container" in schema: continue instance['uuid'] = key @@ -85,16 +153,15 @@ def list_instances(): def remove_instance(instance): - """ - Remove instance from current workfile metadata. + """Remove instance from current workfile metadata. - Updates metadata of current file in File > File Info and removes - icon highlight on group layer. + Updates metadata of current file in File > File Info and removes + icon highlight on group layer. - For SubsetManager + For SubsetManager - Args: - instance (dict): instance representation from subsetmanager model + Args: + instance (dict): instance representation from subsetmanager model """ stub = _get_stub() @@ -109,8 +176,8 @@ def remove_instance(instance): def _get_stub(): - """ - Handle pulling stub from PS to run operations on host + """Handle pulling stub from PS to run operations on host + Returns: (PhotoshopServerStub) or None """ @@ -126,7 +193,7 @@ def _get_stub(): return stub -class Creator(api.Creator): +class Creator(avalon.api.Creator): """Creator plugin to create instances in Photoshop A LayerSet is created to support any number of layers in an instance. If @@ -140,8 +207,8 @@ class Creator(api.Creator): stub = lib.stub() # only after Photoshop is up for layer in stub.get_layers(): if self.name.lower() == layer.Name.lower(): - msg = Qt.QtWidgets.QMessageBox() - msg.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) msg.setText(msg) msg.exec_() return False @@ -160,12 +227,9 @@ class Creator(api.Creator): return group -def containerise(name, - namespace, - layer, - context, - loader=None, - suffix="_CON"): +def containerise( + name, namespace, layer, context, loader=None, suffix="_CON" +): """Imprint layer with metadata Containerisation enables a tracking of version, author and origin From 56446e0c4c4dc51ebbb600fb4656d7a1a851f0cf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:07:45 +0100 Subject: [PATCH 085/229] changed registered host --- openpype/hosts/photoshop/api/lib.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 4c80c04cae..509c5d5c48 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,13 +1,15 @@ import os import sys +import re import contextlib import traceback from Qt import QtWidgets -from openpype.tools.utils import host_tools +import avalon.api from openpype.api import Logger +from openpype.tools.utils import host_tools from openpype.lib.remote_publish import headless_publish from .launch_logic import ProcessLauncher, stub @@ -20,9 +22,9 @@ def safe_excepthook(*args): def main(*subprocess_args): - from avalon import api, photoshop + from openpype.hosts.photoshop import api - api.install(photoshop) + avalon.api.install(api) sys.excepthook = safe_excepthook # coloring in ConsoleTrayApp From c1d6eaa5f948f823eea373b723a405176c27278b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:08:51 +0100 Subject: [PATCH 086/229] changed import of photoshop in plugins --- openpype/hosts/photoshop/plugins/create/create_image.py | 2 +- openpype/hosts/photoshop/plugins/load/load_image.py | 3 ++- .../hosts/photoshop/plugins/load/load_image_from_sequence.py | 2 +- openpype/hosts/photoshop/plugins/load/load_reference.py | 3 ++- openpype/hosts/photoshop/plugins/publish/closePS.py | 2 +- .../hosts/photoshop/plugins/publish/collect_current_file.py | 2 +- .../photoshop/plugins/publish/collect_extension_version.py | 2 +- .../hosts/photoshop/plugins/publish/collect_instances.py | 2 +- .../photoshop/plugins/publish/collect_remote_instances.py | 5 +++-- openpype/hosts/photoshop/plugins/publish/collect_workfile.py | 2 +- openpype/hosts/photoshop/plugins/publish/extract_image.py | 2 +- openpype/hosts/photoshop/plugins/publish/extract_review.py | 2 +- .../hosts/photoshop/plugins/publish/extract_save_scene.py | 2 +- .../hosts/photoshop/plugins/publish/increment_workfile.py | 2 +- .../photoshop/plugins/publish/validate_instance_asset.py | 2 +- openpype/hosts/photoshop/plugins/publish/validate_naming.py | 2 +- 16 files changed, 20 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 657d41aa93..cf41bb4020 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,6 +1,6 @@ from Qt import QtWidgets import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CreateImage(openpype.api.Creator): diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 981a1ed204..3756eba54e 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -1,6 +1,7 @@ import re -from avalon import api, photoshop +from avalon import api +from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 8704627b12..158bdc2940 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,12 +1,12 @@ import os from avalon import api -from avalon import photoshop from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse from openpype.lib import Anatomy from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name +from openpype.hosts.photoshop import api as photoshop stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 0cb4e4a69f..844bb2463a 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -1,7 +1,8 @@ import re -from avalon import api, photoshop +from avalon import api +from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/publish/closePS.py b/openpype/hosts/photoshop/plugins/publish/closePS.py index 2f0eab0ee5..b4ded96001 100644 --- a/openpype/hosts/photoshop/plugins/publish/closePS.py +++ b/openpype/hosts/photoshop/plugins/publish/closePS.py @@ -4,7 +4,7 @@ import os import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ClosePS(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py index 4d4829555e..5daf47c6ac 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py @@ -2,7 +2,7 @@ import os import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectCurrentFile(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py index f07ff0b0ff..64c99b4fc1 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py @@ -2,7 +2,7 @@ import os import re import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectExtensionVersion(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index 5390df768b..f67cc0cbac 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -1,6 +1,6 @@ import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py index c76e15484e..e264d04d9f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py @@ -1,10 +1,11 @@ -import pyblish.api import os import re -from avalon import photoshop +import pyblish.api + from openpype.lib import prepare_template_data from openpype.lib.plugin_tools import parse_json +from openpype.hosts.photoshop import api as photoshop class CollectRemoteInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 88817c3969..db1ede14d5 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -1,5 +1,5 @@ -import pyblish.api import os +import pyblish.api class CollectWorkfile(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index ae9892e290..2ba81e0bac 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -1,7 +1,7 @@ import os import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractImage(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 8c4d05b282..1ad442279a 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -2,7 +2,7 @@ import os import openpype.api import openpype.lib -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractReview(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py b/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py index 0180640c90..03086f389f 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py @@ -1,5 +1,5 @@ import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractSaveScene(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/increment_workfile.py b/openpype/hosts/photoshop/plugins/publish/increment_workfile.py index 709fb988fc..92132c393b 100644 --- a/openpype/hosts/photoshop/plugins/publish/increment_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/increment_workfile.py @@ -3,7 +3,7 @@ import pyblish.api from openpype.action import get_errored_plugins_from_data from openpype.lib import version_up -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class IncrementWorkfile(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py index 4dc1972074..ebe9cc21ea 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py @@ -1,7 +1,7 @@ from avalon import api import pyblish.api import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ValidateInstanceAssetRepair(pyblish.api.Action): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index 1635096f4b..b40e44d016 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -2,7 +2,7 @@ import re import pyblish.api import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ValidateNamingRepair(pyblish.api.Action): From 551d40b62487618f2c821c40b034c93c92ae2bce Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:09:08 +0100 Subject: [PATCH 087/229] changed from where is 'main' imported --- openpype/scripts/non_python_host_launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/scripts/non_python_host_launch.py b/openpype/scripts/non_python_host_launch.py index 32c4b23f4f..6b17e6a037 100644 --- a/openpype/scripts/non_python_host_launch.py +++ b/openpype/scripts/non_python_host_launch.py @@ -81,7 +81,7 @@ def main(argv): host_name = os.environ["AVALON_APP"].lower() if host_name == "photoshop": - from avalon.photoshop.lib import main + from openpype.hosts.photoshop.api.lib import main elif host_name == "aftereffects": from avalon.aftereffects.lib import main elif host_name == "harmony": From d7bc9c4124c38b4f5ae804508939afaf48f572d1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:17:50 +0100 Subject: [PATCH 088/229] moved lib functions outside of plugins dir --- openpype/hosts/photoshop/api/__init__.py | 7 ++++++- openpype/hosts/photoshop/{plugins/lib.py => api/plugin.py} | 0 openpype/hosts/photoshop/plugins/__init__.py | 0 openpype/hosts/photoshop/plugins/load/load_image.py | 2 +- .../photoshop/plugins/load/load_image_from_sequence.py | 2 +- openpype/hosts/photoshop/plugins/load/load_reference.py | 2 +- 6 files changed, 9 insertions(+), 4 deletions(-) rename openpype/hosts/photoshop/{plugins/lib.py => api/plugin.py} (100%) delete mode 100644 openpype/hosts/photoshop/plugins/__init__.py diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 43756b9ee4..a25dfe7044 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -12,7 +12,9 @@ from .pipeline import ( install, containerise ) - +from .plugin import ( + get_unique_layer_name +) from .workio import ( file_extensions, has_unsaved_changes, @@ -38,6 +40,9 @@ __all__ = [ "install", "containerise", + # Plugin + "get_unique_layer_name", + # workfiles "file_extensions", "has_unsaved_changes", diff --git a/openpype/hosts/photoshop/plugins/lib.py b/openpype/hosts/photoshop/api/plugin.py similarity index 100% rename from openpype/hosts/photoshop/plugins/lib.py rename to openpype/hosts/photoshop/api/plugin.py diff --git a/openpype/hosts/photoshop/plugins/__init__.py b/openpype/hosts/photoshop/plugins/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 3756eba54e..25f47b0257 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -2,8 +2,8 @@ import re from avalon import api from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 158bdc2940..bbf4c60242 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -5,8 +5,8 @@ from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse from openpype.lib import Anatomy -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name stub = photoshop.stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 844bb2463a..0f3c148155 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -3,7 +3,7 @@ import re from avalon import api from openpype.hosts.photoshop import api as photoshop -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name +from openpype.hosts.photoshop.api import get_unique_layer_name stub = photoshop.stub() From ab1b2bdd7d1f82360a2e066fc704581af6b3fd66 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:19:53 +0100 Subject: [PATCH 089/229] moved getting of stub to default photoshop loader class instead of loading it in global scope --- openpype/hosts/photoshop/api/__init__.py | 12 +++--- openpype/hosts/photoshop/api/lib.py | 1 - openpype/hosts/photoshop/api/plugin.py | 9 +++++ .../photoshop/plugins/load/load_image.py | 27 +++++++------ .../plugins/load/load_image_from_sequence.py | 20 ++++------ .../photoshop/plugins/load/load_reference.py | 38 ++++++++++--------- 6 files changed, 60 insertions(+), 47 deletions(-) diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index a25dfe7044..17a371f002 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -4,6 +4,8 @@ Anything that isn't defined here is INTERNAL and unreliable for external use. """ +from .launch_logic import stub + from .pipeline import ( ls, list_instances, @@ -13,6 +15,7 @@ from .pipeline import ( containerise ) from .plugin import ( + PhotoshopLoader, get_unique_layer_name ) from .workio import ( @@ -29,9 +32,10 @@ from .lib import ( maintained_visibility ) -from .launch_logic import stub - __all__ = [ + # launch_logic + "stub" + # pipeline "ls", "list_instances", @@ -41,6 +45,7 @@ __all__ = [ "containerise", # Plugin + "PhotoshopLoader", "get_unique_layer_name", # workfiles @@ -54,7 +59,4 @@ __all__ = [ # lib "maintained_selection", "maintained_visibility", - - # launch_logic - "stub" ] diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 509c5d5c48..707cd476c5 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -1,6 +1,5 @@ import os import sys -import re import contextlib import traceback diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py index 74aff06114..c577c67d82 100644 --- a/openpype/hosts/photoshop/api/plugin.py +++ b/openpype/hosts/photoshop/api/plugin.py @@ -1,5 +1,8 @@ import re +import avalon.api +from .launch_logic import stub + def get_unique_layer_name(layers, asset_name, subset_name): """ @@ -24,3 +27,9 @@ def get_unique_layer_name(layers, asset_name, subset_name): occurrences = names.get(name, 0) return "{}_{:0>3d}".format(name, occurrences + 1) + + +class PhotoshopLoader(avalon.api.Loader): + @staticmethod + def get_stub(): + return stub() diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 25f47b0257..3b1cfe9636 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -5,9 +5,7 @@ from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ImageLoader(api.Loader): +class ImageLoader(photoshop.PhotoshopLoader): """Load images Stores the imported asset in a container named after the asset. @@ -17,11 +15,14 @@ class ImageLoader(api.Loader): representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), + context["asset"]["name"], + name + ) with photoshop.maintained_selection(): - layer = self.import_layer(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name @@ -36,6 +37,8 @@ class ImageLoader(api.Loader): def update(self, container, representation): """ Switch asset or change version """ + stub = self.get_stub() + layer = container.pop("layer") context = representation.get("context", {}) @@ -45,9 +48,9 @@ class ImageLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"], - context["subset"]) + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"], context["subset"] + ) else: # switching version - keep same name layer_name = container["namespace"] @@ -67,6 +70,8 @@ class ImageLoader(api.Loader): Args: container (dict): container to be removed - used to get layer_id """ + stub = self.get_stub() + layer = container.pop("layer") stub.imprint(layer, {}) stub.delete_layer(layer.id) @@ -74,5 +79,5 @@ class ImageLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - def import_layer(self, file_name, layer_name): + def import_layer(self, file_name, layer_name, stub): return stub.import_smart_object(file_name, layer_name) diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index bbf4c60242..ab4682e63e 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,17 +1,13 @@ import os -from avalon import api from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse -from openpype.lib import Anatomy from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ImageFromSequenceLoader(api.Loader): +class ImageFromSequenceLoader(photoshop.PhotoshopLoader): """ Load specifing image from sequence Used only as quick load of reference file from a sequence. @@ -35,15 +31,16 @@ class ImageFromSequenceLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): if data.get("frame"): - self.fname = os.path.join(os.path.dirname(self.fname), - data["frame"]) + self.fname = os.path.join( + os.path.dirname(self.fname), data["frame"] + ) if not os.path.exists(self.fname): return - stub = photoshop.stub() - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"]["name"], name + ) with photoshop.maintained_selection(): layer = stub.import_smart_object(self.fname, layer_name) @@ -95,4 +92,3 @@ class ImageFromSequenceLoader(api.Loader): def remove(self, container): """No update possible, not containerized.""" pass - diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 0f3c148155..60142d4a1f 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -5,27 +5,26 @@ from avalon import api from openpype.hosts.photoshop import api as photoshop from openpype.hosts.photoshop.api import get_unique_layer_name -stub = photoshop.stub() - -class ReferenceLoader(api.Loader): +class ReferenceLoader(photoshop.PhotoshopLoader): """Load reference images - Stores the imported asset in a container named after the asset. + Stores the imported asset in a container named after the asset. - Inheriting from 'load_image' didn't work because of - "Cannot write to closing transport", possible refactor. + Inheriting from 'load_image' didn't work because of + "Cannot write to closing transport", possible refactor. """ families = ["image", "render"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"]["name"], name + ) with photoshop.maintained_selection(): - layer = self.import_layer(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name @@ -40,6 +39,7 @@ class ReferenceLoader(api.Loader): def update(self, container, representation): """ Switch asset or change version """ + stub = self.get_stub() layer = container.pop("layer") context = representation.get("context", {}) @@ -49,9 +49,9 @@ class ReferenceLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"], - context["subset"]) + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"], context["subset"] + ) else: # switching version - keep same name layer_name = container["namespace"] @@ -66,11 +66,12 @@ class ReferenceLoader(api.Loader): ) def remove(self, container): - """ - Removes element from scene: deletes layer + removes from Headline + """Removes element from scene: deletes layer + removes from Headline + Args: container (dict): container to be removed - used to get layer_id """ + stub = self.get_stub() layer = container.pop("layer") stub.imprint(layer, {}) stub.delete_layer(layer.id) @@ -78,6 +79,7 @@ class ReferenceLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - def import_layer(self, file_name, layer_name): - return stub.import_smart_object(file_name, layer_name, - as_reference=True) + def import_layer(self, file_name, layer_name, stub): + return stub.import_smart_object( + file_name, layer_name, as_reference=True + ) From c2b6cf8714a8caf5d6e49efbb44b386fdcdf713a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:38:32 +0100 Subject: [PATCH 090/229] fixed init file --- openpype/hosts/photoshop/api/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 17a371f002..4cc2aa2c78 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -10,12 +10,13 @@ from .pipeline import ( ls, list_instances, remove_instance, - Creator, install, + uninstall, containerise ) from .plugin import ( PhotoshopLoader, + Creator, get_unique_layer_name ) from .workio import ( @@ -34,18 +35,18 @@ from .lib import ( __all__ = [ # launch_logic - "stub" + "stub", # pipeline "ls", "list_instances", "remove_instance", - "Creator", "install", "containerise", # Plugin "PhotoshopLoader", + "Creator", "get_unique_layer_name", # workfiles From b6a5123210d8f278cbb320f7378bce877c798949 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 Jan 2022 23:38:43 +0100 Subject: [PATCH 091/229] moved Creator to plugin.py --- openpype/hosts/photoshop/api/pipeline.py | 34 ------------------------ openpype/hosts/photoshop/api/plugin.py | 34 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index ed7b94e249..25983f2471 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -193,40 +193,6 @@ def _get_stub(): return stub -class Creator(avalon.api.Creator): - """Creator plugin to create instances in Photoshop - - A LayerSet is created to support any number of layers in an instance. If - the selection is used, these layers will be added to the LayerSet. - """ - - def process(self): - # Photoshop can have multiple LayerSets with the same name, which does - # not work with Avalon. - msg = "Instance with name \"{}\" already exists.".format(self.name) - stub = lib.stub() # only after Photoshop is up - for layer in stub.get_layers(): - if self.name.lower() == layer.Name.lower(): - msg = QtWidgets.QMessageBox() - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setText(msg) - msg.exec_() - return False - - # Store selection because adding a group will change selection. - with lib.maintained_selection(): - - # Add selection to group. - if (self.options or {}).get("useSelection"): - group = stub.group_selected_layers(self.name) - else: - group = stub.create_group(self.name) - - stub.imprint(group, self.data) - - return group - - def containerise( name, namespace, layer, context, loader=None, suffix="_CON" ): diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py index c577c67d82..e0db67de2c 100644 --- a/openpype/hosts/photoshop/api/plugin.py +++ b/openpype/hosts/photoshop/api/plugin.py @@ -33,3 +33,37 @@ class PhotoshopLoader(avalon.api.Loader): @staticmethod def get_stub(): return stub() + + +class Creator(avalon.api.Creator): + """Creator plugin to create instances in Photoshop + + A LayerSet is created to support any number of layers in an instance. If + the selection is used, these layers will be added to the LayerSet. + """ + + def process(self): + # Photoshop can have multiple LayerSets with the same name, which does + # not work with Avalon. + msg = "Instance with name \"{}\" already exists.".format(self.name) + stub = lib.stub() # only after Photoshop is up + for layer in stub.get_layers(): + if self.name.lower() == layer.Name.lower(): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setText(msg) + msg.exec_() + return False + + # Store selection because adding a group will change selection. + with lib.maintained_selection(): + + # Add selection to group. + if (self.options or {}).get("useSelection"): + group = stub.group_selected_layers(self.name) + else: + group = stub.create_group(self.name) + + stub.imprint(group, self.data) + + return group From 3e53a45bfadc50ae5dd2167f714ebba657edeec2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:20:27 +0100 Subject: [PATCH 092/229] Flame: collect timeline ocio plugin --- .../plugins/publish/precollect_workfile.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/precollect_workfile.py diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py new file mode 100644 index 0000000000..0533d01e00 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -0,0 +1,26 @@ +import pyblish.api +import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export + + +class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): + """Inject the current working context into publish context""" + + label = "Precollect Timeline OTIO" + order = pyblish.api.CollectorOrder - 0.5 + + def process(self, context): + project = opfapi.get_current_project() + sequence = opfapi.get_current_sequence(opfapi.CTX.selection) + + # adding otio timeline to context + otio_timeline = flame_export.create_otio_timeline(sequence) + + # update context with main project attributes + context.data.update({ + "otioTimeline": otio_timeline, + "currentFile": "Flame/{}/{}".format( + project.name, sequence.name + ), + "fps": float(str(sequence.frame_rate)[:-4]) + }) From 104b57120c64d3095c492848adca11a47a958749 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:32:35 +0100 Subject: [PATCH 093/229] Flame: collect instance in otio timeline plugin --- .../flame/plugins/publish/precollect_workfile.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 0533d01e00..3497d19d15 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -1,4 +1,5 @@ import pyblish.api +import avalon.api as avalon import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export @@ -10,12 +11,25 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): + asset = avalon.Session["AVALON_ASSET"] + subset = "otioTimeline" project = opfapi.get_current_project() sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # adding otio timeline to context otio_timeline = flame_export.create_otio_timeline(sequence) + instance_data = { + "name": "{}_{}".format(asset, subset), + "asset": asset, + "subset": "{}{}".format(asset, subset.capitalize()), + "family": "workfile" + } + + # create instance with workfile + instance = context.create_instance(**instance_data) + self.log.info("Creating instance: {}".format(instance)) + # update context with main project attributes context.data.update({ "otioTimeline": otio_timeline, From 9e70f67f4716d8af3956af3486ffc47256b9db96 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:32:53 +0100 Subject: [PATCH 094/229] Flame: exctracting otio file --- .../plugins/publish/extract_otio_file.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/extract_otio_file.py diff --git a/openpype/hosts/flame/plugins/publish/extract_otio_file.py b/openpype/hosts/flame/plugins/publish/extract_otio_file.py new file mode 100644 index 0000000000..7dd75974fc --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/extract_otio_file.py @@ -0,0 +1,43 @@ +import os +import pyblish.api +import openpype.api +import opentimelineio as otio + + +class ExtractOTIOFile(openpype.api.Extractor): + """ + Extractor export OTIO file + """ + + label = "Extract OTIO file" + order = pyblish.api.ExtractorOrder - 0.45 + families = ["workfile"] + hosts = ["flame"] + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + name = instance.data["name"] + staging_dir = self.staging_dir(instance) + + otio_timeline = instance.context.data["otioTimeline"] + # create otio timeline representation + otio_file_name = name + ".otio" + otio_file_path = os.path.join(staging_dir, otio_file_name) + + # export otio file to temp dir + otio.adapters.write_to_file(otio_timeline, otio_file_path) + + representation_otio = { + 'name': "otio", + 'ext': "otio", + 'files': otio_file_name, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation_otio) + + self.log.info("Added OTIO file representation: {}".format( + representation_otio)) From 100ff46421ceb688e7d2e20dec58f43d20f5902b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 11 Jan 2022 13:27:16 +0100 Subject: [PATCH 095/229] OP-2049 - fix frame content for sequence starting with 0 Previously expression didn't trigger as repre.get("frameStart") returned 0 which translated into False --- openpype/plugins/publish/integrate_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 1b0b8da2ff..cec2e470b3 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -580,7 +580,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if repre.get("outputName"): representation["context"]["output"] = repre['outputName'] - if sequence_repre and repre.get("frameStart"): + if sequence_repre and repre.get("frameStart") is not None: representation['context']['frame'] = ( dst_padding_exp % int(repre.get("frameStart")) ) From 32ceb9e9a98fa662bab525be4b8a007f4e8624f6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:24:09 +0100 Subject: [PATCH 096/229] flame: enhancing code of api lib --- openpype/hosts/flame/api/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 2cc9fee173..787ecf4569 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -448,6 +448,8 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue + if segment.hidden: + continue if ( selected is True and segment.selected.get_value() is not True @@ -522,7 +524,7 @@ def _get_shot_tokens_values(clip, tokens): def get_segment_attributes(segment): - if str(segment.name)[1:-1] == "": + if segment.name.get_value() == "": return None # Add timeline segment to tree From 02af9b69a195dca87a109fbfd28880372f4feaf4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:25:16 +0100 Subject: [PATCH 097/229] flame: adding flameSequnce attribute to publishing context --- openpype/hosts/flame/plugins/publish/precollect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 3497d19d15..3d2ce97755 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -32,6 +32,7 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): # update context with main project attributes context.data.update({ + "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( project.name, sequence.name From 281ae76794f2c04ba9081c402b8632bb37b3cafc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:55:18 +0100 Subject: [PATCH 098/229] flame: adding functions to lib and api --- openpype/hosts/flame/api/__init__.py | 10 ++- openpype/hosts/flame/api/lib.py | 114 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index dc47488dc1..308682b884 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -23,7 +23,11 @@ from .lib import ( get_sequence_segments, maintained_segment_selection, reset_segment_selection, - get_segment_attributes + get_segment_attributes, + get_clips_in_reels, + get_reformated_path, + get_frame_from_path, + get_padding_from_path ) from .utils import ( setup @@ -80,6 +84,10 @@ __all__ = [ "maintained_segment_selection", "reset_segment_selection", "get_segment_attributes", + "get_clips_in_reels", + "get_reformated_path", + "get_frame_from_path", + "get_padding_from_path", # pipeline "install", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 787ecf4569..4404f7a612 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -537,6 +537,12 @@ def get_segment_attributes(segment): "PySegment": segment } + # head and tail with forward compatibility + if segment.head: + clip_data["segment_head"] = int(segment.head) + if segment.tail: + clip_data["segment_tail"] = int(segment.tail) + # add all available shot tokens shot_tokens = _get_shot_tokens_values(segment, [ "", "", "", "", "", @@ -564,3 +570,111 @@ def get_segment_attributes(segment): clip_data["segment_timecodes"] = segment_attrs_data return clip_data + + +def get_clips_in_reels(project): + output_clips = [] + project_desktop = project.current_workspace.desktop + + for reel_group in project_desktop.reel_groups: + for reel in reel_group.reels: + for clip in reel.clips: + clip_data = { + "PyClip": clip, + "fps": float(str(clip.frame_rate)[:-4]) + } + + attrs = [ + "name", "width", "height", + "ratio", "sample_rate", "bit_depth" + ] + + for attr in attrs: + val = getattr(clip, attr) + clip_data[attr] = val + + version = clip.versions[-1] + track = version.tracks[-1] + for segment in track.segments: + segment_data = get_segment_attributes(segment) + clip_data.update(segment_data) + + output_clips.append(clip_data) + + return output_clips + + +def get_reformated_path(path, padded=True): + """ + Return fixed python expression path + + Args: + path (str): path url or simple file name + + Returns: + type: string with reformated path + + Example: + get_reformated_path("plate.1001.exr") > plate.%04d.exr + + """ + padding = get_padding_from_path(path) + found = get_frame_from_path(path) + + if not found: + log.info("Path is not sequence: {}".format(path)) + return path + + if padded: + path = path.replace(found, "%0{}d".format(padding)) + else: + path = path.replace(found, "%d") + + return path + + +def get_padding_from_path(path): + """ + Return padding number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: padding number + + Example: + get_padding_from_path("plate.0001.exr") > 4 + + """ + found = get_frame_from_path(path) + + if found: + return len(found) + else: + return None + + +def get_frame_from_path(path): + """ + Return sequence number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: sequence frame number + + Example: + def get_frame_from_path(path): + ("plate.0001.exr") > 0001 + + """ + frame_pattern = re.compile(r"[._](\d+)[.]") + + found = re.findall(frame_pattern, path) + + if found: + return found.pop() + else: + return None From 50e1cbf31e38e7923aceba97da4d1d37eee7c47c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:55:40 +0100 Subject: [PATCH 099/229] flame: adding flameProject to publishing context attributes --- openpype/hosts/flame/plugins/publish/precollect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 3d2ce97755..e7383ddec8 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -32,6 +32,7 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): # update context with main project attributes context.data.update({ + "flameProject": project, "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( From 460048ef4c1a5b6c90ef8161f6394acb85a95d0c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:58:12 +0100 Subject: [PATCH 100/229] flame: collect instances wip --- .../plugins/publish/precollect_instances.py | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/precollect_instances.py diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py new file mode 100644 index 0000000000..5f3b71eba4 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -0,0 +1,251 @@ +import pyblish +# import openpype +import openpype.hosts.flame.api as opfapi + +# # developer reload modules +from pprint import pformat + + +class PrecollectInstances(pyblish.api.ContextPlugin): + """Collect all Track items selection.""" + + order = pyblish.api.CollectorOrder - 0.49 + label = "Precollect Instances" + hosts = ["flame"] + + audio_track_items = [] + + def process(self, context): + project = context.data["flameProject"] + sequence = context.data["flameSequence"] + self.otio_timeline = context.data["otioTimeline"] + self.clips_in_reels = opfapi.get_clips_in_reels(project) + + # return only actually selected and enabled segments + selected_segments = opfapi.get_sequence_segments(sequence, True) + + # only return enabled segments + if not selected_segments: + selected_segments = opfapi.get_sequence_segments( + sequence) + + self.log.info( + "Processing following segments: {}".format( + [s.name for s in selected_segments])) + + # process all sellected timeline track items + for segment in selected_segments: + + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) + + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + + if not marker_data: + continue + + if marker_data.get("id") != "pyblish.avalon.instance": + continue + + file_path = clip_data["fpath"] + first_frame = opfapi.get_frame_from_path(file_path) or 0 + + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") + + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) + ) + + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) + + # add audio to families + with_audio = False + if marker_data.pop("audio"): + with_audio = True + + # add tag data to instance data + data = { + k: v for k, v in marker_data.items() + if k not in ("id", "applieswhole", "label") + } + + asset = marker_data["asset"] + subset = marker_data["subset"] + + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) + + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": context.data["fps"], + }) + + # # otio clip data + # otio_data = self.get_otio_clip_instance_data(segment) or {} + # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # data.update(otio_data) + # self.log.debug("__ data: {}".format(pformat(data))) + + # # add resolution + # self.get_resolution_to_data(data, context) + + # create instance + instance = context.create_instance(**data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self.create_shot_instance(context, clip_name, **data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True + + def get_resolution_to_data(self, data, context): + assert data.get("otioClip"), "Missing `otioClip` data" + + # solve source resolution option + if data.get("sourceResolution", None): + otio_clip_metadata = data[ + "otioClip"].media_reference.metadata + data.update({ + "resolutionWidth": otio_clip_metadata[ + "openpype.source.width"], + "resolutionHeight": otio_clip_metadata[ + "openpype.source.height"], + "pixelAspect": otio_clip_metadata[ + "openpype.source.pixelAspect"] + }) + else: + otio_tl_metadata = context.data["otioTimeline"].metadata + data.update({ + "resolutionWidth": otio_tl_metadata["openpype.timeline.width"], + "resolutionHeight": otio_tl_metadata[ + "openpype.timeline.height"], + "pixelAspect": otio_tl_metadata[ + "openpype.timeline.pixelAspect"] + }) + + def create_shot_instance(self, context, clip_name, **data): + master_layer = data.get("heroTrack") + hierarchy_data = data.get("hierarchyData") + asset = data.get("asset") + + if not master_layer: + return + + if not hierarchy_data: + return + + asset = data["asset"] + subset = "shotMain" + + # insert family into families + family = "shot" + + # form label + label = asset + if asset != clip_name: + label += " ({}) ".format(clip_name) + label += " {}".format(subset) + label += " [{}]".format(family) + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "subset": subset, + "asset": asset, + "family": family, + "families": [] + }) + + instance = context.create_instance(**data) + self.log.info("Creating instance: {}".format(instance)) + self.log.debug( + "_ instance.data: {}".format(pformat(instance.data))) + + # def get_otio_clip_instance_data(self, segment): + # """ + # Return otio objects for timeline, track and clip + + # Args: + # timeline_item_data (dict): timeline_item_data from list returned by + # resolve.get_current_timeline_items() + # otio_timeline (otio.schema.Timeline): otio object + + # Returns: + # dict: otio clip object + + # """ + # ti_track_name = segment.parent().name() + # timeline_range = self.create_otio_time_range_from_timeline_item_data( + # segment) + # for otio_clip in self.otio_timeline.each_clip(): + # track_name = otio_clip.parent().name + # parent_range = otio_clip.range_in_parent() + # if ti_track_name not in track_name: + # continue + # if otio_clip.name not in segment.name(): + # continue + # if openpype.lib.is_overlapping_otio_ranges( + # parent_range, timeline_range, strict=True): + + # # add pypedata marker to otio_clip metadata + # for marker in otio_clip.markers: + # if phiero.pype_tag_name in marker.name: + # otio_clip.metadata.update(marker.metadata) + # return {"otioClip": otio_clip} + + # return None + + # @staticmethod + # def create_otio_time_range_from_timeline_item_data(segment): + # speed = segment.playbackSpeed() + # timeline = phiero.get_current_sequence() + # frame_start = int(segment.timelineIn()) + # frame_duration = int(segment.sourceDuration() / speed) + # fps = timeline.framerate().toFloat() + + # return hiero_export.create_otio_time_range( + # frame_start, frame_duration, fps) From 4fa7eb25ffabc6f83a7af09400f42d3e61addbb3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 16:32:00 +0100 Subject: [PATCH 101/229] flame: fix selection --- openpype/hosts/flame/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 4404f7a612..a409e731e3 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -448,7 +448,7 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue - if segment.hidden: + if segment.hidden.get_value() is True: continue if ( selected is True From a326ab429040c799ac6b45683b326aba65da3fc4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:01:39 +0100 Subject: [PATCH 102/229] flame: deactivating test plugin --- openpype/hosts/flame/plugins/publish/collect_test_selection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 0c75b3204f..84fd4fafe8 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -14,6 +14,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder label = "test selection" hosts = ["flame"] + active = False def process(self, context): self.log.info( From 1669f1782b08c2906dc2c0a705e66bda8031e73c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:03:03 +0100 Subject: [PATCH 103/229] flame: adding maintained selection to publish plugins --- .../plugins/publish/precollect_instances.py | 198 ++++++++---------- .../plugins/publish/precollect_workfile.py | 3 +- 2 files changed, 95 insertions(+), 106 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index 5f3b71eba4..e302bc42a4 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -21,126 +21,114 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.otio_timeline = context.data["otioTimeline"] self.clips_in_reels = opfapi.get_clips_in_reels(project) - # return only actually selected and enabled segments - selected_segments = opfapi.get_sequence_segments(sequence, True) + # process all sellected + with opfapi.maintained_segment_selection(sequence) as selected_segments: + for segment in selected_segments: + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) - # only return enabled segments - if not selected_segments: - selected_segments = opfapi.get_sequence_segments( - sequence) + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format(pformat(marker_data))) - self.log.info( - "Processing following segments: {}".format( - [s.name for s in selected_segments])) + if not marker_data: + continue - # process all sellected timeline track items - for segment in selected_segments: + if marker_data.get("id") != "pyblish.avalon.instance": + continue - clip_data = opfapi.get_segment_attributes(segment) - clip_name = clip_data["segment_name"] - self.log.debug("clip_name: {}".format(clip_name)) + file_path = clip_data["fpath"] + first_frame = opfapi.get_frame_from_path(file_path) or 0 - # get openpype tag data - marker_data = opfapi.get_segment_data_marker(segment) - self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") - if not marker_data: - continue - - if marker_data.get("id") != "pyblish.avalon.instance": - continue - - file_path = clip_data["fpath"] - first_frame = opfapi.get_frame_from_path(file_path) or 0 - - # calculate head and tail with forward compatibility - head = clip_data.get("segment_head") - tail = clip_data.get("segment_tail") - - if not head: - head = int(clip_data["source_in"]) - int(first_frame) - if not tail: - tail = int( - clip_data["source_duration"] - ( - head + clip_data["record_duration"] + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) ) - ) - # solve handles length - marker_data["handleStart"] = min( - marker_data["handleStart"], head) - marker_data["handleEnd"] = min( - marker_data["handleEnd"], tail) + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) - # add audio to families - with_audio = False - if marker_data.pop("audio"): - with_audio = True + # add audio to families + with_audio = False + if marker_data.pop("audio"): + with_audio = True - # add tag data to instance data - data = { - k: v for k, v in marker_data.items() - if k not in ("id", "applieswhole", "label") - } - - asset = marker_data["asset"] - subset = marker_data["subset"] - - # insert family into families - family = marker_data["family"] - families = [str(f) for f in marker_data["families"]] - families.insert(0, str(family)) - - # form label - label = asset - if asset != clip_name: - label += " ({})".format(clip_name) - label += " {}".format(subset) - label += " {}".format("[" + ", ".join(families) + "]") - - data.update({ - "name": "{}_{}".format(asset, subset), - "label": label, - "asset": asset, - "item": segment, - "families": families, - "publish": marker_data["publish"], - "fps": context.data["fps"], - }) - - # # otio clip data - # otio_data = self.get_otio_clip_instance_data(segment) or {} - # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - # data.update(otio_data) - # self.log.debug("__ data: {}".format(pformat(data))) - - # # add resolution - # self.get_resolution_to_data(data, context) - - # create instance - instance = context.create_instance(**data) - - # add colorspace data - instance.data.update({ - "versionData": { - "colorspace": clip_data["colour_space"], + # add tag data to instance data + data = { + k: v for k, v in marker_data.items() + if k not in ("id", "applieswhole", "label") } - }) - # create shot instance for shot attributes create/update - self.create_shot_instance(context, clip_name, **data) + asset = marker_data["asset"] + subset = marker_data["subset"] - self.log.info("Creating instance: {}".format(instance)) - self.log.info( - "_ instance.data: {}".format(pformat(instance.data))) + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) - if not with_audio: - continue + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") - # add audioReview attribute to plate instance data - # if reviewTrack is on - if marker_data.get("reviewTrack") is not None: - instance.data["reviewAudio"] = True + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": context.data["fps"], + }) + + # # otio clip data + # otio_data = self.get_otio_clip_instance_data(segment) or {} + # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # data.update(otio_data) + # self.log.debug("__ data: {}".format(pformat(data))) + + # # add resolution + # self.get_resolution_to_data(data, context) + + # create instance + instance = context.create_instance(**data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self.create_shot_instance(context, clip_name, **data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True def get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index e7383ddec8..aff85e22e6 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -17,7 +17,8 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # adding otio timeline to context - otio_timeline = flame_export.create_otio_timeline(sequence) + with opfapi.maintained_segment_selection(sequence): + otio_timeline = flame_export.create_otio_timeline(sequence) instance_data = { "name": "{}_{}".format(asset, subset), From 74a4fbfea60ff34866000a49962a26d40e6b9fd8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:22:24 +0100 Subject: [PATCH 104/229] flame: testing export of otio timeline --- .../flame/plugins/publish/collect_test_selection.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 0c75b3204f..3e3ff27035 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -1,7 +1,9 @@ import os import pyblish.api +import tempfile import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export as otio_export +import opentimelineio as otio from pprint import pformat reload(otio_export) # noqa @@ -25,16 +27,22 @@ class CollectTestSelection(pyblish.api.ContextPlugin): self.test_otio_export(sequence) def test_otio_export(self, sequence): - home_dir = os.path.expanduser("~") + test_dir = os.path.normpath( + tempfile.mkdtemp(prefix="test_pyblish_tmp_") + ) export_path = os.path.normpath( os.path.join( - home_dir, "otio_timeline_export.otio" + test_dir, "otio_timeline_export.otio" ) ) otio_timeline = otio_export.create_otio_timeline(sequence) otio_export.write_to_file( otio_timeline, export_path ) + read_timeline_otio = otio.adapters.read_from_file(export_path) + + if otio_timeline != read_timeline_otio: + raise Exception("Exported otio timeline is different from original") self.log.info(pformat(otio_timeline)) self.log.info("Otio exported to: {}".format(export_path)) From 896ba23730a0fdedbffe5820e036d058a7e36d39 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:23:56 +0100 Subject: [PATCH 105/229] flame: hound fixes --- .../hosts/flame/plugins/publish/collect_test_selection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 3e3ff27035..73401368b1 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -28,7 +28,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): def test_otio_export(self, sequence): test_dir = os.path.normpath( - tempfile.mkdtemp(prefix="test_pyblish_tmp_") + tempfile.mkdtemp(prefix="test_pyblish_tmp_") ) export_path = os.path.normpath( os.path.join( @@ -42,7 +42,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): read_timeline_otio = otio.adapters.read_from_file(export_path) if otio_timeline != read_timeline_otio: - raise Exception("Exported otio timeline is different from original") + raise Exception("Exported timeline is different from original") self.log.info(pformat(otio_timeline)) self.log.info("Otio exported to: {}".format(export_path)) From 66cedb61e929ae475ce525bb538a8d3166471a52 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:29:11 +0100 Subject: [PATCH 106/229] flame: addressing CTX.apps renamed to CTX.flame_apps --- openpype/hosts/flame/api/lib.py | 2 +- .../api/utility_scripts/openpype_in_flame.py | 23 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 2cc9fee173..3ec57c6434 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -20,7 +20,7 @@ log = Logger.get_logger(__name__) class CTX: # singleton used for passing data between api modules app_framework = None - apps = [] + flame_apps = [] selection = None diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py index c385fbb8cb..72614f2b5d 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py @@ -45,14 +45,14 @@ sys.excepthook = exeption_handler def cleanup(): """Cleaning up Flame framework context """ - if opfapi.CTX.apps: - print('`{}` cleaning up apps:\n {}\n'.format( - __file__, pformat(opfapi.CTX.apps))) - while len(opfapi.CTX.apps): - app = opfapi.CTX.apps.pop() + if opfapi.CTX.flame_apps: + print('`{}` cleaning up flame_apps:\n {}\n'.format( + __file__, pformat(opfapi.CTX.flame_apps))) + while len(opfapi.CTX.flame_apps): + app = opfapi.CTX.flame_apps.pop() print('`{}` removing : {}'.format(__file__, app.name)) del app - opfapi.CTX.apps = [] + opfapi.CTX.flame_apps = [] if opfapi.CTX.app_framework: print('openpype\t: {} cleaning up'.format( @@ -66,11 +66,12 @@ atexit.register(cleanup) def load_apps(): - """Load available apps into Flame framework + """Load available flame_apps into Flame framework """ - opfapi.CTX.apps.append( + opfapi.CTX.flame_apps.append( opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) - opfapi.CTX.apps.append(opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) + opfapi.CTX.flame_apps.append( + opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) opfapi.CTX.app_framework.log.info("Apps are loaded") @@ -103,7 +104,7 @@ Initialisation of the hook is starting from here First it needs to test if it can import the flame modul. This will happen only in case a project has been loaded. Then `app_initialized` will load main Framework which will load -all menu objects as apps. +all menu objects as flame_apps. """ try: @@ -131,7 +132,7 @@ def _build_app_menu(app_name): # first find the relative appname app = None - for _app in opfapi.CTX.apps: + for _app in opfapi.CTX.flame_apps: if _app.__class__.__name__ == app_name: app = _app From c025d4c8e4dc5f4c9c4f68e209e990db05f35a89 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:34:02 +0100 Subject: [PATCH 107/229] flame: removing constant True return --- openpype/hosts/flame/api/lib.py | 5 +---- openpype/hosts/flame/api/pipeline.py | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 3ec57c6434..dd212297e2 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -374,8 +374,6 @@ def set_segment_data_marker(segment, data=None): # add tag data to marker's comment marker.comment = json.dumps(data) - return True - def set_publish_attribute(segment, value): """ Set Publish attribute in input Tag object @@ -388,8 +386,7 @@ def set_publish_attribute(segment, value): tag_data["publish"] = value # set data to the publish attribute - if not set_segment_data_marker(segment, tag_data): - raise AttributeError("Not imprint data to segment") + set_segment_data_marker(segment, tag_data) def get_publish_attribute(segment): diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index b65c85f5df..30c70b491b 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -144,8 +144,7 @@ def imprint(segment, data=None): """ data = data or {} - if not set_segment_data_marker(segment, data): - raise AttributeError("Not imprint data to segment") + set_segment_data_marker(segment, data) # add publish attribute set_publish_attribute(segment, True) From 384edda56fba3ab6ea20b351a81e3fd8e5ea65e8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:39:25 +0100 Subject: [PATCH 108/229] flame: improving code from suggestion --- openpype/hosts/flame/api/lib.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index dd212297e2..7788a6b3f4 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -545,16 +545,16 @@ def get_segment_attributes(segment): "source_duration", "source_in", "source_out" ] segment_attrs_data = {} - for attr in segment_attrs: - if not hasattr(segment, attr): + for attr_name in segment_attrs: + if not hasattr(segment, attr_name): continue - _value = getattr(segment, attr) - segment_attrs_data[attr] = str(_value).replace("+", ":") + attr = getattr(segment, attr_name) + segment_attrs_data[attr] = str(attr).replace("+", ":") if attr in ["record_in", "record_out"]: - clip_data[attr] = _value.relative_frame + clip_data[attr_name] = attr.relative_frame else: - clip_data[attr] = _value.frame + clip_data[attr_name] = attr.frame clip_data["segment_timecodes"] = segment_attrs_data From 74958ba642643dc78988ad1b6b9fbfcaa2127148 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:20:27 +0100 Subject: [PATCH 109/229] Flame: collect timeline ocio plugin --- .../plugins/publish/precollect_workfile.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/precollect_workfile.py diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py new file mode 100644 index 0000000000..0533d01e00 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -0,0 +1,26 @@ +import pyblish.api +import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export + + +class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): + """Inject the current working context into publish context""" + + label = "Precollect Timeline OTIO" + order = pyblish.api.CollectorOrder - 0.5 + + def process(self, context): + project = opfapi.get_current_project() + sequence = opfapi.get_current_sequence(opfapi.CTX.selection) + + # adding otio timeline to context + otio_timeline = flame_export.create_otio_timeline(sequence) + + # update context with main project attributes + context.data.update({ + "otioTimeline": otio_timeline, + "currentFile": "Flame/{}/{}".format( + project.name, sequence.name + ), + "fps": float(str(sequence.frame_rate)[:-4]) + }) From 4f4efea936d4a198fe1b220c07e71ae77a065621 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:32:35 +0100 Subject: [PATCH 110/229] Flame: collect instance in otio timeline plugin --- .../flame/plugins/publish/precollect_workfile.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 0533d01e00..3497d19d15 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -1,4 +1,5 @@ import pyblish.api +import avalon.api as avalon import openpype.hosts.flame.api as opfapi from openpype.hosts.flame.otio import flame_export @@ -10,12 +11,25 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): + asset = avalon.Session["AVALON_ASSET"] + subset = "otioTimeline" project = opfapi.get_current_project() sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # adding otio timeline to context otio_timeline = flame_export.create_otio_timeline(sequence) + instance_data = { + "name": "{}_{}".format(asset, subset), + "asset": asset, + "subset": "{}{}".format(asset, subset.capitalize()), + "family": "workfile" + } + + # create instance with workfile + instance = context.create_instance(**instance_data) + self.log.info("Creating instance: {}".format(instance)) + # update context with main project attributes context.data.update({ "otioTimeline": otio_timeline, From 65fe3a28bb74b26055913fe909208fd6e97becdf Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 12:32:53 +0100 Subject: [PATCH 111/229] Flame: exctracting otio file --- .../plugins/publish/extract_otio_file.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/extract_otio_file.py diff --git a/openpype/hosts/flame/plugins/publish/extract_otio_file.py b/openpype/hosts/flame/plugins/publish/extract_otio_file.py new file mode 100644 index 0000000000..7dd75974fc --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/extract_otio_file.py @@ -0,0 +1,43 @@ +import os +import pyblish.api +import openpype.api +import opentimelineio as otio + + +class ExtractOTIOFile(openpype.api.Extractor): + """ + Extractor export OTIO file + """ + + label = "Extract OTIO file" + order = pyblish.api.ExtractorOrder - 0.45 + families = ["workfile"] + hosts = ["flame"] + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + name = instance.data["name"] + staging_dir = self.staging_dir(instance) + + otio_timeline = instance.context.data["otioTimeline"] + # create otio timeline representation + otio_file_name = name + ".otio" + otio_file_path = os.path.join(staging_dir, otio_file_name) + + # export otio file to temp dir + otio.adapters.write_to_file(otio_timeline, otio_file_path) + + representation_otio = { + 'name': "otio", + 'ext': "otio", + 'files': otio_file_name, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation_otio) + + self.log.info("Added OTIO file representation: {}".format( + representation_otio)) From 42bdd8db7f3c1127f376372f9c5c12bc70daad89 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:24:09 +0100 Subject: [PATCH 112/229] flame: enhancing code of api lib --- openpype/hosts/flame/api/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 7788a6b3f4..b5c7f2031b 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -445,6 +445,8 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue + if segment.hidden: + continue if ( selected is True and segment.selected.get_value() is not True @@ -519,7 +521,7 @@ def _get_shot_tokens_values(clip, tokens): def get_segment_attributes(segment): - if str(segment.name)[1:-1] == "": + if segment.name.get_value() == "": return None # Add timeline segment to tree From eb6c6a5c9fc96cd8596484f06ab91b5bbad1db64 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:25:16 +0100 Subject: [PATCH 113/229] flame: adding flameSequnce attribute to publishing context --- openpype/hosts/flame/plugins/publish/precollect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 3497d19d15..3d2ce97755 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -32,6 +32,7 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): # update context with main project attributes context.data.update({ + "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( project.name, sequence.name From 093015bf34b438d66c6b773c248bf7d67168a6ae Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:55:18 +0100 Subject: [PATCH 114/229] flame: adding functions to lib and api --- openpype/hosts/flame/api/__init__.py | 10 ++- openpype/hosts/flame/api/lib.py | 114 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index dc47488dc1..308682b884 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -23,7 +23,11 @@ from .lib import ( get_sequence_segments, maintained_segment_selection, reset_segment_selection, - get_segment_attributes + get_segment_attributes, + get_clips_in_reels, + get_reformated_path, + get_frame_from_path, + get_padding_from_path ) from .utils import ( setup @@ -80,6 +84,10 @@ __all__ = [ "maintained_segment_selection", "reset_segment_selection", "get_segment_attributes", + "get_clips_in_reels", + "get_reformated_path", + "get_frame_from_path", + "get_padding_from_path", # pipeline "install", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index b5c7f2031b..b204230d9a 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -534,6 +534,12 @@ def get_segment_attributes(segment): "PySegment": segment } + # head and tail with forward compatibility + if segment.head: + clip_data["segment_head"] = int(segment.head) + if segment.tail: + clip_data["segment_tail"] = int(segment.tail) + # add all available shot tokens shot_tokens = _get_shot_tokens_values(segment, [ "", "", "", "", "", @@ -561,3 +567,111 @@ def get_segment_attributes(segment): clip_data["segment_timecodes"] = segment_attrs_data return clip_data + + +def get_clips_in_reels(project): + output_clips = [] + project_desktop = project.current_workspace.desktop + + for reel_group in project_desktop.reel_groups: + for reel in reel_group.reels: + for clip in reel.clips: + clip_data = { + "PyClip": clip, + "fps": float(str(clip.frame_rate)[:-4]) + } + + attrs = [ + "name", "width", "height", + "ratio", "sample_rate", "bit_depth" + ] + + for attr in attrs: + val = getattr(clip, attr) + clip_data[attr] = val + + version = clip.versions[-1] + track = version.tracks[-1] + for segment in track.segments: + segment_data = get_segment_attributes(segment) + clip_data.update(segment_data) + + output_clips.append(clip_data) + + return output_clips + + +def get_reformated_path(path, padded=True): + """ + Return fixed python expression path + + Args: + path (str): path url or simple file name + + Returns: + type: string with reformated path + + Example: + get_reformated_path("plate.1001.exr") > plate.%04d.exr + + """ + padding = get_padding_from_path(path) + found = get_frame_from_path(path) + + if not found: + log.info("Path is not sequence: {}".format(path)) + return path + + if padded: + path = path.replace(found, "%0{}d".format(padding)) + else: + path = path.replace(found, "%d") + + return path + + +def get_padding_from_path(path): + """ + Return padding number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: padding number + + Example: + get_padding_from_path("plate.0001.exr") > 4 + + """ + found = get_frame_from_path(path) + + if found: + return len(found) + else: + return None + + +def get_frame_from_path(path): + """ + Return sequence number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: sequence frame number + + Example: + def get_frame_from_path(path): + ("plate.0001.exr") > 0001 + + """ + frame_pattern = re.compile(r"[._](\d+)[.]") + + found = re.findall(frame_pattern, path) + + if found: + return found.pop() + else: + return None From 402b18640967070ad8fb2079f7ec0d92fb5a222b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:55:40 +0100 Subject: [PATCH 115/229] flame: adding flameProject to publishing context attributes --- openpype/hosts/flame/plugins/publish/precollect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index 3d2ce97755..e7383ddec8 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -32,6 +32,7 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): # update context with main project attributes context.data.update({ + "flameProject": project, "flameSequence": sequence, "otioTimeline": otio_timeline, "currentFile": "Flame/{}/{}".format( From 70d31f2ef16b9a97e03c5f956b344c64bb25c1df Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 15:58:12 +0100 Subject: [PATCH 116/229] flame: collect instances wip --- .../plugins/publish/precollect_instances.py | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/precollect_instances.py diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py new file mode 100644 index 0000000000..5f3b71eba4 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -0,0 +1,251 @@ +import pyblish +# import openpype +import openpype.hosts.flame.api as opfapi + +# # developer reload modules +from pprint import pformat + + +class PrecollectInstances(pyblish.api.ContextPlugin): + """Collect all Track items selection.""" + + order = pyblish.api.CollectorOrder - 0.49 + label = "Precollect Instances" + hosts = ["flame"] + + audio_track_items = [] + + def process(self, context): + project = context.data["flameProject"] + sequence = context.data["flameSequence"] + self.otio_timeline = context.data["otioTimeline"] + self.clips_in_reels = opfapi.get_clips_in_reels(project) + + # return only actually selected and enabled segments + selected_segments = opfapi.get_sequence_segments(sequence, True) + + # only return enabled segments + if not selected_segments: + selected_segments = opfapi.get_sequence_segments( + sequence) + + self.log.info( + "Processing following segments: {}".format( + [s.name for s in selected_segments])) + + # process all sellected timeline track items + for segment in selected_segments: + + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) + + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + + if not marker_data: + continue + + if marker_data.get("id") != "pyblish.avalon.instance": + continue + + file_path = clip_data["fpath"] + first_frame = opfapi.get_frame_from_path(file_path) or 0 + + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") + + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) + ) + + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) + + # add audio to families + with_audio = False + if marker_data.pop("audio"): + with_audio = True + + # add tag data to instance data + data = { + k: v for k, v in marker_data.items() + if k not in ("id", "applieswhole", "label") + } + + asset = marker_data["asset"] + subset = marker_data["subset"] + + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) + + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": context.data["fps"], + }) + + # # otio clip data + # otio_data = self.get_otio_clip_instance_data(segment) or {} + # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # data.update(otio_data) + # self.log.debug("__ data: {}".format(pformat(data))) + + # # add resolution + # self.get_resolution_to_data(data, context) + + # create instance + instance = context.create_instance(**data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self.create_shot_instance(context, clip_name, **data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True + + def get_resolution_to_data(self, data, context): + assert data.get("otioClip"), "Missing `otioClip` data" + + # solve source resolution option + if data.get("sourceResolution", None): + otio_clip_metadata = data[ + "otioClip"].media_reference.metadata + data.update({ + "resolutionWidth": otio_clip_metadata[ + "openpype.source.width"], + "resolutionHeight": otio_clip_metadata[ + "openpype.source.height"], + "pixelAspect": otio_clip_metadata[ + "openpype.source.pixelAspect"] + }) + else: + otio_tl_metadata = context.data["otioTimeline"].metadata + data.update({ + "resolutionWidth": otio_tl_metadata["openpype.timeline.width"], + "resolutionHeight": otio_tl_metadata[ + "openpype.timeline.height"], + "pixelAspect": otio_tl_metadata[ + "openpype.timeline.pixelAspect"] + }) + + def create_shot_instance(self, context, clip_name, **data): + master_layer = data.get("heroTrack") + hierarchy_data = data.get("hierarchyData") + asset = data.get("asset") + + if not master_layer: + return + + if not hierarchy_data: + return + + asset = data["asset"] + subset = "shotMain" + + # insert family into families + family = "shot" + + # form label + label = asset + if asset != clip_name: + label += " ({}) ".format(clip_name) + label += " {}".format(subset) + label += " [{}]".format(family) + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "subset": subset, + "asset": asset, + "family": family, + "families": [] + }) + + instance = context.create_instance(**data) + self.log.info("Creating instance: {}".format(instance)) + self.log.debug( + "_ instance.data: {}".format(pformat(instance.data))) + + # def get_otio_clip_instance_data(self, segment): + # """ + # Return otio objects for timeline, track and clip + + # Args: + # timeline_item_data (dict): timeline_item_data from list returned by + # resolve.get_current_timeline_items() + # otio_timeline (otio.schema.Timeline): otio object + + # Returns: + # dict: otio clip object + + # """ + # ti_track_name = segment.parent().name() + # timeline_range = self.create_otio_time_range_from_timeline_item_data( + # segment) + # for otio_clip in self.otio_timeline.each_clip(): + # track_name = otio_clip.parent().name + # parent_range = otio_clip.range_in_parent() + # if ti_track_name not in track_name: + # continue + # if otio_clip.name not in segment.name(): + # continue + # if openpype.lib.is_overlapping_otio_ranges( + # parent_range, timeline_range, strict=True): + + # # add pypedata marker to otio_clip metadata + # for marker in otio_clip.markers: + # if phiero.pype_tag_name in marker.name: + # otio_clip.metadata.update(marker.metadata) + # return {"otioClip": otio_clip} + + # return None + + # @staticmethod + # def create_otio_time_range_from_timeline_item_data(segment): + # speed = segment.playbackSpeed() + # timeline = phiero.get_current_sequence() + # frame_start = int(segment.timelineIn()) + # frame_duration = int(segment.sourceDuration() / speed) + # fps = timeline.framerate().toFloat() + + # return hiero_export.create_otio_time_range( + # frame_start, frame_duration, fps) From da1bb80b62d8e606e5c1b5bdc1fa0a53685c3fba Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 16:32:00 +0100 Subject: [PATCH 117/229] flame: fix selection --- openpype/hosts/flame/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index b204230d9a..e53127503b 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -445,7 +445,7 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue - if segment.hidden: + if segment.hidden.get_value() is True: continue if ( selected is True From 8f786f325541e5b8282eef515789333044727a8e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:01:39 +0100 Subject: [PATCH 118/229] flame: deactivating test plugin --- openpype/hosts/flame/plugins/publish/collect_test_selection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 73401368b1..9f982321cc 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -16,6 +16,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder label = "test selection" hosts = ["flame"] + active = False def process(self, context): self.log.info( From 28341de97f283f51043530b581fb7a34ffb6337a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jan 2022 17:03:03 +0100 Subject: [PATCH 119/229] flame: adding maintained selection to publish plugins --- .../plugins/publish/precollect_instances.py | 198 ++++++++---------- .../plugins/publish/precollect_workfile.py | 3 +- 2 files changed, 95 insertions(+), 106 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index 5f3b71eba4..e302bc42a4 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -21,126 +21,114 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.otio_timeline = context.data["otioTimeline"] self.clips_in_reels = opfapi.get_clips_in_reels(project) - # return only actually selected and enabled segments - selected_segments = opfapi.get_sequence_segments(sequence, True) + # process all sellected + with opfapi.maintained_segment_selection(sequence) as selected_segments: + for segment in selected_segments: + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) - # only return enabled segments - if not selected_segments: - selected_segments = opfapi.get_sequence_segments( - sequence) + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format(pformat(marker_data))) - self.log.info( - "Processing following segments: {}".format( - [s.name for s in selected_segments])) + if not marker_data: + continue - # process all sellected timeline track items - for segment in selected_segments: + if marker_data.get("id") != "pyblish.avalon.instance": + continue - clip_data = opfapi.get_segment_attributes(segment) - clip_name = clip_data["segment_name"] - self.log.debug("clip_name: {}".format(clip_name)) + file_path = clip_data["fpath"] + first_frame = opfapi.get_frame_from_path(file_path) or 0 - # get openpype tag data - marker_data = opfapi.get_segment_data_marker(segment) - self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") - if not marker_data: - continue - - if marker_data.get("id") != "pyblish.avalon.instance": - continue - - file_path = clip_data["fpath"] - first_frame = opfapi.get_frame_from_path(file_path) or 0 - - # calculate head and tail with forward compatibility - head = clip_data.get("segment_head") - tail = clip_data.get("segment_tail") - - if not head: - head = int(clip_data["source_in"]) - int(first_frame) - if not tail: - tail = int( - clip_data["source_duration"] - ( - head + clip_data["record_duration"] + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) ) - ) - # solve handles length - marker_data["handleStart"] = min( - marker_data["handleStart"], head) - marker_data["handleEnd"] = min( - marker_data["handleEnd"], tail) + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) - # add audio to families - with_audio = False - if marker_data.pop("audio"): - with_audio = True + # add audio to families + with_audio = False + if marker_data.pop("audio"): + with_audio = True - # add tag data to instance data - data = { - k: v for k, v in marker_data.items() - if k not in ("id", "applieswhole", "label") - } - - asset = marker_data["asset"] - subset = marker_data["subset"] - - # insert family into families - family = marker_data["family"] - families = [str(f) for f in marker_data["families"]] - families.insert(0, str(family)) - - # form label - label = asset - if asset != clip_name: - label += " ({})".format(clip_name) - label += " {}".format(subset) - label += " {}".format("[" + ", ".join(families) + "]") - - data.update({ - "name": "{}_{}".format(asset, subset), - "label": label, - "asset": asset, - "item": segment, - "families": families, - "publish": marker_data["publish"], - "fps": context.data["fps"], - }) - - # # otio clip data - # otio_data = self.get_otio_clip_instance_data(segment) or {} - # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - # data.update(otio_data) - # self.log.debug("__ data: {}".format(pformat(data))) - - # # add resolution - # self.get_resolution_to_data(data, context) - - # create instance - instance = context.create_instance(**data) - - # add colorspace data - instance.data.update({ - "versionData": { - "colorspace": clip_data["colour_space"], + # add tag data to instance data + data = { + k: v for k, v in marker_data.items() + if k not in ("id", "applieswhole", "label") } - }) - # create shot instance for shot attributes create/update - self.create_shot_instance(context, clip_name, **data) + asset = marker_data["asset"] + subset = marker_data["subset"] - self.log.info("Creating instance: {}".format(instance)) - self.log.info( - "_ instance.data: {}".format(pformat(instance.data))) + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) - if not with_audio: - continue + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") - # add audioReview attribute to plate instance data - # if reviewTrack is on - if marker_data.get("reviewTrack") is not None: - instance.data["reviewAudio"] = True + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": context.data["fps"], + }) + + # # otio clip data + # otio_data = self.get_otio_clip_instance_data(segment) or {} + # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # data.update(otio_data) + # self.log.debug("__ data: {}".format(pformat(data))) + + # # add resolution + # self.get_resolution_to_data(data, context) + + # create instance + instance = context.create_instance(**data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self.create_shot_instance(context, clip_name, **data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True def get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" diff --git a/openpype/hosts/flame/plugins/publish/precollect_workfile.py b/openpype/hosts/flame/plugins/publish/precollect_workfile.py index e7383ddec8..aff85e22e6 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/flame/plugins/publish/precollect_workfile.py @@ -17,7 +17,8 @@ class PrecollecTimelineOCIO(pyblish.api.ContextPlugin): sequence = opfapi.get_current_sequence(opfapi.CTX.selection) # adding otio timeline to context - otio_timeline = flame_export.create_otio_timeline(sequence) + with opfapi.maintained_segment_selection(sequence): + otio_timeline = flame_export.create_otio_timeline(sequence) instance_data = { "name": "{}_{}".format(asset, subset), From dbf9c6899632c3ec0ed11da1a1d0e17d35a70dc4 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 12 Jan 2022 03:43:49 +0000 Subject: [PATCH 120/229] [Automated] Bump version --- CHANGELOG.md | 13 ++++++++++--- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ab087690..e92c16dc5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.8.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) @@ -10,23 +10,29 @@ **πŸš€ Enhancements** +- Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) +- TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) - Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) - Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) +- Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) - Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) - Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) - Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) - Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) +- Fix \#2453 Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) +- Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) +- Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) - Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) **πŸ› Bug fixes** - General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) +- General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) - Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) - General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) -- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) **Merged pull requests:** @@ -63,7 +69,6 @@ - Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375) - Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365) - Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356) -- TVPaint: Move implementation to OpenPype [\#2336](https://github.com/pypeclub/OpenPype/pull/2336) **πŸ› Bug fixes** @@ -80,7 +85,9 @@ - hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379) - hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378) - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) +- Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) +- Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359) - Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) **Merged pull requests:** diff --git a/openpype/version.py b/openpype/version.py index ed0a96d4de..1f005d6952 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.0-nightly.2" +__version__ = "3.8.0-nightly.3" diff --git a/pyproject.toml b/pyproject.toml index 0ef447e0be..f9155f05a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.0-nightly.2" # OpenPype +version = "3.8.0-nightly.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 95be2c3bc8fc5a91ac2f65072fd56a6a30cda872 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 11:16:50 +0100 Subject: [PATCH 121/229] flame: adding pathsep instead of ";" --- openpype/hosts/flame/hooks/pre_flame_setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index d5ddafde0c..fe8acda257 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -85,7 +85,7 @@ class FlamePrelaunch(PreLaunchHook): pythonpath = self.launch_context.env.get("PYTHONPATH") # separate it explicity by `;` that is what we use in settings - new_pythonpath = self.flame_pythonpath.split(";") + new_pythonpath = self.flame_pythonpath.split(os.pathsep) new_pythonpath += pythonpath.split(os.pathsep) self.launch_context.env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) From e4368e69b1088ea3345932b9109a20a5c0d83de7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 12:25:50 +0100 Subject: [PATCH 122/229] moved nuke implementation from avalon --- openpype/hosts/nuke/api/__init__.py | 164 ++--- openpype/hosts/nuke/api/actions.py | 5 +- openpype/hosts/nuke/api/command.py | 135 ++++ openpype/hosts/nuke/api/lib.py | 616 ++++++++++++++++-- openpype/hosts/nuke/api/menu.py | 166 ----- openpype/hosts/nuke/api/pipeline.py | 421 ++++++++++++ openpype/hosts/nuke/api/plugin.py | 67 +- openpype/hosts/nuke/api/utils.py | 5 +- openpype/hosts/nuke/api/workio.py | 55 ++ .../nuke/plugins/create/create_backdrop.py | 15 +- .../nuke/plugins/create/create_camera.py | 12 +- .../hosts/nuke/plugins/create/create_gizmo.py | 26 +- .../hosts/nuke/plugins/create/create_model.py | 12 +- .../hosts/nuke/plugins/create/create_read.py | 15 +- .../plugins/create/create_write_prerender.py | 11 +- .../plugins/create/create_write_render.py | 11 +- .../nuke/plugins/create/create_write_still.py | 11 +- .../plugins/inventory/repair_old_loaders.py | 9 +- .../plugins/inventory/select_containers.py | 4 +- .../hosts/nuke/plugins/load/load_backdrop.py | 40 +- .../nuke/plugins/load/load_camera_abc.py | 18 +- openpype/hosts/nuke/plugins/load/load_clip.py | 13 +- .../hosts/nuke/plugins/load/load_effects.py | 17 +- .../nuke/plugins/load/load_effects_ip.py | 17 +- .../hosts/nuke/plugins/load/load_gizmo.py | 23 +- .../hosts/nuke/plugins/load/load_gizmo_ip.py | 31 +- .../hosts/nuke/plugins/load/load_image.py | 17 +- .../hosts/nuke/plugins/load/load_model.py | 15 +- .../nuke/plugins/load/load_script_precomp.py | 17 +- .../nuke/plugins/publish/extract_backdrop.py | 25 +- .../nuke/plugins/publish/extract_camera.py | 10 +- .../nuke/plugins/publish/extract_gizmo.py | 20 +- .../nuke/plugins/publish/extract_model.py | 13 +- .../plugins/publish/extract_ouput_node.py | 2 +- .../publish/extract_review_data_lut.py | 6 +- .../publish/extract_review_data_mov.py | 6 +- .../plugins/publish/extract_slate_frame.py | 4 +- .../nuke/plugins/publish/extract_thumbnail.py | 4 +- .../plugins/publish/precollect_instances.py | 9 +- .../plugins/publish/precollect_workfile.py | 15 +- .../nuke/plugins/publish/validate_backdrop.py | 6 +- .../nuke/plugins/publish/validate_gizmo.py | 6 +- .../publish/validate_instance_in_context.py | 13 +- .../plugins/publish/validate_write_legacy.py | 5 +- .../plugins/publish/validate_write_nodes.py | 15 +- openpype/hosts/nuke/startup/init.py | 2 + openpype/hosts/nuke/startup/menu.py | 15 +- 47 files changed, 1581 insertions(+), 563 deletions(-) create mode 100644 openpype/hosts/nuke/api/command.py delete mode 100644 openpype/hosts/nuke/api/menu.py create mode 100644 openpype/hosts/nuke/api/pipeline.py create mode 100644 openpype/hosts/nuke/api/workio.py diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 1567189ed1..d3b7f74d6d 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -1,130 +1,52 @@ -import os -import nuke +from .workio import ( + file_extensions, + has_unsaved_changes, + save_file, + open_file, + current_file, + work_root, +) -import avalon.api -import pyblish.api -import openpype -from . import lib, menu +from .command import ( + reset_frame_range, + get_handles, + reset_resolution, + viewer_update_and_undo_stop +) -log = openpype.api.Logger().get_logger(__name__) +from .plugin import OpenPypeCreator +from .pipeline import ( + install, + uninstall, -AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.nuke.__file__)) -PLUGINS_DIR = os.path.join(HOST_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") + ls, + + containerise, + parse_container, + update_container, +) -# registering pyblish gui regarding settings in presets -if os.getenv("PYBLISH_GUI", None): - pyblish.api.register_gui(os.getenv("PYBLISH_GUI", None)) +__all__ = ( + "file_extensions", + "has_unsaved_changes", + "save_file", + "open_file", + "current_file", + "work_root", + "reset_frame_range", + "get_handles", + "reset_resolution", + "viewer_update_and_undo_stop", -def reload_config(): - """Attempt to reload pipeline at run-time. + "OpenPypeCreator", + "install", + "uninstall", - CAUTION: This is primarily for development and debugging purposes. + "ls", - """ - - import importlib - - for module in ( - "{}.api".format(AVALON_CONFIG), - "{}.hosts.nuke.api.actions".format(AVALON_CONFIG), - "{}.hosts.nuke.api.menu".format(AVALON_CONFIG), - "{}.hosts.nuke.api.plugin".format(AVALON_CONFIG), - "{}.hosts.nuke.api.lib".format(AVALON_CONFIG), - ): - log.info("Reloading module: {}...".format(module)) - - module = importlib.import_module(module) - - try: - importlib.reload(module) - except AttributeError as e: - from importlib import reload - log.warning("Cannot reload module: {}".format(e)) - reload(module) - - -def install(): - ''' Installing all requarements for Nuke host - ''' - - # remove all registred callbacks form avalon.nuke - from avalon import pipeline - pipeline._registered_event_handlers.clear() - - log.info("Registering Nuke plug-ins..") - pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) - avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) - - # Register Avalon event for workfiles loading. - avalon.api.on("workio.open_file", lib.check_inventory_versions) - avalon.api.on("taskChanged", menu.change_context_label) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled) - workfile_settings = lib.WorkfileSettings() - # Disable all families except for the ones we explicitly want to see - family_states = [ - "write", - "review", - "nukenodes", - "model", - "gizmo" - ] - - avalon.api.data["familiesStateDefault"] = False - avalon.api.data["familiesStateToggled"] = family_states - - # Set context settings. - nuke.addOnCreate(workfile_settings.set_context_settings, nodeClass="Root") - nuke.addOnCreate(workfile_settings.set_favorites, nodeClass="Root") - nuke.addOnCreate(lib.process_workfile_builder, nodeClass="Root") - nuke.addOnCreate(lib.launch_workfiles_app, nodeClass="Root") - menu.install() - - -def uninstall(): - '''Uninstalling host's integration - ''' - log.info("Deregistering Nuke plug-ins..") - pyblish.api.deregister_plugin_path(PUBLISH_PATH) - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) - - pyblish.api.deregister_callback( - "instanceToggled", on_pyblish_instance_toggled) - - reload_config() - menu.uninstall() - - -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle node passthrough states on instance toggles.""" - - log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( - instance, old_value, new_value)) - - from avalon.nuke import ( - viewer_update_and_undo_stop, - add_publish_knob - ) - - # Whether instances should be passthrough based on new value - - with viewer_update_and_undo_stop(): - n = instance[0] - try: - n["publish"].value() - except ValueError: - n = add_publish_knob(n) - log.info(" `Publish` knob was added to write node..") - - n["publish"].setValue(new_value) + "containerise", + "parse_container", + "update_container", +) diff --git a/openpype/hosts/nuke/api/actions.py b/openpype/hosts/nuke/api/actions.py index fd18c787c4..c4a6f0fb84 100644 --- a/openpype/hosts/nuke/api/actions.py +++ b/openpype/hosts/nuke/api/actions.py @@ -1,12 +1,11 @@ import pyblish.api -from avalon.nuke.lib import ( +from openpype.api import get_errored_instances_from_context +from .lib import ( reset_selection, select_nodes ) -from openpype.api import get_errored_instances_from_context - class SelectInvalidAction(pyblish.api.Action): """Select invalid nodes in Nuke when plug-in failed. diff --git a/openpype/hosts/nuke/api/command.py b/openpype/hosts/nuke/api/command.py new file mode 100644 index 0000000000..212d4757c6 --- /dev/null +++ b/openpype/hosts/nuke/api/command.py @@ -0,0 +1,135 @@ +import logging +import contextlib +import nuke + +from avalon import api, io + + +log = logging.getLogger(__name__) + + +def reset_frame_range(): + """ Set frame range to current asset + Also it will set a Viewer range with + displayed handles + """ + + fps = float(api.Session.get("AVALON_FPS", 25)) + + nuke.root()["fps"].setValue(fps) + name = api.Session["AVALON_ASSET"] + asset = io.find_one({"name": name, "type": "asset"}) + asset_data = asset["data"] + + handles = get_handles(asset) + + frame_start = int(asset_data.get( + "frameStart", + asset_data.get("edit_in"))) + + frame_end = int(asset_data.get( + "frameEnd", + asset_data.get("edit_out"))) + + if not all([frame_start, frame_end]): + missing = ", ".join(["frame_start", "frame_end"]) + msg = "'{}' are not set for asset '{}'!".format(missing, name) + log.warning(msg) + nuke.message(msg) + return + + frame_start -= handles + frame_end += handles + + nuke.root()["first_frame"].setValue(frame_start) + nuke.root()["last_frame"].setValue(frame_end) + + # setting active viewers + vv = nuke.activeViewer().node() + vv["frame_range_lock"].setValue(True) + vv["frame_range"].setValue("{0}-{1}".format( + int(asset_data["frameStart"]), + int(asset_data["frameEnd"])) + ) + + +def get_handles(asset): + """ Gets handles data + + Arguments: + asset (dict): avalon asset entity + + Returns: + handles (int) + """ + data = asset["data"] + if "handles" in data and data["handles"] is not None: + return int(data["handles"]) + + parent_asset = None + if "visualParent" in data: + vp = data["visualParent"] + if vp is not None: + parent_asset = io.find_one({"_id": io.ObjectId(vp)}) + + if parent_asset is None: + parent_asset = io.find_one({"_id": io.ObjectId(asset["parent"])}) + + if parent_asset is not None: + return get_handles(parent_asset) + else: + return 0 + + +def reset_resolution(): + """Set resolution to project resolution.""" + project = io.find_one({"type": "project"}) + p_data = project["data"] + + width = p_data.get("resolution_width", + p_data.get("resolutionWidth")) + height = p_data.get("resolution_height", + p_data.get("resolutionHeight")) + + if not all([width, height]): + missing = ", ".join(["width", "height"]) + msg = "No resolution information `{0}` found for '{1}'.".format( + missing, + project["name"]) + log.warning(msg) + nuke.message(msg) + return + + current_width = nuke.root()["format"].value().width() + current_height = nuke.root()["format"].value().height() + + if width != current_width or height != current_height: + + fmt = None + for f in nuke.formats(): + if f.width() == width and f.height() == height: + fmt = f.name() + + if not fmt: + nuke.addFormat( + "{0} {1} {2}".format(int(width), int(height), project["name"]) + ) + fmt = project["name"] + + nuke.root()["format"].setValue(fmt) + + +@contextlib.contextmanager +def viewer_update_and_undo_stop(): + """Lock viewer from updating and stop recording undo steps""" + try: + # stop active viewer to update any change + viewer = nuke.activeViewer() + if viewer: + viewer.stop() + else: + log.warning("No available active Viewer") + nuke.Undo.disable() + yield + finally: + nuke.Undo.enable() diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index e36a5aa5ba..0508de9f1d 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3,15 +3,15 @@ import re import sys import six import platform +import contextlib from collections import OrderedDict +import clique + +import nuke from avalon import api, io, lib -import avalon.nuke -from avalon.nuke import lib as anlib -from avalon.nuke import ( - save_file, open_file -) + from openpype.api import ( Logger, Anatomy, @@ -28,21 +28,476 @@ from openpype.lib.path_tools import HostDirmap from openpype.settings import get_project_settings from openpype.modules import ModulesManager -import nuke +from .workio import ( + save_file, + open_file +) -from .utils import set_context_favorites +log = Logger.get_logger(__name__) -log = Logger().get_logger(__name__) +_NODE_TAB_NAME = "{}".format(os.getenv("AVALON_LABEL") or "Avalon") +AVALON_LABEL = os.getenv("AVALON_LABEL") or "Avalon" +AVALON_TAB = "{}".format(AVALON_LABEL) +AVALON_DATA_GROUP = "{}DataGroup".format(AVALON_LABEL.capitalize()) +EXCLUDED_KNOB_TYPE_ON_READ = ( + 20, # Tab Knob + 26, # Text Knob (But for backward compatibility, still be read + # if value is not an empty string.) +) -opnl = sys.modules[__name__] -opnl._project = None -opnl.project_name = os.getenv("AVALON_PROJECT") -opnl.workfiles_launched = False -opnl._node_tab_name = "{}".format(os.getenv("AVALON_LABEL") or "Avalon") + +class Context: + main_window = None + context_label = None + project_name = os.getenv("AVALON_PROJECT") + workfiles_launched = False + # Seems unused + _project_doc = None + + +class Knobby(object): + """For creating knob which it's type isn't mapped in `create_knobs` + + Args: + type (string): Nuke knob type name + value: Value to be set with `Knob.setValue`, put `None` if not required + flags (list, optional): Knob flags to be set with `Knob.setFlag` + *args: Args other than knob name for initializing knob class + + """ + + def __init__(self, type, value, flags=None, *args): + self.type = type + self.value = value + self.flags = flags or [] + self.args = args + + def create(self, name, nice=None): + knob_cls = getattr(nuke, self.type) + knob = knob_cls(name, nice, *self.args) + if self.value is not None: + knob.setValue(self.value) + for flag in self.flags: + knob.setFlag(flag) + return knob + + +def create_knobs(data, tab=None): + """Create knobs by data + + Depending on the type of each dict value and creates the correct Knob. + + Mapped types: + bool: nuke.Boolean_Knob + int: nuke.Int_Knob + float: nuke.Double_Knob + list: nuke.Enumeration_Knob + six.string_types: nuke.String_Knob + + dict: If it's a nested dict (all values are dict), will turn into + A tabs group. Or just a knobs group. + + Args: + data (dict): collection of attributes and their value + tab (string, optional): Knobs' tab name + + Returns: + list: A list of `nuke.Knob` objects + + """ + def nice_naming(key): + """Convert camelCase name into UI Display Name""" + words = re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]) + return " ".join(words) + + # Turn key-value pairs into knobs + knobs = list() + + if tab: + knobs.append(nuke.Tab_Knob(tab)) + + for key, value in data.items(): + # Knob name + if isinstance(key, tuple): + name, nice = key + else: + name, nice = key, nice_naming(key) + + # Create knob by value type + if isinstance(value, Knobby): + knobby = value + knob = knobby.create(name, nice) + + elif isinstance(value, float): + knob = nuke.Double_Knob(name, nice) + knob.setValue(value) + + elif isinstance(value, bool): + knob = nuke.Boolean_Knob(name, nice) + knob.setValue(value) + knob.setFlag(nuke.STARTLINE) + + elif isinstance(value, int): + knob = nuke.Int_Knob(name, nice) + knob.setValue(value) + + elif isinstance(value, six.string_types): + knob = nuke.String_Knob(name, nice) + knob.setValue(value) + + elif isinstance(value, list): + knob = nuke.Enumeration_Knob(name, nice, value) + + elif isinstance(value, dict): + if all(isinstance(v, dict) for v in value.values()): + # Create a group of tabs + begain = nuke.BeginTabGroup_Knob() + end = nuke.EndTabGroup_Knob() + begain.setName(name) + end.setName(name + "_End") + knobs.append(begain) + for k, v in value.items(): + knobs += create_knobs(v, tab=k) + knobs.append(end) + else: + # Create a group of knobs + knobs.append(nuke.Tab_Knob( + name, nice, nuke.TABBEGINCLOSEDGROUP)) + knobs += create_knobs(value) + knobs.append( + nuke.Tab_Knob(name + "_End", nice, nuke.TABENDGROUP)) + continue + + else: + raise TypeError("Unsupported type: %r" % type(value)) + + knobs.append(knob) + + return knobs + + +def imprint(node, data, tab=None): + """Store attributes with value on node + + Parse user data into Node knobs. + Use `collections.OrderedDict` to ensure knob order. + + Args: + node(nuke.Node): node object from Nuke + data(dict): collection of attributes and their value + + Returns: + None + + Examples: + ``` + import nuke + from avalon.nuke import lib + + node = nuke.createNode("NoOp") + data = { + # Regular type of attributes + "myList": ["x", "y", "z"], + "myBool": True, + "myFloat": 0.1, + "myInt": 5, + + # Creating non-default imprint type of knob + "MyFilePath": lib.Knobby("File_Knob", "/file/path"), + "divider": lib.Knobby("Text_Knob", ""), + + # Manual nice knob naming + ("my_knob", "Nice Knob Name"): "some text", + + # dict type will be created as knob group + "KnobGroup": { + "knob1": 5, + "knob2": "hello", + "knob3": ["a", "b"], + }, + + # Nested dict will be created as tab group + "TabGroup": { + "tab1": {"count": 5}, + "tab2": {"isGood": True}, + "tab3": {"direction": ["Left", "Right"]}, + }, + } + lib.imprint(node, data, tab="Demo") + + ``` + + """ + for knob in create_knobs(data, tab): + node.addKnob(knob) + + +def add_publish_knob(node): + """Add Publish knob to node + + Arguments: + node (nuke.Node): nuke node to be processed + + Returns: + node (nuke.Node): processed nuke node + + """ + if "publish" not in node.knobs(): + body = OrderedDict() + body[("divd", "Publishing")] = Knobby("Text_Knob", '') + body["publish"] = True + imprint(node, body) + return node + + +def set_avalon_knob_data(node, data=None, prefix="avalon:"): + """ Sets data into nodes's avalon knob + + Arguments: + node (nuke.Node): Nuke node to imprint with data, + data (dict, optional): Data to be imprinted into AvalonTab + prefix (str, optional): filtering prefix + + Returns: + node (nuke.Node) + + Examples: + data = { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + } + """ + data = data or dict() + create = OrderedDict() + + tab_name = AVALON_TAB + editable = ["asset", "subset", "name", "namespace"] + + existed_knobs = node.knobs() + + for key, value in data.items(): + knob_name = prefix + key + gui_name = key + + if knob_name in existed_knobs: + # Set value + try: + node[knob_name].setValue(value) + except TypeError: + node[knob_name].setValue(str(value)) + else: + # New knob + name = (knob_name, gui_name) # Hide prefix on GUI + if key in editable: + create[name] = value + else: + create[name] = Knobby("String_Knob", + str(value), + flags=[nuke.READ_ONLY]) + if tab_name in existed_knobs: + tab_name = None + else: + tab = OrderedDict() + warn = Knobby("Text_Knob", "Warning! Do not change following data!") + divd = Knobby("Text_Knob", "") + head = [ + (("warn", ""), warn), + (("divd", ""), divd), + ] + tab[AVALON_DATA_GROUP] = OrderedDict(head + list(create.items())) + create = tab + + imprint(node, create, tab=tab_name) + return node + + +def get_avalon_knob_data(node, prefix="avalon:"): + """ Gets a data from nodes's avalon knob + + Arguments: + node (obj): Nuke node to search for data, + prefix (str, optional): filtering prefix + + Returns: + data (dict) + """ + + # check if lists + if not isinstance(prefix, list): + prefix = list([prefix]) + + data = dict() + + # loop prefix + for p in prefix: + # check if the node is avalon tracked + if AVALON_TAB not in node.knobs(): + continue + try: + # check if data available on the node + test = node[AVALON_DATA_GROUP].value() + log.debug("Only testing if data avalable: `{}`".format(test)) + except NameError as e: + # if it doesn't then create it + log.debug("Creating avalon knob: `{}`".format(e)) + node = set_avalon_knob_data(node) + return get_avalon_knob_data(node) + + # get data from filtered knobs + data.update({k.replace(p, ''): node[k].value() + for k in node.knobs().keys() + if p in k}) + + return data + + +def fix_data_for_node_create(data): + """Fixing data to be used for nuke knobs + """ + for k, v in data.items(): + if isinstance(v, six.text_type): + data[k] = str(v) + if str(v).startswith("0x"): + data[k] = int(v, 16) + return data + + +def add_write_node(name, **kwarg): + """Adding nuke write node + + Arguments: + name (str): nuke node name + kwarg (attrs): data for nuke knobs + + Returns: + node (obj): nuke write node + """ + frame_range = kwarg.get("frame_range", None) + + w = nuke.createNode( + "Write", + "name {}".format(name)) + + w["file"].setValue(kwarg["file"]) + + for k, v in kwarg.items(): + if "frame_range" in k: + continue + log.info([k, v]) + try: + w[k].setValue(v) + except KeyError as e: + log.debug(e) + continue + + if frame_range: + w["use_limit"].setValue(True) + w["first"].setValue(frame_range[0]) + w["last"].setValue(frame_range[1]) + + return w + + +def read(node): + """Return user-defined knobs from given `node` + + Args: + node (nuke.Node): Nuke node object + + Returns: + list: A list of nuke.Knob object + + """ + def compat_prefixed(knob_name): + if knob_name.startswith("avalon:"): + return knob_name[len("avalon:"):] + elif knob_name.startswith("ak:"): + return knob_name[len("ak:"):] + else: + return knob_name + + data = dict() + + pattern = ("(?<=addUserKnob {)" + "([0-9]*) (\\S*)" # Matching knob type and knob name + "(?=[ |}])") + tcl_script = node.writeKnobs(nuke.WRITE_USER_KNOB_DEFS) + result = re.search(pattern, tcl_script) + + if result: + first_user_knob = result.group(2) + # Collect user knobs from the end of the knob list + for knob in reversed(node.allKnobs()): + knob_name = knob.name() + if not knob_name: + # Ignore unnamed knob + continue + + knob_type = nuke.knob(knob.fullyQualifiedName(), type=True) + value = knob.value() + + if ( + knob_type not in EXCLUDED_KNOB_TYPE_ON_READ or + # For compating read-only string data that imprinted + # by `nuke.Text_Knob`. + (knob_type == 26 and value) + ): + key = compat_prefixed(knob_name) + data[key] = value + + if knob_name == first_user_knob: + break + + return data + + +def get_node_path(path, padding=4): + """Get filename for the Nuke write with padded number as '#' + + Arguments: + path (str): The path to render to. + + Returns: + tuple: head, padding, tail (extension) + + Examples: + >>> get_frame_path("test.exr") + ('test', 4, '.exr') + + >>> get_frame_path("filename.#####.tif") + ('filename.', 5, '.tif') + + >>> get_frame_path("foobar##.tif") + ('foobar', 2, '.tif') + + >>> get_frame_path("foobar_%08d.tif") + ('foobar_', 8, '.tif') + """ + filename, ext = os.path.splitext(path) + + # Find a final number group + if '%' in filename: + match = re.match('.*?(%[0-9]+d)$', filename) + if match: + padding = int(match.group(1).replace('%', '').replace('d', '')) + # remove number from end since fusion + # will swap it with the frame number + filename = filename.replace(match.group(1), '') + elif '#' in filename: + match = re.match('.*?(#+)$', filename) + + if match: + padding = len(match.group(1)) + # remove number from end since fusion + # will swap it with the frame number + filename = filename.replace(match.group(1), '') + + return filename, padding, ext def get_nuke_imageio_settings(): - return get_anatomy_settings(opnl.project_name)["imageio"]["nuke"] + return get_anatomy_settings(Context.project_name)["imageio"]["nuke"] def get_created_node_imageio_setting(**kwarg): @@ -103,14 +558,15 @@ def check_inventory_versions(): and check if the node is having actual version. If not then it will color it to red. """ + from .pipeline import parse_container + # get all Loader nodes by avalon attribute metadata for each in nuke.allNodes(): - container = avalon.nuke.parse_container(each) + container = parse_container(each) if container: node = nuke.toNode(container["objectName"]) - avalon_knob_data = avalon.nuke.read( - node) + avalon_knob_data = read(node) # get representation from io representation = io.find_one({ @@ -163,11 +619,10 @@ def writes_version_sync(): for each in nuke.allNodes(filter="Write"): # check if the node is avalon tracked - if opnl._node_tab_name not in each.knobs(): + if _NODE_TAB_NAME not in each.knobs(): continue - avalon_knob_data = avalon.nuke.read( - each) + avalon_knob_data = read(each) try: if avalon_knob_data['families'] not in ["render"]: @@ -209,14 +664,14 @@ def check_subsetname_exists(nodes, subset_name): bool: True of False """ return next((True for n in nodes - if subset_name in avalon.nuke.read(n).get("subset", "")), + if subset_name in read(n).get("subset", "")), False) def get_render_path(node): ''' Generate Render path from presets regarding avalon knob data ''' - data = {'avalon': avalon.nuke.read(node)} + data = {'avalon': read(node)} data_preset = { "nodeclass": data['avalon']['family'], "families": [data['avalon']['families']], @@ -385,7 +840,7 @@ def create_write_node(name, data, input=None, prenodes=None, for knob in imageio_writes["knobs"]: _data.update({knob["name"]: knob["value"]}) - _data = anlib.fix_data_for_node_create(_data) + _data = fix_data_for_node_create(_data) log.debug("_data: `{}`".format(_data)) @@ -466,7 +921,7 @@ def create_write_node(name, data, input=None, prenodes=None, prev_node = now_node # creating write node - write_node = now_node = anlib.add_write_node( + write_node = now_node = add_write_node( "inside_{}".format(name), **_data ) @@ -484,8 +939,8 @@ def create_write_node(name, data, input=None, prenodes=None, now_node.setInput(0, prev_node) # imprinting group node - anlib.set_avalon_knob_data(GN, data["avalon"]) - anlib.add_publish_knob(GN) + set_avalon_knob_data(GN, data["avalon"]) + add_publish_knob(GN) add_rendering_knobs(GN, farm) if review: @@ -537,7 +992,7 @@ def create_write_node(name, data, input=None, prenodes=None, add_deadline_tab(GN) # open the our Tab as default - GN[opnl._node_tab_name].setFlag(0) + GN[_NODE_TAB_NAME].setFlag(0) # set tile color tile_color = _data.get("tile_color", "0xff0000ff") @@ -663,7 +1118,7 @@ class WorkfileSettings(object): root_node=None, nodes=None, **kwargs): - opnl._project = kwargs.get( + Context._project_doc = kwargs.get( "project") or io.find_one({"type": "project"}) self._asset = kwargs.get("asset_name") or api.Session["AVALON_ASSET"] self._asset_entity = get_asset(self._asset) @@ -804,8 +1259,6 @@ class WorkfileSettings(object): ''' Adds correct colorspace to write node dict ''' - from avalon.nuke import read - for node in nuke.allNodes(filter="Group"): # get data from avalon knob @@ -1005,7 +1458,7 @@ class WorkfileSettings(object): node['frame_range_lock'].setValue(True) # adding handle_start/end to root avalon knob - if not anlib.set_avalon_knob_data(self._root_node, { + if not set_avalon_knob_data(self._root_node, { "handleStart": int(handle_start), "handleEnd": int(handle_end) }): @@ -1089,6 +1542,8 @@ class WorkfileSettings(object): self.set_colorspace() def set_favorites(self): + from .utils import set_context_favorites + work_dir = os.getenv("AVALON_WORKDIR") asset = os.getenv("AVALON_ASSET") favorite_items = OrderedDict() @@ -1096,9 +1551,9 @@ class WorkfileSettings(object): # project # get project's root and split to parts projects_root = os.path.normpath(work_dir.split( - opnl.project_name)[0]) + Context.project_name)[0]) # add project name - project_dir = os.path.join(projects_root, opnl.project_name) + "/" + project_dir = os.path.join(projects_root, Context.project_name) + "/" # add to favorites favorite_items.update({"Project dir": project_dir.replace("\\", "/")}) @@ -1145,8 +1600,7 @@ def get_write_node_template_attr(node): ''' # get avalon data from node data = dict() - data['avalon'] = avalon.nuke.read( - node) + data['avalon'] = read(node) data_preset = { "nodeclass": data['avalon']['family'], "families": [data['avalon']['families']], @@ -1167,7 +1621,7 @@ def get_write_node_template_attr(node): if k not in ["_id", "_previous"]} # fix badly encoded data - return anlib.fix_data_for_node_create(correct_data) + return fix_data_for_node_create(correct_data) def get_dependent_nodes(nodes): @@ -1274,13 +1728,53 @@ def find_free_space_to_paste_nodes( return xpos, ypos +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context + + Example: + >>> with maintained_selection(): + ... node['selected'].setValue(True) + >>> print(node['selected'].value()) + False + """ + previous_selection = nuke.selectedNodes() + try: + yield + finally: + # unselect all selection in case there is some + current_seletion = nuke.selectedNodes() + [n['selected'].setValue(False) for n in current_seletion] + # and select all previously selected nodes + if previous_selection: + [n['selected'].setValue(True) for n in previous_selection] + + +def reset_selection(): + """Deselect all selected nodes""" + for node in nuke.selectedNodes(): + node["selected"].setValue(False) + + +def select_nodes(nodes): + """Selects all inputed nodes + + Arguments: + nodes (list): nuke nodes to be selected + """ + assert isinstance(nodes, (list, tuple)), "nodes has to be list or tuple" + + for node in nodes: + node["selected"].setValue(True) + + def launch_workfiles_app(): '''Function letting start workfiles after start of host ''' from openpype.lib import ( env_value_to_bool ) - from avalon.nuke.pipeline import get_main_window + from .pipeline import get_main_window # get all imortant settings open_at_start = env_value_to_bool( @@ -1291,8 +1785,8 @@ def launch_workfiles_app(): if not open_at_start: return - if not opnl.workfiles_launched: - opnl.workfiles_launched = True + if not Context.workfiles_launched: + Context.workfiles_launched = True main_window = get_main_window() host_tools.show_workfiles(parent=main_window) @@ -1378,7 +1872,7 @@ def recreate_instance(origin_node, avalon_data=None): knobs_wl = ["render", "publish", "review", "ypos", "use_limit", "first", "last"] # get data from avalon knobs - data = anlib.get_avalon_knob_data( + data = get_avalon_knob_data( origin_node) # add input data to avalon data @@ -1494,3 +1988,45 @@ def dirmap_file_name_filter(file_name): if os.path.exists(dirmap_processor.file_name): return dirmap_processor.file_name return file_name + + +# ------------------------------------ +# This function seems to be deprecated +# ------------------------------------ +def ls_img_sequence(path): + """Listing all available coherent image sequence from path + + Arguments: + path (str): A nuke's node object + + Returns: + data (dict): with nuke formated path and frameranges + """ + file = os.path.basename(path) + dirpath = os.path.dirname(path) + base, ext = os.path.splitext(file) + name, padding = os.path.splitext(base) + + # populate list of files + files = [ + f for f in os.listdir(dirpath) + if name in f + if ext in f + ] + + # create collection from list of files + collections, reminder = clique.assemble(files) + + if len(collections) > 0: + head = collections[0].format("{head}") + padding = collections[0].format("{padding}") % 1 + padding = "#" * len(padding) + tail = collections[0].format("{tail}") + file = head + padding + tail + + return { + "path": os.path.join(dirpath, file).replace("\\", "/"), + "frames": collections[0].format("[{ranges}]") + } + + return False diff --git a/openpype/hosts/nuke/api/menu.py b/openpype/hosts/nuke/api/menu.py deleted file mode 100644 index 86293edb99..0000000000 --- a/openpype/hosts/nuke/api/menu.py +++ /dev/null @@ -1,166 +0,0 @@ -import os -import nuke -from avalon.nuke.pipeline import get_main_window - -from .lib import WorkfileSettings -from openpype.api import Logger, BuildWorkfile, get_current_project_settings -from openpype.tools.utils import host_tools - - -log = Logger().get_logger(__name__) - -menu_label = os.environ["AVALON_LABEL"] -context_label = None - - -def change_context_label(*args): - global context_label - menubar = nuke.menu("Nuke") - menu = menubar.findItem(menu_label) - - label = "{0}, {1}".format( - os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] - ) - - rm_item = [ - (i, item) for i, item in enumerate(menu.items()) - if context_label in item.name() - ][0] - - menu.removeItem(rm_item[1].name()) - - context_action = menu.addCommand( - label, - index=(rm_item[0]) - ) - context_action.setEnabled(False) - - log.info("Task label changed from `{}` to `{}`".format( - context_label, label)) - - context_label = label - - - -def install(): - from openpype.hosts.nuke.api import reload_config - - global context_label - - # uninstall original avalon menu - uninstall() - - main_window = get_main_window() - menubar = nuke.menu("Nuke") - menu = menubar.addMenu(menu_label) - - label = "{0}, {1}".format( - os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] - ) - context_label = label - context_action = menu.addCommand(label) - context_action.setEnabled(False) - - menu.addSeparator() - menu.addCommand( - "Work Files...", - lambda: host_tools.show_workfiles(parent=main_window) - ) - - menu.addSeparator() - menu.addCommand( - "Create...", - lambda: host_tools.show_creator(parent=main_window) - ) - menu.addCommand( - "Load...", - lambda: host_tools.show_loader( - parent=main_window, - use_context=True - ) - ) - menu.addCommand( - "Publish...", - lambda: host_tools.show_publish(parent=main_window) - ) - menu.addCommand( - "Manage...", - lambda: host_tools.show_scene_inventory(parent=main_window) - ) - - menu.addSeparator() - menu.addCommand( - "Set Resolution", - lambda: WorkfileSettings().reset_resolution() - ) - menu.addCommand( - "Set Frame Range", - lambda: WorkfileSettings().reset_frame_range_handles() - ) - menu.addCommand( - "Set Colorspace", - lambda: WorkfileSettings().set_colorspace() - ) - menu.addCommand( - "Apply All Settings", - lambda: WorkfileSettings().set_context_settings() - ) - - menu.addSeparator() - menu.addCommand( - "Build Workfile", - lambda: BuildWorkfile().process() - ) - - menu.addSeparator() - menu.addCommand( - "Experimental tools...", - lambda: host_tools.show_experimental_tools_dialog(parent=main_window) - ) - - # add reload pipeline only in debug mode - if bool(os.getenv("NUKE_DEBUG")): - menu.addSeparator() - menu.addCommand("Reload Pipeline", reload_config) - - # adding shortcuts - add_shortcuts_from_presets() - - -def uninstall(): - - menubar = nuke.menu("Nuke") - menu = menubar.findItem(menu_label) - - for item in menu.items(): - log.info("Removing menu item: {}".format(item.name())) - menu.removeItem(item.name()) - - -def add_shortcuts_from_presets(): - menubar = nuke.menu("Nuke") - nuke_presets = get_current_project_settings()["nuke"]["general"] - - if nuke_presets.get("menu"): - menu_label_mapping = { - "manage": "Manage...", - "create": "Create...", - "load": "Load...", - "build_workfile": "Build Workfile", - "publish": "Publish..." - } - - for command_name, shortcut_str in nuke_presets.get("menu").items(): - log.info("menu_name `{}` | menu_label `{}`".format( - command_name, menu_label - )) - log.info("Adding Shortcut `{}` to `{}`".format( - shortcut_str, command_name - )) - try: - menu = menubar.findItem(menu_label) - item_label = menu_label_mapping[command_name] - menuitem = menu.findItem(item_label) - menuitem.setShortcut(shortcut_str) - except AttributeError as e: - log.error(e) diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py new file mode 100644 index 0000000000..c47187666b --- /dev/null +++ b/openpype/hosts/nuke/api/pipeline.py @@ -0,0 +1,421 @@ +import os +import importlib +from collections import OrderedDict + +import nuke + +import pyblish.api +import avalon.api +from avalon import pipeline + +import openpype +from openpype.api import ( + Logger, + BuildWorkfile, + get_current_project_settings +) +from openpype.tools.utils import host_tools + +from .command import viewer_update_and_undo_stop +from .lib import ( + add_publish_knob, + WorkfileSettings, + process_workfile_builder, + launch_workfiles_app, + check_inventory_versions, + set_avalon_knob_data, + read, + Context +) + +log = Logger.get_logger(__name__) + +AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.nuke.__file__)) +PLUGINS_DIR = os.path.join(HOST_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") + +MENU_LABEL = os.environ["AVALON_LABEL"] + + +# registering pyblish gui regarding settings in presets +if os.getenv("PYBLISH_GUI", None): + pyblish.api.register_gui(os.getenv("PYBLISH_GUI", None)) + + +def get_main_window(): + """Acquire Nuke's main window""" + if Context.main_window is None: + from Qt import QtWidgets + + top_widgets = QtWidgets.QApplication.topLevelWidgets() + name = "Foundry::UI::DockMainWindow" + for widget in top_widgets: + if ( + widget.inherits("QMainWindow") + and widget.metaObject().className() == name + ): + Context.main_window = widget + break + return Context.main_window + + +def reload_config(): + """Attempt to reload pipeline at run-time. + + CAUTION: This is primarily for development and debugging purposes. + + """ + + for module in ( + "{}.api".format(AVALON_CONFIG), + "{}.hosts.nuke.api.actions".format(AVALON_CONFIG), + "{}.hosts.nuke.api.menu".format(AVALON_CONFIG), + "{}.hosts.nuke.api.plugin".format(AVALON_CONFIG), + "{}.hosts.nuke.api.lib".format(AVALON_CONFIG), + ): + log.info("Reloading module: {}...".format(module)) + + module = importlib.import_module(module) + + try: + importlib.reload(module) + except AttributeError as e: + from importlib import reload + log.warning("Cannot reload module: {}".format(e)) + reload(module) + + +def install(): + ''' Installing all requarements for Nuke host + ''' + + pyblish.api.register_host("nuke") + + log.info("Registering Nuke plug-ins..") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) + + # Register Avalon event for workfiles loading. + avalon.api.on("workio.open_file", check_inventory_versions) + avalon.api.on("taskChanged", change_context_label) + + pyblish.api.register_callback( + "instanceToggled", on_pyblish_instance_toggled) + workfile_settings = WorkfileSettings() + # Disable all families except for the ones we explicitly want to see + family_states = [ + "write", + "review", + "nukenodes", + "model", + "gizmo" + ] + + avalon.api.data["familiesStateDefault"] = False + avalon.api.data["familiesStateToggled"] = family_states + + # Set context settings. + nuke.addOnCreate(workfile_settings.set_context_settings, nodeClass="Root") + nuke.addOnCreate(workfile_settings.set_favorites, nodeClass="Root") + nuke.addOnCreate(process_workfile_builder, nodeClass="Root") + nuke.addOnCreate(launch_workfiles_app, nodeClass="Root") + _install_menu() + + +def uninstall(): + '''Uninstalling host's integration + ''' + log.info("Deregistering Nuke plug-ins..") + pyblish.deregister_host("nuke") + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + + pyblish.api.deregister_callback( + "instanceToggled", on_pyblish_instance_toggled) + + reload_config() + _uninstall_menu() + + +def _install_menu(): + # uninstall original avalon menu + main_window = get_main_window() + menubar = nuke.menu("Nuke") + menu = menubar.addMenu(MENU_LABEL) + + label = "{0}, {1}".format( + os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] + ) + Context.context_label = label + context_action = menu.addCommand(label) + context_action.setEnabled(False) + + menu.addSeparator() + menu.addCommand( + "Work Files...", + lambda: host_tools.show_workfiles(parent=main_window) + ) + + menu.addSeparator() + menu.addCommand( + "Create...", + lambda: host_tools.show_creator(parent=main_window) + ) + menu.addCommand( + "Load...", + lambda: host_tools.show_loader( + parent=main_window, + use_context=True + ) + ) + menu.addCommand( + "Publish...", + lambda: host_tools.show_publish(parent=main_window) + ) + menu.addCommand( + "Manage...", + lambda: host_tools.show_scene_inventory(parent=main_window) + ) + + menu.addSeparator() + menu.addCommand( + "Set Resolution", + lambda: WorkfileSettings().reset_resolution() + ) + menu.addCommand( + "Set Frame Range", + lambda: WorkfileSettings().reset_frame_range_handles() + ) + menu.addCommand( + "Set Colorspace", + lambda: WorkfileSettings().set_colorspace() + ) + menu.addCommand( + "Apply All Settings", + lambda: WorkfileSettings().set_context_settings() + ) + + menu.addSeparator() + menu.addCommand( + "Build Workfile", + lambda: BuildWorkfile().process() + ) + + menu.addSeparator() + menu.addCommand( + "Experimental tools...", + lambda: host_tools.show_experimental_tools_dialog(parent=main_window) + ) + + # add reload pipeline only in debug mode + if bool(os.getenv("NUKE_DEBUG")): + menu.addSeparator() + menu.addCommand("Reload Pipeline", reload_config) + + # adding shortcuts + add_shortcuts_from_presets() + + +def _uninstall_menu(): + menubar = nuke.menu("Nuke") + menu = menubar.findItem(MENU_LABEL) + + for item in menu.items(): + log.info("Removing menu item: {}".format(item.name())) + menu.removeItem(item.name()) + + +def change_context_label(*args): + menubar = nuke.menu("Nuke") + menu = menubar.findItem(MENU_LABEL) + + label = "{0}, {1}".format( + os.environ["AVALON_ASSET"], os.environ["AVALON_TASK"] + ) + + rm_item = [ + (i, item) for i, item in enumerate(menu.items()) + if Context.context_label in item.name() + ][0] + + menu.removeItem(rm_item[1].name()) + + context_action = menu.addCommand( + label, + index=(rm_item[0]) + ) + context_action.setEnabled(False) + + log.info("Task label changed from `{}` to `{}`".format( + Context.context_label, label)) + + +def add_shortcuts_from_presets(): + menubar = nuke.menu("Nuke") + nuke_presets = get_current_project_settings()["nuke"]["general"] + + if nuke_presets.get("menu"): + menu_label_mapping = { + "manage": "Manage...", + "create": "Create...", + "load": "Load...", + "build_workfile": "Build Workfile", + "publish": "Publish..." + } + + for command_name, shortcut_str in nuke_presets.get("menu").items(): + log.info("menu_name `{}` | menu_label `{}`".format( + command_name, MENU_LABEL + )) + log.info("Adding Shortcut `{}` to `{}`".format( + shortcut_str, command_name + )) + try: + menu = menubar.findItem(MENU_LABEL) + item_label = menu_label_mapping[command_name] + menuitem = menu.findItem(item_label) + menuitem.setShortcut(shortcut_str) + except AttributeError as e: + log.error(e) + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle node passthrough states on instance toggles.""" + + log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( + instance, old_value, new_value)) + + # Whether instances should be passthrough based on new value + + with viewer_update_and_undo_stop(): + n = instance[0] + try: + n["publish"].value() + except ValueError: + n = add_publish_knob(n) + log.info(" `Publish` knob was added to write node..") + + n["publish"].setValue(new_value) + + +def containerise(node, + name, + namespace, + context, + loader=None, + data=None): + """Bundle `node` into an assembly and imprint it with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + node (nuke.Node): Nuke's node object to imprint as container + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + context (dict): Asset information + loader (str, optional): Name of node used to produce this container. + + Returns: + node (nuke.Node): containerised nuke's node object + + """ + data = OrderedDict( + [ + ("schema", "openpype:container-2.0"), + ("id", pipeline.AVALON_CONTAINER_ID), + ("name", name), + ("namespace", namespace), + ("loader", str(loader)), + ("representation", context["representation"]["_id"]), + ], + + **data or dict() + ) + + set_avalon_knob_data(node, data) + + return node + + +def parse_container(node): + """Returns containerised data of a node + + Reads the imprinted data from `containerise`. + + Arguments: + node (nuke.Node): Nuke's node object to read imprinted data + + Returns: + dict: The container schema data for this container node. + + """ + data = read(node) + + # (TODO) Remove key validation when `ls` has re-implemented. + # + # If not all required data return the empty container + required = ["schema", "id", "name", + "namespace", "loader", "representation"] + if not all(key in data for key in required): + return + + # Store the node's name + data["objectName"] = node["name"].value() + + return data + + +def update_container(node, keys=None): + """Returns node with updateted containder data + + Arguments: + node (nuke.Node): The node in Nuke to imprint as container, + keys (dict, optional): data which should be updated + + Returns: + node (nuke.Node): nuke node with updated container data + + Raises: + TypeError on given an invalid container node + + """ + keys = keys or dict() + + container = parse_container(node) + if not container: + raise TypeError("Not a valid container node.") + + container.update(keys) + node = set_avalon_knob_data(node, container) + + return node + + +def ls(): + """List available containers. + + This function is used by the Container Manager in Nuke. You'll + need to implement a for-loop that then *yields* one Container at + a time. + + See the `container.json` schema for details on how it should look, + and the Maya equivalent, which is in `avalon.maya.pipeline` + """ + all_nodes = nuke.allNodes(recurseGroups=False) + + # TODO: add readgeo, readcamera, readimage + nodes = [n for n in all_nodes] + + for n in nodes: + log.debug("name: `{}`".format(n.name())) + container = parse_container(n) + if container: + yield container diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 82299dd354..66b42f7bb1 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -2,23 +2,30 @@ import os import random import string -import avalon.nuke -from avalon.nuke import lib as anlib -from avalon import api +import nuke + +import avalon.api from openpype.api import ( get_current_project_settings, PypeCreatorMixin ) -from .lib import check_subsetname_exists -import nuke +from .lib import ( + Knobby, + check_subsetname_exists, + reset_selection, + maintained_selection, + set_avalon_knob_data, + add_publish_knob +) -class PypeCreator(PypeCreatorMixin, avalon.nuke.pipeline.Creator): - """Pype Nuke Creator class wrapper - """ +class OpenPypeCreator(PypeCreatorMixin, avalon.api.Creator): + """Pype Nuke Creator class wrapper""" + node_color = "0xdfea5dff" + def __init__(self, *args, **kwargs): - super(PypeCreator, self).__init__(*args, **kwargs) + super(OpenPypeCreator, self).__init__(*args, **kwargs) self.presets = get_current_project_settings()["nuke"]["create"].get( self.__class__.__name__, {} ) @@ -31,6 +38,38 @@ class PypeCreator(PypeCreatorMixin, avalon.nuke.pipeline.Creator): raise NameError("`{0}: {1}".format(__name__, msg)) return + def process(self): + from nukescripts import autoBackdrop + + instance = None + + if (self.options or {}).get("useSelection"): + + nodes = nuke.selectedNodes() + if not nodes: + nuke.message("Please select nodes that you " + "wish to add to a container") + return + + elif len(nodes) == 1: + # only one node is selected + instance = nodes[0] + + if not instance: + # Not using selection or multiple nodes selected + bckd_node = autoBackdrop() + bckd_node["tile_color"].setValue(int(self.node_color, 16)) + bckd_node["note_font_size"].setValue(24) + bckd_node["label"].setValue("[{}]".format(self.name)) + + instance = bckd_node + + # add avalon knobs + set_avalon_knob_data(instance, self.data) + add_publish_knob(instance) + + return instance + def get_review_presets_config(): settings = get_current_project_settings() @@ -48,7 +87,7 @@ def get_review_presets_config(): return [str(name) for name, _prop in outputs.items()] -class NukeLoader(api.Loader): +class NukeLoader(avalon.api.Loader): container_id_knob = "containerId" container_id = None @@ -74,7 +113,7 @@ class NukeLoader(api.Loader): node[self.container_id_knob].setValue(source_id) else: HIDEN_FLAG = 0x00040000 - _knob = anlib.Knobby( + _knob = Knobby( "String_Knob", self.container_id, flags=[ @@ -183,7 +222,7 @@ class ExporterReview(object): Returns: nuke.Node: copy node of Input Process node """ - anlib.reset_selection() + reset_selection() ipn_orig = None for v in nuke.allNodes(filter="Viewer"): ip = v["input_process"].getValue() @@ -196,7 +235,7 @@ class ExporterReview(object): # copy selected to clipboard nuke.nodeCopy("%clipboard%") # reset selection - anlib.reset_selection() + reset_selection() # paste node and selection is on it only nuke.nodePaste("%clipboard%") # assign to variable @@ -396,7 +435,7 @@ class ExporterReviewMov(ExporterReview): def save_file(self): import shutil - with anlib.maintained_selection(): + with maintained_selection(): self.log.info("Saving nodes as file... ") # create nk path path = os.path.splitext(self.path)[0] + ".nk" diff --git a/openpype/hosts/nuke/api/utils.py b/openpype/hosts/nuke/api/utils.py index e43c11a380..f8f248357b 100644 --- a/openpype/hosts/nuke/api/utils.py +++ b/openpype/hosts/nuke/api/utils.py @@ -1,7 +1,8 @@ import os import nuke -from avalon.nuke import lib as anlib + from openpype.api import resources +from .lib import maintained_selection def set_context_favorites(favorites=None): @@ -55,7 +56,7 @@ def bake_gizmos_recursively(in_group=nuke.Root()): is_group (nuke.Node)[optonal]: group node or all nodes """ # preserve selection after all is done - with anlib.maintained_selection(): + with maintained_selection(): # jump to the group with in_group: for node in nuke.allNodes(): diff --git a/openpype/hosts/nuke/api/workio.py b/openpype/hosts/nuke/api/workio.py new file mode 100644 index 0000000000..dbc24fdc9b --- /dev/null +++ b/openpype/hosts/nuke/api/workio.py @@ -0,0 +1,55 @@ +"""Host API required Work Files tool""" +import os +import nuke +import avalon.api + + +def file_extensions(): + return avalon.api.HOST_WORKFILE_EXTENSIONS["nuke"] + + +def has_unsaved_changes(): + return nuke.root().modified() + + +def save_file(filepath): + path = filepath.replace("\\", "/") + nuke.scriptSaveAs(path) + nuke.Root()["name"].setValue(path) + nuke.Root()["project_directory"].setValue(os.path.dirname(path)) + nuke.Root().setModified(False) + + +def open_file(filepath): + filepath = filepath.replace("\\", "/") + + # To remain in the same window, we have to clear the script and read + # in the contents of the workfile. + nuke.scriptClear() + nuke.scriptReadFile(filepath) + nuke.Root()["name"].setValue(filepath) + nuke.Root()["project_directory"].setValue(os.path.dirname(filepath)) + nuke.Root().setModified(False) + return True + + +def current_file(): + current_file = nuke.root().name() + + # Unsaved current file + if current_file == 'Root': + return None + + return os.path.normpath(current_file).replace("\\", "/") + + +def work_root(session): + + work_dir = session["AVALON_WORKDIR"] + scene_dir = session.get("AVALON_SCENEDIR") + if scene_dir: + path = os.path.join(work_dir, scene_dir) + else: + path = work_dir + + return os.path.normpath(path).replace("\\", "/") diff --git a/openpype/hosts/nuke/plugins/create/create_backdrop.py b/openpype/hosts/nuke/plugins/create/create_backdrop.py index cda2629587..0c11b3f274 100644 --- a/openpype/hosts/nuke/plugins/create/create_backdrop.py +++ b/openpype/hosts/nuke/plugins/create/create_backdrop.py @@ -1,9 +1,12 @@ -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + select_nodes, + set_avalon_knob_data +) -class CreateBackdrop(plugin.PypeCreator): +class CreateBackdrop(plugin.OpenPypeCreator): """Add Publishable Backdrop""" name = "nukenodes" @@ -25,14 +28,14 @@ class CreateBackdrop(plugin.PypeCreator): nodes = self.nodes if len(nodes) >= 1: - anlib.select_nodes(nodes) + select_nodes(nodes) bckd_node = autoBackdrop() bckd_node["name"].setValue("{}_BDN".format(self.name)) bckd_node["tile_color"].setValue(int(self.node_color, 16)) bckd_node["note_font_size"].setValue(24) bckd_node["label"].setValue("[{}]".format(self.name)) # add avalon knobs - instance = anlib.set_avalon_knob_data(bckd_node, self.data) + instance = set_avalon_knob_data(bckd_node, self.data) return instance else: @@ -48,6 +51,6 @@ class CreateBackdrop(plugin.PypeCreator): bckd_node["note_font_size"].setValue(24) bckd_node["label"].setValue("[{}]".format(self.name)) # add avalon knobs - instance = anlib.set_avalon_knob_data(bckd_node, self.data) + instance = set_avalon_knob_data(bckd_node, self.data) return instance diff --git a/openpype/hosts/nuke/plugins/create/create_camera.py b/openpype/hosts/nuke/plugins/create/create_camera.py index 359086d48f..3b13c80dc4 100644 --- a/openpype/hosts/nuke/plugins/create/create_camera.py +++ b/openpype/hosts/nuke/plugins/create/create_camera.py @@ -1,9 +1,11 @@ -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + set_avalon_knob_data +) -class CreateCamera(plugin.PypeCreator): +class CreateCamera(plugin.OpenPypeCreator): """Add Publishable Backdrop""" name = "camera" @@ -36,7 +38,7 @@ class CreateCamera(plugin.PypeCreator): # change node color n["tile_color"].setValue(int(self.node_color, 16)) # add avalon knobs - anlib.set_avalon_knob_data(n, data) + set_avalon_knob_data(n, data) return True else: msg = str("Please select nodes you " @@ -49,5 +51,5 @@ class CreateCamera(plugin.PypeCreator): camera_node = nuke.createNode("Camera2") camera_node["tile_color"].setValue(int(self.node_color, 16)) # add avalon knobs - instance = anlib.set_avalon_knob_data(camera_node, self.data) + instance = set_avalon_knob_data(camera_node, self.data) return instance diff --git a/openpype/hosts/nuke/plugins/create/create_gizmo.py b/openpype/hosts/nuke/plugins/create/create_gizmo.py index c59713cff1..de73623a1e 100644 --- a/openpype/hosts/nuke/plugins/create/create_gizmo.py +++ b/openpype/hosts/nuke/plugins/create/create_gizmo.py @@ -1,9 +1,14 @@ -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + select_nodes, + set_avalon_knob_data +) -class CreateGizmo(plugin.PypeCreator): + +class CreateGizmo(plugin.OpenPypeCreator): """Add Publishable "gizmo" group The name is symbolically gizmo as presumably @@ -28,13 +33,13 @@ class CreateGizmo(plugin.PypeCreator): nodes = self.nodes self.log.info(len(nodes)) if len(nodes) == 1: - anlib.select_nodes(nodes) + select_nodes(nodes) node = nodes[-1] # check if Group node if node.Class() in "Group": node["name"].setValue("{}_GZM".format(self.name)) node["tile_color"].setValue(int(self.node_color, 16)) - return anlib.set_avalon_knob_data(node, self.data) + return set_avalon_knob_data(node, self.data) else: msg = ("Please select a group node " "you wish to publish as the gizmo") @@ -42,7 +47,7 @@ class CreateGizmo(plugin.PypeCreator): nuke.message(msg) if len(nodes) >= 2: - anlib.select_nodes(nodes) + select_nodes(nodes) nuke.makeGroup() gizmo_node = nuke.selectedNode() gizmo_node["name"].setValue("{}_GZM".format(self.name)) @@ -57,16 +62,15 @@ class CreateGizmo(plugin.PypeCreator): "- create User knobs on the group") # add avalon knobs - return anlib.set_avalon_knob_data(gizmo_node, self.data) + return set_avalon_knob_data(gizmo_node, self.data) else: - msg = ("Please select nodes you " - "wish to add to the gizmo") + msg = "Please select nodes you wish to add to the gizmo" self.log.error(msg) nuke.message(msg) return else: - with anlib.maintained_selection(): + with maintained_selection(): gizmo_node = nuke.createNode("Group") gizmo_node["name"].setValue("{}_GZM".format(self.name)) gizmo_node["tile_color"].setValue(int(self.node_color, 16)) @@ -80,4 +84,4 @@ class CreateGizmo(plugin.PypeCreator): "- create User knobs on the group") # add avalon knobs - return anlib.set_avalon_knob_data(gizmo_node, self.data) + return set_avalon_knob_data(gizmo_node, self.data) diff --git a/openpype/hosts/nuke/plugins/create/create_model.py b/openpype/hosts/nuke/plugins/create/create_model.py index 4e30860e05..15a4e3ab8a 100644 --- a/openpype/hosts/nuke/plugins/create/create_model.py +++ b/openpype/hosts/nuke/plugins/create/create_model.py @@ -1,9 +1,11 @@ -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + set_avalon_knob_data +) -class CreateModel(plugin.PypeCreator): +class CreateModel(plugin.OpenPypeCreator): """Add Publishable Model Geometry""" name = "model" @@ -68,7 +70,7 @@ class CreateModel(plugin.PypeCreator): # change node color n["tile_color"].setValue(int(self.node_color, 16)) # add avalon knobs - anlib.set_avalon_knob_data(n, data) + set_avalon_knob_data(n, data) return True else: msg = str("Please select nodes you " @@ -81,5 +83,5 @@ class CreateModel(plugin.PypeCreator): model_node = nuke.createNode("WriteGeo") model_node["tile_color"].setValue(int(self.node_color, 16)) # add avalon knobs - instance = anlib.set_avalon_knob_data(model_node, self.data) + instance = set_avalon_knob_data(model_node, self.data) return instance diff --git a/openpype/hosts/nuke/plugins/create/create_read.py b/openpype/hosts/nuke/plugins/create/create_read.py index bf5de23346..bdc67add42 100644 --- a/openpype/hosts/nuke/plugins/create/create_read.py +++ b/openpype/hosts/nuke/plugins/create/create_read.py @@ -1,13 +1,16 @@ from collections import OrderedDict -import avalon.api -import avalon.nuke -from openpype import api as pype -from openpype.hosts.nuke.api import plugin import nuke +import avalon.api +from openpype import api as pype +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import ( + set_avalon_knob_data +) -class CrateRead(plugin.PypeCreator): + +class CrateRead(plugin.OpenPypeCreator): # change this to template preset name = "ReadCopy" label = "Create Read Copy" @@ -45,7 +48,7 @@ class CrateRead(plugin.PypeCreator): continue avalon_data = self.data avalon_data['subset'] = "{}".format(self.name) - avalon.nuke.lib.set_avalon_knob_data(node, avalon_data) + set_avalon_knob_data(node, avalon_data) node['tile_color'].setValue(16744935) count_reads += 1 diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index 1b925014ad..3285e5f92d 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -1,11 +1,12 @@ from collections import OrderedDict -from openpype.hosts.nuke.api import ( - plugin, - lib) + import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import create_write_node -class CreateWritePrerender(plugin.PypeCreator): + +class CreateWritePrerender(plugin.OpenPypeCreator): # change this to template preset name = "WritePrerender" label = "Create Write Prerender" @@ -98,7 +99,7 @@ class CreateWritePrerender(plugin.PypeCreator): self.log.info("write_data: {}".format(write_data)) - write_node = lib.create_write_node( + write_node = create_write_node( self.data["subset"], write_data, input=selected_node, diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index 5f13fddf4e..a9c4b5341e 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -1,11 +1,12 @@ from collections import OrderedDict -from openpype.hosts.nuke.api import ( - plugin, - lib) + import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import create_write_node -class CreateWriteRender(plugin.PypeCreator): + +class CreateWriteRender(plugin.OpenPypeCreator): # change this to template preset name = "WriteRender" label = "Create Write Render" @@ -119,7 +120,7 @@ class CreateWriteRender(plugin.PypeCreator): } ] - write_node = lib.create_write_node( + write_node = create_write_node( self.data["subset"], write_data, input=selected_node, diff --git a/openpype/hosts/nuke/plugins/create/create_write_still.py b/openpype/hosts/nuke/plugins/create/create_write_still.py index eebb5613c3..0037b64ce3 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_still.py +++ b/openpype/hosts/nuke/plugins/create/create_write_still.py @@ -1,11 +1,12 @@ from collections import OrderedDict -from openpype.hosts.nuke.api import ( - plugin, - lib) + import nuke +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import create_write_node -class CreateWriteStill(plugin.PypeCreator): + +class CreateWriteStill(plugin.OpenPypeCreator): # change this to template preset name = "WriteStillFrame" label = "Create Write Still Image" @@ -108,7 +109,7 @@ class CreateWriteStill(plugin.PypeCreator): } ] - write_node = lib.create_write_node( + write_node = create_write_node( self.name, write_data, input=selected_node, diff --git a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py index e7ae51fa86..49405fd213 100644 --- a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py +++ b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py @@ -1,7 +1,6 @@ from avalon import api, style -from avalon.nuke import lib as anlib -from openpype.api import ( - Logger) +from openpype.api import Logger +from openpype.hosts.nuke.api.lib import set_avalon_knob_data class RepairOldLoaders(api.InventoryAction): @@ -10,7 +9,7 @@ class RepairOldLoaders(api.InventoryAction): icon = "gears" color = style.colors.alert - log = Logger().get_logger(__name__) + log = Logger.get_logger(__name__) def process(self, containers): import nuke @@ -34,4 +33,4 @@ class RepairOldLoaders(api.InventoryAction): }) node["name"].setValue(new_name) # get data from avalon knob - anlib.set_avalon_knob_data(node, cdata) + set_avalon_knob_data(node, cdata) diff --git a/openpype/hosts/nuke/plugins/inventory/select_containers.py b/openpype/hosts/nuke/plugins/inventory/select_containers.py index bd00983172..3f174b3562 100644 --- a/openpype/hosts/nuke/plugins/inventory/select_containers.py +++ b/openpype/hosts/nuke/plugins/inventory/select_containers.py @@ -1,4 +1,5 @@ from avalon import api +from openpype.hosts.nuke.api.commands import viewer_update_and_undo_stop class SelectContainers(api.InventoryAction): @@ -9,11 +10,10 @@ class SelectContainers(api.InventoryAction): def process(self, containers): import nuke - import avalon.nuke nodes = [nuke.toNode(i["objectName"]) for i in containers] - with avalon.nuke.viewer_update_and_undo_stop(): + with viewer_update_and_undo_stop(): # clear previous_selection [n['selected'].setValue(False) for n in nodes] # Select tool diff --git a/openpype/hosts/nuke/plugins/load/load_backdrop.py b/openpype/hosts/nuke/plugins/load/load_backdrop.py index 9148260e9e..a2bd458948 100644 --- a/openpype/hosts/nuke/plugins/load/load_backdrop.py +++ b/openpype/hosts/nuke/plugins/load/load_backdrop.py @@ -1,9 +1,18 @@ from avalon import api, style, io import nuke import nukescripts -from openpype.hosts.nuke.api import lib as pnlib -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container + +from openpype.hosts.nuke.api.lib import ( + find_free_space_to_paste_nodes, + maintained_selection, + reset_selection, + select_nodes, + get_avalon_knob_data, + set_avalon_knob_data +) +from openpype.hosts.nuke.api.commands import viewer_update_and_undo_stop +from openpype.hosts.nuke.api import containerise, update_container + class LoadBackdropNodes(api.Loader): """Loading Published Backdrop nodes (workfile, nukenodes)""" @@ -66,12 +75,12 @@ class LoadBackdropNodes(api.Loader): # Get mouse position n = nuke.createNode("NoOp") xcursor, ycursor = (n.xpos(), n.ypos()) - anlib.reset_selection() + reset_selection() nuke.delete(n) bdn_frame = 50 - with anlib.maintained_selection(): + with maintained_selection(): # add group from nk nuke.nodePaste(file) @@ -81,11 +90,13 @@ class LoadBackdropNodes(api.Loader): nodes = nuke.selectedNodes() # get pointer position in DAG - xpointer, ypointer = pnlib.find_free_space_to_paste_nodes(nodes, direction="right", offset=200+bdn_frame) + xpointer, ypointer = find_free_space_to_paste_nodes( + nodes, direction="right", offset=200 + bdn_frame + ) # reset position to all nodes and replace inputs and output for n in nodes: - anlib.reset_selection() + reset_selection() xpos = (n.xpos() - xcursor) + xpointer ypos = (n.ypos() - ycursor) + ypointer n.setXYpos(xpos, ypos) @@ -108,7 +119,7 @@ class LoadBackdropNodes(api.Loader): d.setInput(index, dot) # remove Input node - anlib.reset_selection() + reset_selection() nuke.delete(n) continue @@ -127,15 +138,15 @@ class LoadBackdropNodes(api.Loader): dot.setInput(0, dep) # remove Input node - anlib.reset_selection() + reset_selection() nuke.delete(n) continue else: new_nodes.append(n) # reselect nodes with new Dot instead of Inputs and Output - anlib.reset_selection() - anlib.select_nodes(new_nodes) + reset_selection() + select_nodes(new_nodes) # place on backdrop bdn = nukescripts.autoBackdrop() @@ -208,16 +219,16 @@ class LoadBackdropNodes(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): xpos = GN.xpos() ypos = GN.ypos() - avalon_data = anlib.get_avalon_knob_data(GN) + avalon_data = get_avalon_knob_data(GN) nuke.delete(GN) # add group from nk nuke.nodePaste(file) GN = nuke.selectedNode() - anlib.set_avalon_knob_data(GN, avalon_data) + set_avalon_knob_data(GN, avalon_data) GN.setXYpos(xpos, ypos) GN["name"].setValue(object_name) @@ -243,7 +254,6 @@ class LoadBackdropNodes(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index 377d60e84b..b9d4bb358f 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -1,8 +1,15 @@ -from avalon import api, io -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container import nuke +from avalon import api, io +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) +from openpype.hosts.nuke.api.lib import ( + maintained_selection +) + class AlembicCameraLoader(api.Loader): """ @@ -43,7 +50,7 @@ class AlembicCameraLoader(api.Loader): # getting file path file = self.fname.replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): camera_node = nuke.createNode( "Camera2", "name {} file {} read_from_file True".format( @@ -122,7 +129,7 @@ class AlembicCameraLoader(api.Loader): # getting file path file = api.get_representation_path(representation).replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): camera_node = nuke.toNode(object_name) camera_node['selected'].setValue(True) @@ -181,7 +188,6 @@ class AlembicCameraLoader(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 9ce72c0519..712cdf213f 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -3,13 +3,13 @@ from avalon.vendor import qargparse from avalon import api, io from openpype.hosts.nuke.api.lib import ( - get_imageio_input_colorspace + get_imageio_input_colorspace, + maintained_selection ) -from avalon.nuke import ( +from openpype.hosts.nuke.api import ( containerise, update_container, - viewer_update_and_undo_stop, - maintained_selection + viewer_update_and_undo_stop ) from openpype.hosts.nuke.api import plugin @@ -280,9 +280,6 @@ class LoadClip(plugin.NukeLoader): self.set_as_member(read_node) def remove(self, container): - - from avalon.nuke import viewer_update_and_undo_stop - read_node = nuke.toNode(container['objectName']) assert read_node.Class() == "Read", "Must be Read" @@ -378,4 +375,4 @@ class LoadClip(plugin.NukeLoader): "class_name": self.__class__.__name__ } - return self.node_name_template.format(**name_data) \ No newline at end of file + return self.node_name_template.format(**name_data) diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 8ba1b6b7c1..8b8867feba 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -1,7 +1,12 @@ -from avalon import api, style, io -import nuke import json from collections import OrderedDict +import nuke +from avalon import api, style, io +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadEffects(api.Loader): @@ -30,9 +35,6 @@ class LoadEffects(api.Loader): Returns: nuke node: containerised nuke node object """ - # import dependencies - from avalon.nuke import containerise - # get main variables version = context['version'] version_data = version.get("data", {}) @@ -138,10 +140,6 @@ class LoadEffects(api.Loader): inputs: """ - - from avalon.nuke import ( - update_container - ) # get main variables # Get version from io version = io.find_one({ @@ -338,7 +336,6 @@ class LoadEffects(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index d0cab26842..7948cbba9a 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -1,8 +1,15 @@ -from avalon import api, style, io -import nuke import json from collections import OrderedDict + +import nuke + +from avalon import api, style, io from openpype.hosts.nuke.api import lib +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadEffectsInputProcess(api.Loader): @@ -30,8 +37,6 @@ class LoadEffectsInputProcess(api.Loader): Returns: nuke node: containerised nuke node object """ - # import dependencies - from avalon.nuke import containerise # get main variables version = context['version'] @@ -142,9 +147,6 @@ class LoadEffectsInputProcess(api.Loader): """ - from avalon.nuke import ( - update_container - ) # get main variables # Get version from io version = io.find_one({ @@ -355,7 +357,6 @@ class LoadEffectsInputProcess(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index c6228b95f6..f549623b88 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -1,7 +1,15 @@ -from avalon import api, style, io import nuke -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container +from avalon import api, style, io +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + get_avalon_knob_data, + set_avalon_knob_data +) +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadGizmo(api.Loader): @@ -61,7 +69,7 @@ class LoadGizmo(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): # add group from nk nuke.nodePaste(file) @@ -122,16 +130,16 @@ class LoadGizmo(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): xpos = GN.xpos() ypos = GN.ypos() - avalon_data = anlib.get_avalon_knob_data(GN) + avalon_data = get_avalon_knob_data(GN) nuke.delete(GN) # add group from nk nuke.nodePaste(file) GN = nuke.selectedNode() - anlib.set_avalon_knob_data(GN, avalon_data) + set_avalon_knob_data(GN, avalon_data) GN.setXYpos(xpos, ypos) GN["name"].setValue(object_name) @@ -157,7 +165,6 @@ class LoadGizmo(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index 5ca101d6cb..4f17446673 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -1,8 +1,16 @@ from avalon import api, style, io import nuke -from openpype.hosts.nuke.api import lib as pnlib -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + create_backdrop, + get_avalon_knob_data, + set_avalon_knob_data +) +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadGizmoInputProcess(api.Loader): @@ -62,7 +70,7 @@ class LoadGizmoInputProcess(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): # add group from nk nuke.nodePaste(file) @@ -128,16 +136,16 @@ class LoadGizmoInputProcess(api.Loader): # just in case we are in group lets jump out of it nuke.endGroup() - with anlib.maintained_selection(): + with maintained_selection(): xpos = GN.xpos() ypos = GN.ypos() - avalon_data = anlib.get_avalon_knob_data(GN) + avalon_data = get_avalon_knob_data(GN) nuke.delete(GN) # add group from nk nuke.nodePaste(file) GN = nuke.selectedNode() - anlib.set_avalon_knob_data(GN, avalon_data) + set_avalon_knob_data(GN, avalon_data) GN.setXYpos(xpos, ypos) GN["name"].setValue(object_name) @@ -197,8 +205,12 @@ class LoadGizmoInputProcess(api.Loader): viewer["input_process_node"].setValue(group_node_name) # put backdrop under - pnlib.create_backdrop(label="Input Process", layer=2, - nodes=[viewer, group_node], color="0x7c7faaff") + create_backdrop( + label="Input Process", + layer=2, + nodes=[viewer, group_node], + color="0x7c7faaff" + ) return True @@ -234,7 +246,6 @@ class LoadGizmoInputProcess(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 02a5b55c18..427167ca98 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -7,6 +7,11 @@ from avalon import api, io from openpype.hosts.nuke.api.lib import ( get_imageio_input_colorspace ) +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LoadImage(api.Loader): @@ -46,10 +51,6 @@ class LoadImage(api.Loader): return cls.representations + cls._representations def load(self, context, name, namespace, options): - from avalon.nuke import ( - containerise, - viewer_update_and_undo_stop - ) self.log.info("__ options: `{}`".format(options)) frame_number = options.get("frame_number", 1) @@ -154,11 +155,6 @@ class LoadImage(api.Loader): inputs: """ - - from avalon.nuke import ( - update_container - ) - node = nuke.toNode(container["objectName"]) frame_number = node["first"].value() @@ -234,9 +230,6 @@ class LoadImage(api.Loader): self.log.info("udated to version: {}".format(version.get("name"))) def remove(self, container): - - from avalon.nuke import viewer_update_and_undo_stop - node = nuke.toNode(container['objectName']) assert node.Class() == "Read", "Must be Read" diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index 15fa4fa35c..8c8dc7f37d 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -1,7 +1,11 @@ -from avalon import api, io -from avalon.nuke import lib as anlib -from avalon.nuke import containerise, update_container import nuke +from avalon import api, io +from openpype.hosts.nuke.api.lib import maintained_selection +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class AlembicModelLoader(api.Loader): @@ -43,7 +47,7 @@ class AlembicModelLoader(api.Loader): # getting file path file = self.fname.replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): model_node = nuke.createNode( "ReadGeo2", "name {} file {} ".format( @@ -122,7 +126,7 @@ class AlembicModelLoader(api.Loader): # getting file path file = api.get_representation_path(representation).replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): model_node = nuke.toNode(object_name) model_node['selected'].setValue(True) @@ -181,7 +185,6 @@ class AlembicModelLoader(api.Loader): self.update(container, representation) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index 7444dd6e96..8489283e8c 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -1,6 +1,11 @@ -from avalon import api, style, io -from avalon.nuke import get_avalon_knob_data import nuke +from avalon import api, style, io +from openpype.hosts.nuke.api.lib import get_avalon_knob_data +from openpype.hosts.nuke.api import ( + containerise, + update_container, + viewer_update_and_undo_stop +) class LinkAsGroup(api.Loader): @@ -15,8 +20,6 @@ class LinkAsGroup(api.Loader): color = style.colors.alert def load(self, context, name, namespace, data): - - from avalon.nuke import containerise # for k, v in context.items(): # log.info("key: `{}`, value: {}\n".format(k, v)) version = context['version'] @@ -103,11 +106,6 @@ class LinkAsGroup(api.Loader): inputs: """ - - from avalon.nuke import ( - update_container - ) - node = nuke.toNode(container['objectName']) root = api.get_representation_path(representation).replace("\\", "/") @@ -155,7 +153,6 @@ class LinkAsGroup(api.Loader): self.log.info("udated to version: {}".format(version.get("name"))) def remove(self, container): - from avalon.nuke import viewer_update_and_undo_stop node = nuke.toNode(container['objectName']) with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py index 0747c15ea7..0a2df0898e 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py @@ -1,9 +1,16 @@ -import pyblish.api -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import lib as pnlib -import nuke import os + +import nuke + +import pyblish.api + import openpype +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + reset_selection, + select_nodes +) + class ExtractBackdropNode(openpype.api.Extractor): """Extracting content of backdrop nodes @@ -27,7 +34,7 @@ class ExtractBackdropNode(openpype.api.Extractor): path = os.path.join(stagingdir, filename) # maintain selection - with anlib.maintained_selection(): + with maintained_selection(): # all connections outside of backdrop connections_in = instance.data["nodeConnectionsIn"] connections_out = instance.data["nodeConnectionsOut"] @@ -44,7 +51,7 @@ class ExtractBackdropNode(openpype.api.Extractor): nodes.append(inpn) tmp_nodes.append(inpn) - anlib.reset_selection() + reset_selection() # connect output node for n, output in connections_out.items(): @@ -58,11 +65,11 @@ class ExtractBackdropNode(openpype.api.Extractor): opn.autoplace() nodes.append(opn) tmp_nodes.append(opn) - anlib.reset_selection() + reset_selection() # select nodes to copy - anlib.reset_selection() - anlib.select_nodes(nodes) + reset_selection() + select_nodes(nodes) # create tmp nk file # save file to the path nuke.nodeCopy(path) diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index bc50dac108..942cdc537d 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -1,10 +1,12 @@ -import nuke import os import math +from pprint import pformat + +import nuke + import pyblish.api import openpype.api -from avalon.nuke import lib as anlib -from pprint import pformat +from openpype.hosts.nuke.api.lib import maintained_selection class ExtractCamera(openpype.api.Extractor): @@ -52,7 +54,7 @@ class ExtractCamera(openpype.api.Extractor): filename = subset + ".{}".format(extension) file_path = os.path.join(staging_dir, filename).replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): # bake camera with axeses onto word coordinate XYZ rm_n = bakeCameraWithAxeses( nuke.toNode(instance.data["name"]), output_range) diff --git a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py index 78bf9c998d..2d5bfdeb5e 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py @@ -1,9 +1,15 @@ -import pyblish.api -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import utils as pnutils -import nuke import os +import nuke + +import pyblish.api + import openpype +from openpype.hosts.nuke.api import utils as pnutils +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + reset_selection, + select_nodes +) class ExtractGizmo(openpype.api.Extractor): @@ -26,17 +32,17 @@ class ExtractGizmo(openpype.api.Extractor): path = os.path.join(stagingdir, filename) # maintain selection - with anlib.maintained_selection(): + with maintained_selection(): orig_grpn_name = orig_grpn.name() tmp_grpn_name = orig_grpn_name + "_tmp" # select original group node - anlib.select_nodes([orig_grpn]) + select_nodes([orig_grpn]) # copy to clipboard nuke.nodeCopy("%clipboard%") # reset selection to none - anlib.reset_selection() + reset_selection() # paste clipboard nuke.nodePaste("%clipboard%") diff --git a/openpype/hosts/nuke/plugins/publish/extract_model.py b/openpype/hosts/nuke/plugins/publish/extract_model.py index 43214bf3e9..0375263338 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_model.py +++ b/openpype/hosts/nuke/plugins/publish/extract_model.py @@ -1,9 +1,12 @@ -import nuke import os +from pprint import pformat +import nuke import pyblish.api import openpype.api -from avalon.nuke import lib as anlib -from pprint import pformat +from openpype.hosts.nuke.api.lib import ( + maintained_selection, + select_nodes +) class ExtractModel(openpype.api.Extractor): @@ -49,9 +52,9 @@ class ExtractModel(openpype.api.Extractor): filename = subset + ".{}".format(extension) file_path = os.path.join(staging_dir, filename).replace("\\", "/") - with anlib.maintained_selection(): + with maintained_selection(): # select model node - anlib.select_nodes([model_node]) + select_nodes([model_node]) # create write geo node wg_n = nuke.createNode("WriteGeo") diff --git a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py index c3a6a3b167..e38927c3a7 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py +++ b/openpype/hosts/nuke/plugins/publish/extract_ouput_node.py @@ -1,6 +1,6 @@ import nuke import pyblish.api -from avalon.nuke import maintained_selection +from openpype.hosts.nuke.api.lib import maintained_selection class CreateOutputNode(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index 8ba746a3c4..4cf2fd7d9f 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -1,8 +1,8 @@ import os import pyblish.api -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import openpype +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import maintained_selection class ExtractReviewDataLut(openpype.api.Extractor): @@ -37,7 +37,7 @@ class ExtractReviewDataLut(openpype.api.Extractor): "StagingDir `{0}`...".format(instance.data["stagingDir"])) # generate data - with anlib.maintained_selection(): + with maintained_selection(): exporter = plugin.ExporterReviewLut( self, instance ) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 32962b57a6..13d23ffb9c 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -1,8 +1,8 @@ import os import pyblish.api -from avalon.nuke import lib as anlib -from openpype.hosts.nuke.api import plugin import openpype +from openpype.hosts.nuke.api import plugin +from openpype.hosts.nuke.api.lib import maintained_selection class ExtractReviewDataMov(openpype.api.Extractor): @@ -41,7 +41,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): self.log.info(self.outputs) # generate data - with anlib.maintained_selection(): + with maintained_selection(): generated_repres = [] for o_name, o_data in self.outputs.items(): f_families = o_data["filter"]["families"] diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index 0f68680742..50e5f995f4 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -1,8 +1,8 @@ import os import nuke -from avalon.nuke import lib as anlib import pyblish.api import openpype +from openpype.hosts.nuke.api.lib import maintained_selection class ExtractSlateFrame(openpype.api.Extractor): @@ -25,7 +25,7 @@ class ExtractSlateFrame(openpype.api.Extractor): else: self.viewer_lut_raw = False - with anlib.maintained_selection(): + with maintained_selection(): self.log.debug("instance: {}".format(instance)) self.log.debug("instance.data[families]: {}".format( instance.data["families"])) diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index 0c9af66435..ef6d486ca2 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -1,9 +1,9 @@ import sys import os import nuke -from avalon.nuke import lib as anlib import pyblish.api import openpype +from openpype.hosts.nuke.api.lib import maintained_selection if sys.version_info[0] >= 3: @@ -30,7 +30,7 @@ class ExtractThumbnail(openpype.api.Extractor): if "render.farm" in instance.data["families"]: return - with anlib.maintained_selection(): + with maintained_selection(): self.log.debug("instance: {}".format(instance)) self.log.debug("instance.data[families]: {}".format( instance.data["families"])) diff --git a/openpype/hosts/nuke/plugins/publish/precollect_instances.py b/openpype/hosts/nuke/plugins/publish/precollect_instances.py index 5c30df9a62..97ddef0a59 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_instances.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_instances.py @@ -1,7 +1,10 @@ import nuke import pyblish.api from avalon import io, api -from avalon.nuke import lib as anlib +from openpype.hosts.nuke.api.lib import ( + add_publish_knob, + get_avalon_knob_data +) @pyblish.api.log @@ -39,7 +42,7 @@ class PreCollectNukeInstances(pyblish.api.ContextPlugin): self.log.warning(E) # get data from avalon knob - avalon_knob_data = anlib.get_avalon_knob_data( + avalon_knob_data = get_avalon_knob_data( node, ["avalon:", "ak:"]) self.log.debug("avalon_knob_data: {}".format(avalon_knob_data)) @@ -115,7 +118,7 @@ class PreCollectNukeInstances(pyblish.api.ContextPlugin): # get publish knob value if "publish" not in node.knobs(): - anlib.add_publish_knob(node) + add_publish_knob(node) # sync workfile version _families_test = [family] + families diff --git a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py index 0e27273ceb..a2d1c80628 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py @@ -1,8 +1,13 @@ -import nuke -import pyblish.api import os + +import nuke + +import pyblish.api import openpype.api as pype -from avalon.nuke import lib as anlib +from openpype.hosts.nuke.api.lib import ( + add_publish_knob, + get_avalon_knob_data +) class CollectWorkfile(pyblish.api.ContextPlugin): @@ -17,9 +22,9 @@ class CollectWorkfile(pyblish.api.ContextPlugin): current_file = os.path.normpath(nuke.root().name()) - knob_data = anlib.get_avalon_knob_data(root) + knob_data = get_avalon_knob_data(root) - anlib.add_publish_knob(root) + add_publish_knob(root) family = "workfile" task = os.getenv("AVALON_TASK", None) diff --git a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py index f280ad4af1..7694c3d2ba 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/validate_backdrop.py @@ -1,6 +1,6 @@ -import pyblish -from avalon.nuke import lib as anlib import nuke +import pyblish +from openpype.hosts.nuke.api.lib import maintained_selection class SelectCenterInNodeGraph(pyblish.api.Action): @@ -28,7 +28,7 @@ class SelectCenterInNodeGraph(pyblish.api.Action): all_yC = list() # maintain selection - with anlib.maintained_selection(): + with maintained_selection(): # collect all failed nodes xpos and ypos for instance in instances: bdn = instance[0] diff --git a/openpype/hosts/nuke/plugins/publish/validate_gizmo.py b/openpype/hosts/nuke/plugins/publish/validate_gizmo.py index 9c94ea88ef..d0d930f50c 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/validate_gizmo.py @@ -1,6 +1,6 @@ -import pyblish -from avalon.nuke import lib as anlib import nuke +import pyblish +from openpype.hosts.nuke.api.lib import maintained_selection class OpenFailedGroupNode(pyblish.api.Action): @@ -25,7 +25,7 @@ class OpenFailedGroupNode(pyblish.api.Action): instances = pyblish.api.instances_by_plugin(failed, plugin) # maintain selection - with anlib.maintained_selection(): + with maintained_selection(): # collect all failed nodes xpos and ypos for instance in instances: grpn = instance[0] diff --git a/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py b/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py index ddf46a0873..842f74b6f6 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py +++ b/openpype/hosts/nuke/plugins/publish/validate_instance_in_context.py @@ -6,8 +6,11 @@ import nuke import pyblish.api import openpype.api -import avalon.nuke.lib -import openpype.hosts.nuke.api as nuke_api +from openpype.hosts.nuke.api.lib import ( + recreate_instance, + reset_selection, + select_nodes +) class SelectInvalidInstances(pyblish.api.Action): @@ -47,12 +50,12 @@ class SelectInvalidInstances(pyblish.api.Action): self.deselect() def select(self, instances): - avalon.nuke.lib.select_nodes( + select_nodes( [nuke.toNode(str(x)) for x in instances] ) def deselect(self): - avalon.nuke.lib.reset_selection() + reset_selection() class RepairSelectInvalidInstances(pyblish.api.Action): @@ -82,7 +85,7 @@ class RepairSelectInvalidInstances(pyblish.api.Action): context_asset = context.data["assetEntity"]["name"] for instance in instances: origin_node = instance[0] - nuke_api.lib.recreate_instance( + recreate_instance( origin_node, avalon_data={"asset": context_asset} ) diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py index ba34ec8338..a73bed8edd 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py @@ -1,13 +1,12 @@ -import toml import os +import toml import nuke from avalon import api -import re import pyblish.api import openpype.api -from avalon.nuke import get_avalon_knob_data +from openpype.hosts.nuke.api.lib import get_avalon_knob_data class ValidateWriteLegacy(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index 732f321b85..c0d5c8f402 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -1,8 +1,11 @@ import os import pyblish.api import openpype.utils -import openpype.hosts.nuke.lib as nukelib -import avalon.nuke +from openpype.hosts.nuke.api.lib import ( + get_write_node_template_attr, + get_node_path +) + @pyblish.api.log class RepairNukeWriteNodeAction(pyblish.api.Action): @@ -15,7 +18,7 @@ class RepairNukeWriteNodeAction(pyblish.api.Action): for instance in instances: node = instance[1] - correct_data = nukelib.get_write_node_template_attr(node) + correct_data = get_write_node_template_attr(node) for k, v in correct_data.items(): node[k].setValue(v) self.log.info("Node attributes were fixed") @@ -34,14 +37,14 @@ class ValidateNukeWriteNode(pyblish.api.InstancePlugin): def process(self, instance): node = instance[1] - correct_data = nukelib.get_write_node_template_attr(node) + correct_data = get_write_node_template_attr(node) check = [] for k, v in correct_data.items(): if k is 'file': padding = len(v.split('#')) - ref_path = avalon.nuke.lib.get_node_path(v, padding) - n_path = avalon.nuke.lib.get_node_path(node[k].value(), padding) + ref_path = get_node_path(v, padding) + n_path = get_node_path(node[k].value(), padding) isnt = False for i, p in enumerate(ref_path): if str(n_path[i]) not in str(p): diff --git a/openpype/hosts/nuke/startup/init.py b/openpype/hosts/nuke/startup/init.py index 0ea5d1ad7d..d7560814bf 100644 --- a/openpype/hosts/nuke/startup/init.py +++ b/openpype/hosts/nuke/startup/init.py @@ -1,2 +1,4 @@ +import nuke + # default write mov nuke.knobDefault('Write.mov.colorspace', 'sRGB') diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index b7ed35b3b4..2cac6d09e7 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,14 +1,19 @@ +import nuke +import avalon.api + +from openpype.api import Logger +from openpype.hosts.nuke import api from openpype.hosts.nuke.api.lib import ( on_script_load, check_inventory_versions, - WorkfileSettings + WorkfileSettings, + dirmap_file_name_filter ) -import nuke -from openpype.api import Logger -from openpype.hosts.nuke.api.lib import dirmap_file_name_filter +log = Logger.get_logger(__name__) -log = Logger().get_logger(__name__) + +avalon.api.install(api) # fix ffmpeg settings on script nuke.addOnScriptLoad(on_script_load) From 26d8304fd9704f04bd9ac076d193dc1646e4a38b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 12:27:09 +0100 Subject: [PATCH 123/229] removed avalon nuke path from add implementation environments --- openpype/hosts/nuke/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index 366f704dd8..60b37ce1dd 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -6,10 +6,7 @@ def add_implementation_envs(env, _app): # Add requirements to NUKE_PATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] new_nuke_paths = [ - os.path.join(pype_root, "openpype", "hosts", "nuke", "startup"), - os.path.join( - pype_root, "repos", "avalon-core", "setup", "nuke", "nuke_path" - ) + os.path.join(pype_root, "openpype", "hosts", "nuke", "startup") ] old_nuke_path = env.get("NUKE_PATH") or "" for path in old_nuke_path.split(os.pathsep): From 9980aa90fa196eb07e57ea7155b7ce98469d81e9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 12:32:21 +0100 Subject: [PATCH 124/229] fix default value of function argument --- openpype/hosts/nuke/api/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/utils.py b/openpype/hosts/nuke/api/utils.py index f8f248357b..205b23efe6 100644 --- a/openpype/hosts/nuke/api/utils.py +++ b/openpype/hosts/nuke/api/utils.py @@ -49,12 +49,14 @@ def gizmo_is_nuke_default(gizmo): return gizmo.filename().startswith(plug_dir) -def bake_gizmos_recursively(in_group=nuke.Root()): +def bake_gizmos_recursively(in_group=None): """Converting a gizmo to group Argumets: is_group (nuke.Node)[optonal]: group node or all nodes """ + if in_group is None: + in_group = nuke.Root() # preserve selection after all is done with maintained_selection(): # jump to the group From 197b2d33a672e4ece48bfe7b6f5b38076c2209bd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 13:30:49 +0100 Subject: [PATCH 125/229] flame: instance collector update --- .../plugins/publish/precollect_instances.py | 151 ++++++++++-------- 1 file changed, 88 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index e302bc42a4..fa007b3efd 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -1,6 +1,7 @@ import pyblish -# import openpype +import openpype import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export # # developer reload modules from pprint import pformat @@ -20,10 +21,11 @@ class PrecollectInstances(pyblish.api.ContextPlugin): sequence = context.data["flameSequence"] self.otio_timeline = context.data["otioTimeline"] self.clips_in_reels = opfapi.get_clips_in_reels(project) + self.fps = context.data["fps"] # process all sellected - with opfapi.maintained_segment_selection(sequence) as selected_segments: - for segment in selected_segments: + with opfapi.maintained_segment_selection(sequence) as segments: + for segment in segments: clip_data = opfapi.get_segment_attributes(segment) clip_name = clip_data["segment_name"] self.log.debug("clip_name: {}".format(clip_name)) @@ -38,21 +40,15 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if marker_data.get("id") != "pyblish.avalon.instance": continue + # get file path file_path = clip_data["fpath"] + + # get source clip + source_clip = self._get_reel_clip(file_path) + first_frame = opfapi.get_frame_from_path(file_path) or 0 - # calculate head and tail with forward compatibility - head = clip_data.get("segment_head") - tail = clip_data.get("segment_tail") - - if not head: - head = int(clip_data["source_in"]) - int(first_frame) - if not tail: - tail = int( - clip_data["source_duration"] - ( - head + clip_data["record_duration"] - ) - ) + head, tail = self._get_head_tail(clip_data, first_frame) # solve handles length marker_data["handleStart"] = min( @@ -93,17 +89,19 @@ class PrecollectInstances(pyblish.api.ContextPlugin): "item": segment, "families": families, "publish": marker_data["publish"], - "fps": context.data["fps"], + "fps": self.fps, + "flameSourceClip": source_clip, + "sourceFirstFrame": first_frame }) - # # otio clip data - # otio_data = self.get_otio_clip_instance_data(segment) or {} - # self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - # data.update(otio_data) - # self.log.debug("__ data: {}".format(pformat(data))) + # otio clip data + otio_data = self._get_otio_clip_instance_data(clip_data) or {} + self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + data.update(otio_data) + self.log.debug("__ data: {}".format(pformat(data))) - # # add resolution - # self.get_resolution_to_data(data, context) + # add resolution + self._get_resolution_to_data(data, context) # create instance instance = context.create_instance(**data) @@ -116,7 +114,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): }) # create shot instance for shot attributes create/update - self.create_shot_instance(context, clip_name, **data) + self._create_shot_instance(context, clip_name, **data) self.log.info("Creating instance: {}".format(instance)) self.log.info( @@ -130,7 +128,30 @@ class PrecollectInstances(pyblish.api.ContextPlugin): if marker_data.get("reviewTrack") is not None: instance.data["reviewAudio"] = True - def get_resolution_to_data(self, data, context): + def _get_head_tail(self, clip_data, first_frame): + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") + + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) + ) + return head, tail + + def _get_reel_clip(self, path): + match_reel_clip = [ + clip for clip in self.clips_in_reels + if clip["fpath"] == path + ] + if match_reel_clip: + return match_reel_clip.pop() + + def _get_resolution_to_data(self, data, context): assert data.get("otioClip"), "Missing `otioClip` data" # solve source resolution option @@ -155,7 +176,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): "openpype.timeline.pixelAspect"] }) - def create_shot_instance(self, context, clip_name, **data): + def _create_shot_instance(self, context, clip_name, **data): master_layer = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") asset = data.get("asset") @@ -193,47 +214,51 @@ class PrecollectInstances(pyblish.api.ContextPlugin): self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) - # def get_otio_clip_instance_data(self, segment): - # """ - # Return otio objects for timeline, track and clip + def _get_otio_clip_instance_data(self, clip_data): + """ + Return otio objects for timeline, track and clip - # Args: - # timeline_item_data (dict): timeline_item_data from list returned by - # resolve.get_current_timeline_items() - # otio_timeline (otio.schema.Timeline): otio object + Args: + timeline_item_data (dict): timeline_item_data from list returned by + resolve.get_current_timeline_items() + otio_timeline (otio.schema.Timeline): otio object - # Returns: - # dict: otio clip object + Returns: + dict: otio clip object - # """ - # ti_track_name = segment.parent().name() - # timeline_range = self.create_otio_time_range_from_timeline_item_data( - # segment) - # for otio_clip in self.otio_timeline.each_clip(): - # track_name = otio_clip.parent().name - # parent_range = otio_clip.range_in_parent() - # if ti_track_name not in track_name: - # continue - # if otio_clip.name not in segment.name(): - # continue - # if openpype.lib.is_overlapping_otio_ranges( - # parent_range, timeline_range, strict=True): + """ + segment = clip_data["PySegment"] - # # add pypedata marker to otio_clip metadata - # for marker in otio_clip.markers: - # if phiero.pype_tag_name in marker.name: - # otio_clip.metadata.update(marker.metadata) - # return {"otioClip": otio_clip} + self.log.debug( + ">> flame Track.dir: {}".format(dir(segment.parent))) + s_track_name = segment.parent.name.get_value() - # return None + timeline_range = self._create_otio_time_range_from_timeline_item_data( + clip_data) - # @staticmethod - # def create_otio_time_range_from_timeline_item_data(segment): - # speed = segment.playbackSpeed() - # timeline = phiero.get_current_sequence() - # frame_start = int(segment.timelineIn()) - # frame_duration = int(segment.sourceDuration() / speed) - # fps = timeline.framerate().toFloat() + for otio_clip in self.otio_timeline.each_clip(): + self.log.debug( + ">> OTIO Track.dir: {}".format(dir(otio_clip.parent()))) + track_name = otio_clip.parent().name + parent_range = otio_clip.range_in_parent() + if s_track_name not in track_name: + continue + if otio_clip.name not in segment.name.get_value(): + continue + if openpype.lib.is_overlapping_otio_ranges( + parent_range, timeline_range, strict=True): - # return hiero_export.create_otio_time_range( - # frame_start, frame_duration, fps) + # add pypedata marker to otio_clip metadata + for marker in otio_clip.markers: + if opfapi.MARKER_NAME in marker.name: + otio_clip.metadata.update(marker.metadata) + return {"otioClip": otio_clip} + + return None + + def _create_otio_time_range_from_timeline_item_data(self, clip_data): + frame_start = int(clip_data["record_in"]) + frame_duration = int(clip_data["record_duration"]) + + return flame_export.create_otio_time_range( + frame_start, frame_duration, self.fps) From c01ed46157fe70346a5a6e3b639624fe6ca551b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 14:11:49 +0100 Subject: [PATCH 126/229] added ability to skip 3rd part lib validations --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 92cc76dc7a..6891b3c419 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,9 @@ def validate_thirdparty_binaries(): raise RuntimeError(error_msg.format("OpenImageIO")) -validate_thirdparty_binaries() +# Give ability to skip vaidation +if not os.getenv("SKIP_THIRD_PARTY_VALIDATION"): + validate_thirdparty_binaries() version = {} From 67138f2787bb871b79ab865e162e53a542414779 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 14:35:20 +0100 Subject: [PATCH 127/229] flame: fix correct search condition --- openpype/hosts/flame/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index e53127503b..b963a1cb39 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -559,7 +559,7 @@ def get_segment_attributes(segment): attr = getattr(segment, attr_name) segment_attrs_data[attr] = str(attr).replace("+", ":") - if attr in ["record_in", "record_out"]: + if attr_name in ["record_in", "record_out"]: clip_data[attr_name] = attr.relative_frame else: clip_data[attr_name] = attr.frame From f0a11fa0bfdf4ac33218c4154cd150cbe5a99490 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 15:37:11 +0100 Subject: [PATCH 128/229] flame: fix otio path padding --- openpype/hosts/flame/otio/flame_export.py | 2 +- openpype/hosts/flame/otio/utils.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index aea1f387e8..615904ec09 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -295,7 +295,7 @@ def create_otio_reference(clip_data): if is_sequence: metadata.update({ "isSequence": True, - "padding": padding + "padding": len(padding) }) otio_ex_ref_item = None diff --git a/openpype/hosts/flame/otio/utils.py b/openpype/hosts/flame/otio/utils.py index 229946343b..57a15d65a1 100644 --- a/openpype/hosts/flame/otio/utils.py +++ b/openpype/hosts/flame/otio/utils.py @@ -1,4 +1,5 @@ import re +import os import opentimelineio as otio import logging log = logging.getLogger(__name__) @@ -33,19 +34,21 @@ def get_reformated_path(path, padded=True): get_reformated_path("plate.1001.exr") > plate.%04d.exr """ - padding = get_padding_from_path(path) - found = get_frame_from_path(path) + basename = os.path.basename(path) + dirpath = os.path.dirname(path) + padding = get_padding_from_path(basename) + found = get_frame_from_path(basename) if not found: log.info("Path is not sequence: {}".format(path)) return path if padded: - path = path.replace(found, "%0{}d".format(padding)) + basename = basename.replace(found, "%0{}d".format(padding)) else: - path = path.replace(found, "%d") + basename = basename.replace(found, "%d") - return path + return os.path.join(dirpath, basename) def get_padding_from_path(path): From 10de030e133d7beca656515638da48312b33751f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 15:40:33 +0100 Subject: [PATCH 129/229] flame: adding host to global plugins --- openpype/plugins/publish/collect_hierarchy.py | 2 +- openpype/plugins/publish/collect_otio_frame_ranges.py | 2 +- openpype/plugins/publish/collect_otio_review.py | 2 +- openpype/plugins/publish/collect_otio_subset_resources.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index f7d1c6b4be..7f7306f73b 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -15,7 +15,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): label = "Collect Hierarchy" order = pyblish.api.CollectorOrder - 0.47 families = ["shot"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, context): temp_context = {} diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index a35ef47e79..511ed757b3 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -20,7 +20,7 @@ class CollectOcioFrameRanges(pyblish.api.InstancePlugin): label = "Collect OTIO Frame Ranges" order = pyblish.api.CollectorOrder - 0.48 families = ["shot", "clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance): # get basic variables diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index 10ceafdcca..6634be0671 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -22,7 +22,7 @@ class CollectOcioReview(pyblish.api.InstancePlugin): label = "Collect OTIO Review" order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance): # get basic variables diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index 571d0d56a4..d740ceb508 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -20,7 +20,7 @@ class CollectOcioSubsetResources(pyblish.api.InstancePlugin): label = "Collect OTIO Subset Resources" order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance): From 35f721fc8ac41de47473f93eeb8a650dfa76c8a0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 16:08:09 +0100 Subject: [PATCH 130/229] flame: adding file path to instance data --- .../hosts/flame/plugins/publish/precollect_instances.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index fa007b3efd..a093bb82fa 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -91,7 +91,8 @@ class PrecollectInstances(pyblish.api.ContextPlugin): "publish": marker_data["publish"], "fps": self.fps, "flameSourceClip": source_clip, - "sourceFirstFrame": first_frame + "sourceFirstFrame": first_frame, + "path": file_path }) # otio clip data @@ -228,17 +229,11 @@ class PrecollectInstances(pyblish.api.ContextPlugin): """ segment = clip_data["PySegment"] - - self.log.debug( - ">> flame Track.dir: {}".format(dir(segment.parent))) s_track_name = segment.parent.name.get_value() - timeline_range = self._create_otio_time_range_from_timeline_item_data( clip_data) for otio_clip in self.otio_timeline.each_clip(): - self.log.debug( - ">> OTIO Track.dir: {}".format(dir(otio_clip.parent()))) track_name = otio_clip.parent().name parent_range = otio_clip.range_in_parent() if s_track_name not in track_name: From 39578a4a5104737a5e5bbcaa44bd8eebc64cebe2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 16:11:19 +0100 Subject: [PATCH 131/229] flame: adding host to ftrack plugin --- .../ftrack/plugins/publish/integrate_hierarchy_ftrack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py b/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py index fbd64d9f70..61892240d7 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py @@ -63,7 +63,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder - 0.04 label = 'Integrate Hierarchy To Ftrack' families = ["shot"] - hosts = ["hiero", "resolve", "standalonepublisher"] + hosts = ["hiero", "resolve", "standalonepublisher", "flame"] optional = False def process(self, context): From 95dcc57d0f175490a73e37852238c875cad1816f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 16:41:51 +0100 Subject: [PATCH 132/229] OP-1730 - add permission to list channels Will be used to delete messages or files --- openpype/modules/slack/manifest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/modules/slack/manifest.yml b/openpype/modules/slack/manifest.yml index bd920ac266..7a65cc5915 100644 --- a/openpype/modules/slack/manifest.yml +++ b/openpype/modules/slack/manifest.yml @@ -18,6 +18,7 @@ oauth_config: - chat:write.customize - chat:write.public - files:write + - channels:read settings: org_deploy_enabled: false socket_mode_enabled: false From 57a51c28a38a6313f3df08e54acefe69f650227a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 16:43:12 +0100 Subject: [PATCH 133/229] OP-1730 - added icon and documentation Slack requires peculiar format of icon, provided one. --- .../modules/slack/resources/openpype_icon.png | Bin 0 -> 105495 bytes website/docs/module_slack.md | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 openpype/modules/slack/resources/openpype_icon.png diff --git a/openpype/modules/slack/resources/openpype_icon.png b/openpype/modules/slack/resources/openpype_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bb38dcf5778d06b75793b70c8c92176062e8c90b GIT binary patch literal 105495 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≫Lb-DaL0}w^*iUAX- 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

-|1{^JYf%l_9# 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 Basic information > Display Information. +You can upload any image you want, or for your convenience locate prepared OpenPype icon in your installed Openpype installation in `openpype\modules\slac\resources`. ## System Settings From e9cbcf917c1cce6984254cbee7e72e4e163568b0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 16:58:04 +0100 Subject: [PATCH 134/229] OP-1730 - added logging sent messages and uploaded files into db --- .../plugins/publish/integrate_slack_api.py | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 5aba372549..6afbbf5849 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -2,8 +2,10 @@ import os import six import pyblish.api import copy +from datetime import datetime from openpype.lib.plugin_tools import prepare_template_data +from openpype.lib import OpenPypeMongoConnection class IntegrateSlackAPI(pyblish.api.InstancePlugin): @@ -41,23 +43,38 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): if not message: return - # if message_profile["upload_thumbnail"] and thumbnail_path: - # publish_files.add(thumbnail_path) + if message_profile["upload_thumbnail"] and thumbnail_path: + publish_files.add(thumbnail_path) if message_profile["upload_review"] and review_path: publish_files.add(review_path) + project = instance.context.data["anatomyData"]["project"]["code"] for channel in message_profile["channels"]: if six.PY2: - self._python2_call(instance.data["slack_token"], - channel, - message, - publish_files) + msg_id, file_ids = \ + self._python2_call(instance.data["slack_token"], + channel, + message, + publish_files) else: - self._python3_call(instance.data["slack_token"], - channel, - message, - publish_files) + msg_id, file_ids = \ + self._python3_call(instance.data["slack_token"], + channel, + message, + publish_files) + + msg = { + "type": "slack", + "msg_id": msg_id, + "file_ids": file_ids, + "project": project, + "created_dt": datetime.now() + } + mongo_client = OpenPypeMongoConnection.get_mongo_client() + database_name = os.environ["OPENPYPE_DATABASE_NAME"] + dbcon = mongo_client[database_name]["notification_messages"] + dbcon.insert_one(msg) def _get_filled_message(self, message_templ, instance, review_path=None): """Use message_templ and data from instance to get message content. @@ -85,7 +102,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): task_data = fill_data.get("task") for key, value in task_data.items(): fill_key = "task[{}]".format(key) - fill_pairs.append((fill_key , value)) + fill_pairs.append((fill_key, value)) fill_pairs.append(("task", task_data["name"])) self.log.debug("fill_pairs ::{}".format(fill_pairs)) @@ -126,23 +143,24 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): break return published_path - def _python2_call(self, token, channel, message, - publish_files): + def _python2_call(self, token, channel, message, publish_files): from slackclient import SlackClient try: client = SlackClient(token) - self.log.info("publish {}".format(publish_files)) attachment_str = "\n\n Attachment links: \n" + file_ids = [] for p_file in publish_files: with open(p_file, 'rb') as pf: response = client.api_call( "files.upload", - channels=channel, - file=pf + file=pf, + channel=channel, + title=os.path.basename(p_file) ) attachment_str += "\n<{}|{}>".format( response["file"]["permalink"], os.path.basename(p_file)) + file_ids.append(response["file"]["id"]) if publish_files: message += attachment_str @@ -152,23 +170,24 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): channel=channel, text=message ) - self.log.info("repsonse {}".format(response)) if response.get("error"): error_str = self._enrich_error(str(response.get("error")), channel) self.log.warning("Error happened: {}".format(error_str)) + else: + return response["ts"], file_ids except Exception as e: # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e), channel) self.log.warning("Error happened: {}".format(error_str)) - def _python3_call(self, token, channel, message, - publish_files): + def _python3_call(self, token, channel, message, publish_files): from slack_sdk import WebClient from slack_sdk.errors import SlackApiError try: client = WebClient(token=token) attachment_str = "\n\n Attachment links: \n" + file_ids = [] for published_file in publish_files: response = client.files_upload( file=published_file, @@ -176,16 +195,16 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): attachment_str += "\n<{}|{}>".format( response["file"]["permalink"], os.path.basename(published_file)) + file_ids.append(response["file"]["id"]) if publish_files: message += attachment_str - _ = client.chat_postMessage( + response = client.chat_postMessage( channel=channel, - text=message, - username=self.bot_user_name, - icon_url=self.icon_url + text=message ) + return response.data["ts"], file_ids except SlackApiError as e: # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e.response["error"]), channel) From 5e8f0e0152df9547caed72d4b401a5fb9200fb30 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jan 2022 17:02:58 +0100 Subject: [PATCH 135/229] OP-1730 - removed obsolete variables Modification username and icon via message payload doesn't work for both post_method and upload_file. Icon must be set in Slack app configuration. --- openpype/modules/slack/plugins/publish/integrate_slack_api.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 6afbbf5849..5d014382a3 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -26,10 +26,6 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True - # internal, not configurable - bot_user_name = "OpenPypeNotifier" - icon_url = "https://openpype.io/img/favicon/favicon.ico" - def process(self, instance): thumbnail_path = self._get_thumbnail_path(instance) review_path = self._get_review_path(instance) From 87c5eb549786fa2e166dd381fe4b97204b146b48 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Jan 2022 17:55:22 +0100 Subject: [PATCH 136/229] Expose toggle publish plug-in settings for Maya Look Shading Engine Naming --- openpype/settings/defaults/project_settings/maya.json | 5 +++++ .../projects_schema/schemas/schema_maya_publish.json | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index b75b0168ec..a756071106 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -166,6 +166,11 @@ "enabled": false, "regex": "(?P.*)_(.*)_SHD" }, + "ValidateShadingEngine": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateAttributes": { "enabled": false, "attributes": {} diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 606dd6c2bb..7c9a5a6b46 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -72,6 +72,17 @@ ] }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateShadingEngine", + "label": "Validate Look Shading Engine Naming" + } + ] + }, + { "type": "dict", "collapsible": true, From 6df5488ccec9f9621b5fdb00e68791c0fcc7781e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 Jan 2022 18:07:31 +0100 Subject: [PATCH 137/229] fix type --- igniter/bootstrap_repos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index db62cbbe91..637f821366 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -762,7 +762,7 @@ class BootstrapRepos: destination = self._move_zip_to_data_dir(temp_zip) - return OpenPypeVersion(version=version, path=destination) + return OpenPypeVersion(version=version, path=Path(destination)) def _move_zip_to_data_dir(self, zip_file) -> Union[None, Path]: """Move zip with OpenPype version to user data directory. From 6c2204c92d577a9cddc9533b13d16fd2829f1974 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 Jan 2022 18:44:33 +0100 Subject: [PATCH 138/229] added ability to hide publish if plugin need it --- openpype/tools/pyblish_pype/window.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index fdd2d80e23..edcf6f53b6 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -909,6 +909,13 @@ class Window(QtWidgets.QDialog): self.tr("Processing"), plugin_item.data(QtCore.Qt.DisplayRole) )) + visibility = True + if hasattr(plugin, "hide_ui_on_process") and plugin.hide_ui_on_process: + visibility = False + + if self.isVisible() != visibility: + self.setVisible(visibility) + def on_plugin_action_menu_requested(self, pos): """The user right-clicked on a plug-in __________ From 91930038880fb24c68518f5d135b5eba44bdbf05 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 Jan 2022 18:46:44 +0100 Subject: [PATCH 139/229] fix dir/file resolution --- tools/create_zip.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/create_zip.py b/tools/create_zip.py index 32a4d27e8b..2fc351469a 100644 --- a/tools/create_zip.py +++ b/tools/create_zip.py @@ -31,7 +31,9 @@ def main(path): bs = bootstrap_repos.BootstrapRepos(progress_callback=progress) if path: out_path = Path(path) - bs.data_dir = out_path.parent + bs.data_dir = out_path + if out_path.is_file(): + bs.data_dir = out_path.parent _print(f"Creating zip in {bs.data_dir} ...") repo_file = bs.create_version_from_live_code() From 226903ea0b45d7ff09e5dd284a80566d2b88402d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jan 2022 20:24:06 +0100 Subject: [PATCH 140/229] hound: flake8 fix --- openpype/hosts/flame/plugins/publish/precollect_instances.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index a093bb82fa..b4b2ebf63f 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -32,7 +32,8 @@ class PrecollectInstances(pyblish.api.ContextPlugin): # get openpype tag data marker_data = opfapi.get_segment_data_marker(segment) - self.log.debug("__ marker_data: {}".format(pformat(marker_data))) + self.log.debug("__ marker_data: {}".format( + pformat(marker_data))) if not marker_data: continue From 5d9ddca7d0aef5c84d0119fac0396fb2790f3f1c Mon Sep 17 00:00:00 2001 From: "IGOTGAMES\\jesse.d" Date: Wed, 12 Jan 2022 17:25:22 -0800 Subject: [PATCH 141/229] Fixed bug: File list would be 1 file long if node frame range is 2 frames long. --- openpype/hosts/houdini/plugins/publish/collect_frames.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index ef77c3230b..8d21794c1b 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -37,7 +37,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # Check if frames are bigger than 1 (file collection) # override the result - if end_frame - start_frame > 1: + if end_frame - start_frame > 0: result = self.create_file_list( match, int(start_frame), int(end_frame) ) From 365368554f4ebf6d34ff6139e4193de4edd022d4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 10:38:39 +0100 Subject: [PATCH 142/229] flame: starting render utlis modul --- openpype/hosts/flame/api/render_utils.py | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 openpype/hosts/flame/api/render_utils.py diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py new file mode 100644 index 0000000000..01efc9e5b9 --- /dev/null +++ b/openpype/hosts/flame/api/render_utils.py @@ -0,0 +1,46 @@ +import os + +SHARED_PRESET_PATH = '/opt/Autodesk/shared/export/presets/file_sequence' +SHARED_PRESETS = ['Default Jpeg'] + [ + preset[:-4] for preset in os.listdir(SHARED_PRESET_PATH)] + + +def export_clip(export_path, clip, export_preset, **kwargs): + import flame + + # Set exporter + exporter = flame.PyExporter() + exporter.foreground = True + exporter.export_between_marks = True + + if "in_mark" not in kwargs.keys(): + exporter.export_between_marks = False + + # Duplicate the clip to avoid modifying the original clip + duplicate_clip = flame.duplicate(clip) + + # Set export preset path + if export_preset == 'Default Jpeg': + # Get default export preset path + preset_dir = flame.PyExporter.get_presets_dir( + flame.PyExporter.PresetVisibility.Autodesk, + flame.PyExporter.PresetType.Image_Sequence) + export_preset_path = os.path.join( + preset_dir, "Jpeg", "Jpeg (8-bit).xml") + else: + export_preset_path = os.path.join( + SHARED_PRESET_PATH, export_preset + '.xml') + + try: + if kwargs.get("in_mark") and kwargs.get("out_mark"): + duplicate_clip.in_mark = int(kwargs["in_mark"]) + duplicate_clip.in_mark = int(kwargs["out_mark"]) + + exporter.export(duplicate_clip, export_preset_path, export_path) + finally: + print('Exported: {} at {}-{}'.format( + clip.name.get_value(), + duplicate_clip.in_mark, + duplicate_clip.out_mark + )) + flame.delete(duplicate_clip) \ No newline at end of file From 9d9f9514c1bbc25046b16a6c882505b428fa3c61 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 11:02:21 +0100 Subject: [PATCH 143/229] format output arguments with anatomy data --- openpype/plugins/publish/extract_review.py | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b6c2e49385..be29c7bf9c 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -187,6 +187,7 @@ class ExtractReview(pyblish.api.InstancePlugin): outputs_per_repres = self._get_outputs_per_representations( instance, profile_outputs ) + fill_data = copy.deepcopy(instance.data["anatomyData"]) for repre, outputs in outputs_per_repres: # Check if input should be preconverted before processing # Store original staging dir (it's value may change) @@ -293,7 +294,7 @@ class ExtractReview(pyblish.api.InstancePlugin): try: # temporary until oiiotool is supported cross platform ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data + output_def, instance, new_repre, temp_data, fill_data ) except ZeroDivisionError: if 'exr' in temp_data["origin_repre"]["ext"]: @@ -446,7 +447,9 @@ class ExtractReview(pyblish.api.InstancePlugin): "handles_are_set": handles_are_set } - def _ffmpeg_arguments(self, output_def, instance, new_repre, temp_data): + def _ffmpeg_arguments( + self, output_def, instance, new_repre, temp_data, fill_data + ): """Prepares ffmpeg arguments for expected extraction. Prepares input and output arguments based on output definition and @@ -472,9 +475,6 @@ class ExtractReview(pyblish.api.InstancePlugin): ffmpeg_input_args = [ value for value in _ffmpeg_input_args if value.strip() ] - ffmpeg_output_args = [ - value for value in _ffmpeg_output_args if value.strip() - ] ffmpeg_video_filters = [ value for value in _ffmpeg_video_filters if value.strip() ] @@ -482,6 +482,21 @@ class ExtractReview(pyblish.api.InstancePlugin): value for value in _ffmpeg_audio_filters if value.strip() ] + ffmpeg_output_args = [] + for value in _ffmpeg_output_args: + value = value.strip() + if not value: + continue + try: + value = value.format(**fill_data) + except Exception: + self.log.warning( + "Failed to format ffmpeg argument: {}".format(value), + exc_info=True + ) + pass + ffmpeg_output_args.append(value) + # Prepare input and output filepaths self.input_output_paths(new_repre, output_def, temp_data) From aa19d699d3283c7db94d1de5d13a53700bf011a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 12:03:19 +0100 Subject: [PATCH 144/229] flame: updating render_utils modul --- openpype/hosts/flame/api/render_utils.py | 88 ++++++++++++++++++++---- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index 01efc9e5b9..d2e312785f 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,41 +1,99 @@ import os -SHARED_PRESET_PATH = '/opt/Autodesk/shared/export/presets/file_sequence' -SHARED_PRESETS = ['Default Jpeg'] + [ - preset[:-4] for preset in os.listdir(SHARED_PRESET_PATH)] +SHARED_PRESET_PATH = '/opt/Autodesk/shared/export/presets' def export_clip(export_path, clip, export_preset, **kwargs): + """Flame exported wrapper + + Args: + export_path (str): exporting directory path + clip (PyClip): flame api object + export_preset (str): name of exporting preset xml file + + Kwargs: + export_type (str)[optional]: name of export type folder + thumb_frame_number (int)[optional]: source frame number + in_mark (int)[optional]: cut in mark + out_mark (int)[optional]: cut out mark + + Raises: + KeyError: Missing input kwarg `thumb_frame_number` + in case `thumbnail` in `export_preset` + KeyError: Missing input kwarg `export_type` + in case of other `export_preset` then `thumbnail` + FileExistsError: Missing export preset in shared folder + """ import flame + in_mark = out_mark = None + # Set exporter exporter = flame.PyExporter() exporter.foreground = True exporter.export_between_marks = True - if "in_mark" not in kwargs.keys(): - exporter.export_between_marks = False - # Duplicate the clip to avoid modifying the original clip duplicate_clip = flame.duplicate(clip) - # Set export preset path - if export_preset == 'Default Jpeg': - # Get default export preset path + if export_preset == 'thumbnail': + thumb_frame_number = kwargs.get("thumb_frame_number") + # make sure it exists in kwargs + if not thumb_frame_number: + raise KeyError( + "Missing key `thumb_frame_number` in input kwargs") + + in_mark = int(thumb_frame_number) + out_mark = int(thumb_frame_number) + 1 + + # In case Thumbnail is needed preset_dir = flame.PyExporter.get_presets_dir( flame.PyExporter.PresetVisibility.Autodesk, flame.PyExporter.PresetType.Image_Sequence) export_preset_path = os.path.join( preset_dir, "Jpeg", "Jpeg (8-bit).xml") + else: + # In case other output is needed + # get compulsory kwargs + export_type = kwargs.get("export_type") + # make sure it exists in kwargs + if not export_type: + raise KeyError( + "Missing key `export_type` in input kwargs") + + # create full shared preset path + shared_preset_dir = os.path.join( + SHARED_PRESET_PATH, export_type + ) + + # check if export preset is available in shared presets + shared_presets = [ + preset[:-4] for preset in os.listdir(shared_preset_dir)] + if export_preset not in shared_presets: + raise FileExistsError( + "Missing preset file `{}` in `{}`".format( + export_preset, + shared_preset_dir + )) + export_preset_path = os.path.join( - SHARED_PRESET_PATH, export_preset + '.xml') + shared_preset_dir, export_preset + '.xml') + + # check if mark in/out is set in kwargs + if kwargs.get("in_mark") and kwargs.get("out_mark"): + in_mark = int(kwargs["in_mark"]) + out_mark = int(kwargs["out_mark"]) + else: + exporter.export_between_marks = False try: - if kwargs.get("in_mark") and kwargs.get("out_mark"): - duplicate_clip.in_mark = int(kwargs["in_mark"]) - duplicate_clip.in_mark = int(kwargs["out_mark"]) + # set in and out marks if they are available + if in_mark and out_mark: + duplicate_clip.in_mark = in_mark + duplicate_clip.out_mark = out_mark + # export with exporter exporter.export(duplicate_clip, export_preset_path, export_path) finally: print('Exported: {} at {}-{}'.format( @@ -43,4 +101,6 @@ def export_clip(export_path, clip, export_preset, **kwargs): duplicate_clip.in_mark, duplicate_clip.out_mark )) - flame.delete(duplicate_clip) \ No newline at end of file + + # delete duplicated clip it is not needed anymore + flame.delete(duplicate_clip) From 574466f6dcea84d6a09bff0ac13493d4a5179c36 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 12:52:22 +0100 Subject: [PATCH 145/229] flame: adding export clip to api --- openpype/hosts/flame/api/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 308682b884..fce59af506 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -59,6 +59,9 @@ from .workio import ( file_extensions, work_root ) +from .render_utils import ( + export_clip +) __all__ = [ # constants @@ -119,5 +122,8 @@ __all__ = [ "current_file", "has_unsaved_changes", "file_extensions", - "work_root" + "work_root", + + # render utils + "export_clip" ] From a2d414c64657d11ddbbacfd546db3613ee91ab85 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 12:53:02 +0100 Subject: [PATCH 146/229] flame: adding exporter plugin --- .../publish/extract_subset_resources.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/extract_subset_resources.py diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py new file mode 100644 index 0000000000..ffa01eb1b3 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -0,0 +1,91 @@ +import os +import pyblish.api +import openpype.api +from openpype.hosts.flame import api as opfapi + + +class ExtractSubsetResources(openpype.api.Extractor): + """ + Extractor for transcoding files from Flame clip + """ + + label = "Extract subset resources" + order = pyblish.api.CollectorOrder + 0.49 + families = ["clip"] + hosts = ["flame"] + + # hide publisher during exporting + hide_ui_on_process = True + + export_presets_mapping = { + "thumbnail": { + "ext": "jpg", + "uniqueName": "thumbnail" + }, + "OpenEXR (16-bit fp DWAA)_custom": { + "ext": "exr", + "preset_type": "file_sequence", + "uniqueName": "exr16fpdwaa" + }, + "QuickTime (H.264 1080p 8Mbits)_custom": { + "ext": "mov", + "preset_type": "movie_file", + "uniqueName": "ftrackpreview" + } + } + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + name = instance.data["name"] + clip = instance.data["flameSourceClip"] + staging_dir = self.staging_dir(instance) + + # prepare full export path + export_dir_path = os.path.join( + staging_dir, name + ) + # loop all preset names and + for preset_name, preset_config in self.export_presets_mapping: + kwargs = {} + unique_name = preset_config["uniqueName"] + preset_type = None + + # define kwargs based on preset type + if "thumbnail" in preset_name: + kwargs["thumb_frame_number"] = 2 + else: + preset_type = preset_config["preset_type"] + kwargs.update({ + "in_mark": 2, + "out_mark": 5, + "preset_type": preset_type + }) + + _export_dir_path = os.path.join( + export_dir_path, unique_name + ) + # export + opfapi.export_clip( + _export_dir_path, clip, preset_name, **kwargs) + + # create representation data + representation_data = { + 'name': unique_name, + 'ext': preset_config["ext"], + "stagingDir": _export_dir_path, + } + + files = os.listdir(_export_dir_path) + + if preset_type and preset_type == "movie_file": + representation_data["files"] = files + else: + representation_data["files"] = files.pop() + + instance.data["representations"].append(representation_data) + + self.log.info("Added representation: {}".format( + representation_data)) From 29445314346e644ea02e1df8f4479ce8b587a32d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 13:02:51 +0100 Subject: [PATCH 147/229] implemented callback warpper for execution in main thread --- openpype/tools/tray/pype_tray.py | 24 ++++++----- openpype/tools/utils/__init__.py | 5 ++- openpype/tools/utils/lib.py | 70 +++++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 12 deletions(-) diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index df0238c848..e7ac390c30 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -22,6 +22,7 @@ from openpype.settings import ( ProjectSettings, DefaultsNotDefined ) +from openpype.tools.utils import WrappedCallbackItem from .pype_info_widget import PypeInfoWidget @@ -61,21 +62,24 @@ class TrayManager: if callback: self.execute_in_main_thread(callback) - def execute_in_main_thread(self, callback): - self._main_thread_callbacks.append(callback) + def execute_in_main_thread(self, callback, *args, **kwargs): + if isinstance(callback, WrappedCallbackItem): + item = callback + else: + item = WrappedCallbackItem(callback, *args, **kwargs) + + self._main_thread_callbacks.append(item) + + return item def _main_thread_execution(self): if self._execution_in_progress: return self._execution_in_progress = True - while self._main_thread_callbacks: - try: - callback = self._main_thread_callbacks.popleft() - callback() - except: - self.log.warning( - "Failed to execute {} in main thread".format(callback), - exc_info=True) + for _ in range(len(self._main_thread_callbacks)): + if self._main_thread_callbacks: + item = self._main_thread_callbacks.popleft() + item.execute() self._execution_in_progress = False diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 4dd6bdd05f..65025ac358 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -6,6 +6,7 @@ from .widgets import ( ) from .error_dialog import ErrorMessageBox +from .lib import WrappedCallbackItem __all__ = ( @@ -14,5 +15,7 @@ __all__ = ( "ClickableFrame", "ExpandBtn", - "ErrorMessageBox" + "ErrorMessageBox", + + "WrappedCallbackItem", ) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 6742df8557..5f3456ae3e 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -9,7 +9,10 @@ import avalon.api from avalon import style from avalon.vendor import qtawesome -from openpype.api import get_project_settings +from openpype.api import ( + get_project_settings, + Logger +) from openpype.lib import filter_profiles @@ -598,3 +601,68 @@ def is_remove_site_loader(loader): def is_add_site_loader(loader): return hasattr(loader, "add_site_to_representation") + + +class WrappedCallbackItem: + """Structure to store information about callback and args/kwargs for it. + + Item can be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + _log = None + + def __init__(self, callback, *args, **kwargs): + self._done = False + self._exception = self.not_set + self._result = self.not_set + self._callback = callback + self._args = args + self._kwargs = kwargs + + def __call__(self): + self.execute() + + @property + def log(self): + cls = self.__class__ + if cls._log is None: + cls._log = Logger.get_logger(cls.__name__) + return cls._log + + @property + def done(self): + return self._done + + @property + def exception(self): + return self._exception + + @property + def result(self): + return self._result + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + if self.done: + self.log.warning("- item is already processed") + return + + self.log.debug("Running callback: {}".format(str(self._callback))) + try: + result = self._callback(*self._args, **self._kwargs) + self._result = result + + except Exception as exc: + self._exception = exc + + finally: + self._done = True From d7fb171f101bf36495a106fbca296515f692b080 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 13:08:35 +0100 Subject: [PATCH 148/229] added check if current running openpype has expected version --- openpype/lib/__init__.py | 6 ++- openpype/lib/pype_info.py | 48 +++++++++++++++++---- openpype/tools/tray/pype_tray.py | 74 ++++++++++++++++++++++++++++++-- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 12e47a8961..65019f3fab 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -170,7 +170,9 @@ from .editorial import ( from .pype_info import ( get_openpype_version, - get_build_version + get_build_version, + is_running_from_build, + is_current_version_studio_latest ) terminal = Terminal @@ -304,4 +306,6 @@ __all__ = [ "get_openpype_version", "get_build_version", + "is_running_from_build", + "is_current_version_studio_latest", ] diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index 15856bfb19..ea804c8a18 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -10,6 +10,12 @@ from openpype.settings.lib import get_local_settings from .execute import get_openpype_execute_args from .local_settings import get_local_site_id from .python_module_tools import import_filepath +from .openpype_version import ( + op_version_control_available, + openpype_path_is_accessible, + get_expected_studio_version, + get_OpenPypeVersion +) def get_openpype_version(): @@ -17,15 +23,6 @@ def get_openpype_version(): return openpype.version.__version__ -def get_pype_version(): - """Backwards compatibility. Remove when 100% not used.""" - print(( - "Using deprecated function 'openpype.lib.pype_info.get_pype_version'" - " replace with 'openpype.lib.pype_info.get_openpype_version'." - )) - return get_openpype_version() - - def get_build_version(): """OpenPype version of build.""" # Return OpenPype version if is running from code @@ -138,3 +135,36 @@ def extract_pype_info_to_file(dirpath): with open(filepath, "w") as file_stream: json.dump(data, file_stream, indent=4) return filepath + + +def is_current_version_studio_latest(): + """Is currently running OpenPype version which is defined by studio. + + It is not recommended to ask in each process as there may be situations + when older OpenPype should be used. For example on farm. But it does make + sense in processes that can run for a long time. + + Returns: + None: Can't determine. e.g. when running from code or the build is + too old. + bool: True when is using studio + """ + output = None + # Skip if is not running from build + if not is_running_from_build(): + return output + + # Skip if build does not support version control + if not op_version_control_available(): + return output + + # Skip if path to folder with zip files is not accessible + if not openpype_path_is_accessible(): + return output + + # Check if current version is expected version + OpenPypeVersion = get_OpenPypeVersion() + current_version = OpenPypeVersion(get_openpype_version()) + expected_version = get_expected_studio_version(is_running_staging()) + + return current_version == expected_version diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index e7ac390c30..5af82b2c64 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -14,7 +14,11 @@ from openpype.api import ( resources, get_system_settings ) -from openpype.lib import get_openpype_execute_args +from openpype.lib import ( + get_openpype_execute_args, + is_current_version_studio_latest, + is_running_from_build +) from openpype.modules import TrayModulesManager from openpype import style from openpype.settings import ( @@ -27,11 +31,43 @@ from openpype.tools.utils import WrappedCallbackItem from .pype_info_widget import PypeInfoWidget +class VersionDialog(QtWidgets.QDialog): + def __init__(self, parent=None): + super(VersionDialog, self).__init__(parent) + + label_widget = QtWidgets.QLabel( + "Your version does not match to studio version", self + ) + + ignore_btn = QtWidgets.QPushButton("Ignore", self) + restart_btn = QtWidgets.QPushButton("Restart and Install", self) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ignore_btn, 0) + btns_layout.addWidget(restart_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(label_widget, 0) + layout.addStretch(1) + layout.addLayout(btns_layout, 0) + + ignore_btn.clicked.connect(self._on_ignore) + restart_btn.clicked.connect(self._on_reset) + + def _on_ignore(self): + self.reject() + + def _on_reset(self): + self.accept() + + class TrayManager: """Cares about context of application. Load submenus, actions, separators and modules into tray's context. """ + _version_check_interval = 5 * 60 * 1000 def __init__(self, tray_widget, main_window): self.tray_widget = tray_widget @@ -46,6 +82,9 @@ class TrayManager: self.errors = [] + self._version_check_timer = None + self._version_dialog = None + self.main_thread_timer = None self._main_thread_callbacks = collections.deque() self._execution_in_progress = None @@ -62,6 +101,24 @@ class TrayManager: if callback: self.execute_in_main_thread(callback) + def _on_version_check_timer(self): + # Check if is running from build and stop future validations if yes + if not is_running_from_build(): + self._version_check_timer.stop() + return + + self.validate_openpype_version() + + def validate_openpype_version(self): + if is_current_version_studio_latest(): + return + + if self._version_dialog is None: + self._version_dialog = VersionDialog() + result = self._version_dialog.exec_() + if result: + self.restart() + def execute_in_main_thread(self, callback, *args, **kwargs): if isinstance(callback, WrappedCallbackItem): item = callback @@ -123,6 +180,12 @@ class TrayManager: self.main_thread_timer = main_thread_timer + version_check_timer = QtCore.QTimer() + version_check_timer.setInterval(self._version_check_interval) + version_check_timer.timeout.connect(self._on_version_check_timer) + version_check_timer.start() + self._version_check_timer = version_check_timer + # For storing missing settings dialog self._settings_validation_dialog = None @@ -207,7 +270,7 @@ class TrayManager: self.tray_widget.menu.addAction(version_action) self.tray_widget.menu.addSeparator() - def restart(self): + def restart(self, reset_version=True): """Restart Tray tool. First creates new process with same argument and close current tray. @@ -221,7 +284,9 @@ class TrayManager: additional_args.pop(0) args.extend(additional_args) - kwargs = {} + kwargs = { + "env": dict(os.environ.items()) + } if platform.system().lower() == "windows": flags = ( subprocess.CREATE_NEW_PROCESS_GROUP @@ -229,6 +294,9 @@ class TrayManager: ) kwargs["creationflags"] = flags + if reset_version: + kwargs["env"].pop("OPENPYPE_VERSION", None) + subprocess.Popen(args, **kwargs) self.exit() From 9c20580d699c77ad5f5f3462050069172145d3dd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 15:32:40 +0100 Subject: [PATCH 149/229] flame: export clip to correct frame range --- .../publish/extract_subset_resources.py | 55 ++++++++++++------- .../plugins/publish/precollect_instances.py | 2 +- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index ffa01eb1b3..ea782845ef 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -15,7 +15,7 @@ class ExtractSubsetResources(openpype.api.Extractor): hosts = ["flame"] # hide publisher during exporting - hide_ui_on_process = True + # hide_ui_on_process = True export_presets_mapping = { "thumbnail": { @@ -39,51 +39,66 @@ class ExtractSubsetResources(openpype.api.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - name = instance.data["name"] - clip = instance.data["flameSourceClip"] + source_first_frame = instance.data["sourceFirstFrame"] + source_start_handles = instance.data["sourceStartH"] + source_end_handles = instance.data["sourceEndH"] + source_duration_handles = ( + source_end_handles - source_start_handles) + 1 + + clip_data = instance.data["flameSourceClip"] + clip = clip_data["PyClip"] + + in_mark = (source_start_handles - source_first_frame) + 1 + out_mark = in_mark + source_duration_handles + staging_dir = self.staging_dir(instance) - # prepare full export path - export_dir_path = os.path.join( - staging_dir, name - ) # loop all preset names and - for preset_name, preset_config in self.export_presets_mapping: + for preset_name, preset_config in self.export_presets_mapping.items(): kwargs = {} unique_name = preset_config["uniqueName"] preset_type = None # define kwargs based on preset type if "thumbnail" in preset_name: - kwargs["thumb_frame_number"] = 2 + kwargs["thumb_frame_number"] = in_mark + ( + source_duration_handles / 2) else: preset_type = preset_config["preset_type"] kwargs.update({ - "in_mark": 2, - "out_mark": 5, - "preset_type": preset_type + "in_mark": in_mark, + "out_mark": out_mark, + "export_type": preset_type }) - _export_dir_path = os.path.join( - export_dir_path, unique_name + export_dir_path = os.path.join( + staging_dir, unique_name ) + os.makedirs(export_dir_path) + # export opfapi.export_clip( - _export_dir_path, clip, preset_name, **kwargs) + export_dir_path, clip, preset_name, **kwargs) # create representation data representation_data = { 'name': unique_name, 'ext': preset_config["ext"], - "stagingDir": _export_dir_path, + "stagingDir": export_dir_path, } - files = os.listdir(_export_dir_path) + files = os.listdir(export_dir_path) - if preset_type and preset_type == "movie_file": - representation_data["files"] = files - else: + # add files to represetation but add + # imagesequence as list + if ( + preset_type + and preset_type == "movie_file" + or preset_name == "thumbnail" + ): representation_data["files"] = files.pop() + else: + representation_data["files"] = files instance.data["representations"].append(representation_data) diff --git a/openpype/hosts/flame/plugins/publish/precollect_instances.py b/openpype/hosts/flame/plugins/publish/precollect_instances.py index b4b2ebf63f..bda583fe8e 100644 --- a/openpype/hosts/flame/plugins/publish/precollect_instances.py +++ b/openpype/hosts/flame/plugins/publish/precollect_instances.py @@ -92,7 +92,7 @@ class PrecollectInstances(pyblish.api.ContextPlugin): "publish": marker_data["publish"], "fps": self.fps, "flameSourceClip": source_clip, - "sourceFirstFrame": first_frame, + "sourceFirstFrame": int(first_frame), "path": file_path }) From 7d283f55558f203cd98265ec327be6e2caa5fd53 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 17:08:37 +0100 Subject: [PATCH 150/229] moved code from pype_info to openpype_version and fixed few bugs --- openpype/lib/__init__.py | 2 +- openpype/lib/openpype_version.py | 117 ++++++++++++++++++++++++++++++- openpype/lib/pype_info.py | 89 +---------------------- openpype/resources/__init__.py | 2 +- 4 files changed, 118 insertions(+), 92 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 65019f3fab..c556f2adc1 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -168,7 +168,7 @@ from .editorial import ( make_sequence_collection ) -from .pype_info import ( +from .openpype_version import ( get_openpype_version, get_build_version, is_running_from_build, diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index e3a4e1fa3e..839222018c 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -9,9 +9,69 @@ OpenPype version located in build but versions available in remote versions repository or locally available. """ +import os import sys +import openpype.version +from .python_module_tools import import_filepath + + +# ---------------------------------------- +# Functions independent on OpenPypeVersion +# ---------------------------------------- +def get_openpype_version(): + """Version of pype that is currently used.""" + return openpype.version.__version__ + + +def get_build_version(): + """OpenPype version of build.""" + # Return OpenPype version if is running from code + if not is_running_from_build(): + return get_openpype_version() + + # Import `version.py` from build directory + version_filepath = os.path.join( + os.environ["OPENPYPE_ROOT"], + "openpype", + "version.py" + ) + if not os.path.exists(version_filepath): + return None + + module = import_filepath(version_filepath, "openpype_build_version") + return getattr(module, "__version__", None) + + +def is_running_from_build(): + """Determine if current process is running from build or code. + + Returns: + bool: True if running from build. + """ + executable_path = os.environ["OPENPYPE_EXECUTABLE"] + executable_filename = os.path.basename(executable_path) + if "python" in executable_filename.lower(): + return False + return True + + +def is_running_staging(): + """Currently used OpenPype is staging version. + + Returns: + bool: True if openpype version containt 'staging'. + """ + if "staging" in get_openpype_version(): + return True + return False + + +# ---------------------------------------- +# Functions dependent on OpenPypeVersion +# - Make sense to call only in OpenPype process +# ---------------------------------------- def get_OpenPypeVersion(): """Access to OpenPypeVersion class stored in sys modules.""" return sys.modules.get("OpenPypeVersion") @@ -71,15 +131,66 @@ def get_remote_versions(*args, **kwargs): return None -def get_latest_version(*args, **kwargs): +def get_latest_version(staging=None, local=None, remote=None): """Get latest version from repository path.""" + if staging is None: + staging = is_running_staging() if op_version_control_available(): - return get_OpenPypeVersion().get_latest_version(*args, **kwargs) + return get_OpenPypeVersion().get_latest_version( + staging=staging, + local=local, + remote=remote + ) return None -def get_expected_studio_version(staging=False): +def get_expected_studio_version(staging=None): """Expected production or staging version in studio.""" + if staging is None: + staging = is_running_staging() if op_version_control_available(): return get_OpenPypeVersion().get_expected_studio_version(staging) return None + + +def is_current_version_studio_latest(): + """Is currently running OpenPype version which is defined by studio. + + It is not recommended to ask in each process as there may be situations + when older OpenPype should be used. For example on farm. But it does make + sense in processes that can run for a long time. + + Returns: + None: Can't determine. e.g. when running from code or the build is + too old. + bool: True when is using studio + """ + output = None + # Skip if is not running from build + if not is_running_from_build(): + return output + + # Skip if build does not support version control + if not op_version_control_available(): + return output + + # Skip if path to folder with zip files is not accessible + if not openpype_path_is_accessible(): + return output + + # Get OpenPypeVersion class + OpenPypeVersion = get_OpenPypeVersion() + # Convert current version to OpenPypeVersion object + current_version = OpenPypeVersion(version=get_openpype_version()) + + staging = is_running_staging() + # Get expected version (from settings) + expected_version = get_expected_studio_version(staging) + if expected_version is None: + # Look for latest if expected version is not set in settings + expected_version = get_latest_version( + staging=staging, + remote=True + ) + # Check if current version is expected version + return current_version == expected_version diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index ea804c8a18..848a505187 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -5,67 +5,15 @@ import platform import getpass import socket -import openpype.version from openpype.settings.lib import get_local_settings from .execute import get_openpype_execute_args from .local_settings import get_local_site_id -from .python_module_tools import import_filepath from .openpype_version import ( - op_version_control_available, - openpype_path_is_accessible, - get_expected_studio_version, - get_OpenPypeVersion + is_running_from_build, + get_openpype_version ) -def get_openpype_version(): - """Version of pype that is currently used.""" - return openpype.version.__version__ - - -def get_build_version(): - """OpenPype version of build.""" - # Return OpenPype version if is running from code - if not is_running_from_build(): - return get_openpype_version() - - # Import `version.py` from build directory - version_filepath = os.path.join( - os.environ["OPENPYPE_ROOT"], - "openpype", - "version.py" - ) - if not os.path.exists(version_filepath): - return None - - module = import_filepath(version_filepath, "openpype_build_version") - return getattr(module, "__version__", None) - - -def is_running_from_build(): - """Determine if current process is running from build or code. - - Returns: - bool: True if running from build. - """ - executable_path = os.environ["OPENPYPE_EXECUTABLE"] - executable_filename = os.path.basename(executable_path) - if "python" in executable_filename.lower(): - return False - return True - - -def is_running_staging(): - """Currently used OpenPype is staging version. - - Returns: - bool: True if openpype version containt 'staging'. - """ - if "staging" in get_openpype_version(): - return True - return False - - def get_pype_info(): """Information about currently used Pype process.""" executable_args = get_openpype_execute_args() @@ -135,36 +83,3 @@ def extract_pype_info_to_file(dirpath): with open(filepath, "w") as file_stream: json.dump(data, file_stream, indent=4) return filepath - - -def is_current_version_studio_latest(): - """Is currently running OpenPype version which is defined by studio. - - It is not recommended to ask in each process as there may be situations - when older OpenPype should be used. For example on farm. But it does make - sense in processes that can run for a long time. - - Returns: - None: Can't determine. e.g. when running from code or the build is - too old. - bool: True when is using studio - """ - output = None - # Skip if is not running from build - if not is_running_from_build(): - return output - - # Skip if build does not support version control - if not op_version_control_available(): - return output - - # Skip if path to folder with zip files is not accessible - if not openpype_path_is_accessible(): - return output - - # Check if current version is expected version - OpenPypeVersion = get_OpenPypeVersion() - current_version = OpenPypeVersion(get_openpype_version()) - expected_version = get_expected_studio_version(is_running_staging()) - - return current_version == expected_version diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index f463933525..34a833d080 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -1,5 +1,5 @@ import os -from openpype.lib.pype_info import is_running_staging +from openpype.lib.openpype_version import is_running_staging RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) From 9fa024daae060fbbfc88aecb7b9639bf2cc7c087 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 Jan 2022 17:45:58 +0100 Subject: [PATCH 151/229] flame: hide gui when processing plugin --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index ea782845ef..6061c80762 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -10,12 +10,12 @@ class ExtractSubsetResources(openpype.api.Extractor): """ label = "Extract subset resources" - order = pyblish.api.CollectorOrder + 0.49 + order = pyblish.api.ExtractorOrder families = ["clip"] hosts = ["flame"] # hide publisher during exporting - # hide_ui_on_process = True + hide_ui_on_process = True export_presets_mapping = { "thumbnail": { From 12156b6d90f723d6a96016fb51c3e876415dca8c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 17:57:27 +0100 Subject: [PATCH 152/229] tray will show info that is outdated and user should restart --- openpype/lib/__init__.py | 2 + openpype/lib/openpype_version.py | 37 ++++++------ openpype/style/data.json | 6 +- openpype/style/style.css | 8 +-- .../project_manager/project_manager/style.py | 2 +- .../project_manager/widgets.py | 2 +- openpype/tools/tray/pype_tray.py | 58 ++++++++++++++++--- 7 files changed, 81 insertions(+), 34 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index c556f2adc1..a2a16bcc00 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -171,6 +171,7 @@ from .editorial import ( from .openpype_version import ( get_openpype_version, get_build_version, + get_expected_version, is_running_from_build, is_current_version_studio_latest ) @@ -306,6 +307,7 @@ __all__ = [ "get_openpype_version", "get_build_version", + "get_expected_version", "is_running_from_build", "is_current_version_studio_latest", ] diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py index 839222018c..201bf646e9 100644 --- a/openpype/lib/openpype_version.py +++ b/openpype/lib/openpype_version.py @@ -153,6 +153,17 @@ def get_expected_studio_version(staging=None): return None +def get_expected_version(staging=None): + expected_version = get_expected_studio_version(staging) + if expected_version is None: + # Look for latest if expected version is not set in settings + expected_version = get_latest_version( + staging=staging, + remote=True + ) + return expected_version + + def is_current_version_studio_latest(): """Is currently running OpenPype version which is defined by studio. @@ -166,16 +177,13 @@ def is_current_version_studio_latest(): bool: True when is using studio """ output = None - # Skip if is not running from build - if not is_running_from_build(): - return output - - # Skip if build does not support version control - if not op_version_control_available(): - return output - - # Skip if path to folder with zip files is not accessible - if not openpype_path_is_accessible(): + # Skip if is not running from build or build does not support version + # control or path to folder with zip files is not accessible + if ( + not is_running_from_build() + or not op_version_control_available() + or not openpype_path_is_accessible() + ): return output # Get OpenPypeVersion class @@ -183,14 +191,7 @@ def is_current_version_studio_latest(): # Convert current version to OpenPypeVersion object current_version = OpenPypeVersion(version=get_openpype_version()) - staging = is_running_staging() # Get expected version (from settings) - expected_version = get_expected_studio_version(staging) - if expected_version is None: - # Look for latest if expected version is not set in settings - expected_version = get_latest_version( - staging=staging, - remote=True - ) + expected_version = get_expected_version() # Check if current version is expected version return current_version == expected_version diff --git a/openpype/style/data.json b/openpype/style/data.json index b3dffd7c71..6e1b6e822b 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,8 +51,10 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", - "delete-btn-bg": "rgb(201, 54, 54)", - "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "warning-btn-bg": "rgb(201, 54, 54)", + + "warning-btn-bg": "rgb(201, 54, 54)", + "warning-btn-bg-disabled": "rgba(201, 54, 54, 64)", "tab-widget": { "bg": "#21252B", diff --git a/openpype/style/style.css b/openpype/style/style.css index 7f7f30e2bc..65e8d0cb40 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -734,11 +734,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-view-hover}; } -#DeleteButton { - background: {color:delete-btn-bg}; +#WarningButton { + background: {color:warning-btn-bg}; } -#DeleteButton:disabled { - background: {color:delete-btn-bg-disabled}; +#WarningButton:disabled { + background: {color:warning-btn-bg-disabled}; } /* Launcher specific stylesheets */ diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index 9fa7a5520b..980c637bca 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -95,7 +95,7 @@ class ResourceCache: def get_warning_pixmap(cls): src_image = get_warning_image() colors = get_objected_colors() - color_value = colors["delete-btn-bg"] + color_value = colors["warning-btn-bg"] return paint_image_with_color( src_image, diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 4b5aca35ef..e58dcc7d0c 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -369,7 +369,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): cancel_btn = QtWidgets.QPushButton("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") confirm_btn = QtWidgets.QPushButton("Permanently Delete Project", self) - confirm_btn.setObjectName("DeleteButton") + confirm_btn.setObjectName("WarningButton") confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 5af82b2c64..c32cf17e18 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -17,7 +17,9 @@ from openpype.api import ( from openpype.lib import ( get_openpype_execute_args, is_current_version_studio_latest, - is_running_from_build + is_running_from_build, + get_expected_version, + get_openpype_version ) from openpype.modules import TrayModulesManager from openpype import style @@ -32,15 +34,30 @@ from .pype_info_widget import PypeInfoWidget class VersionDialog(QtWidgets.QDialog): + restart_requested = QtCore.Signal() + + _min_width = 400 + _min_height = 130 + def __init__(self, parent=None): super(VersionDialog, self).__init__(parent) - - label_widget = QtWidgets.QLabel( - "Your version does not match to studio version", self + self.setWindowTitle("Wrong OpenPype version") + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowFlags( + self.windowFlags() + | QtCore.Qt.WindowStaysOnTopHint ) + self.setMinimumWidth(self._min_width) + self.setMinimumHeight(self._min_height) + + label_widget = QtWidgets.QLabel(self) + label_widget.setWordWrap(True) + ignore_btn = QtWidgets.QPushButton("Ignore", self) - restart_btn = QtWidgets.QPushButton("Restart and Install", self) + ignore_btn.setObjectName("WarningButton") + restart_btn = QtWidgets.QPushButton("Restart and Change", self) btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) @@ -55,10 +72,22 @@ class VersionDialog(QtWidgets.QDialog): ignore_btn.clicked.connect(self._on_ignore) restart_btn.clicked.connect(self._on_reset) + self._label_widget = label_widget + + self.setStyleSheet(style.load_stylesheet()) + + def update_versions(self, current_version, expected_version): + message = ( + "Your OpenPype version {} does" + " not match to studio version {}" + ).format(str(current_version), str(expected_version)) + self._label_widget.setText(message) + def _on_ignore(self): self.reject() def _on_reset(self): + self.restart_requested.emit() self.accept() @@ -115,9 +144,22 @@ class TrayManager: if self._version_dialog is None: self._version_dialog = VersionDialog() - result = self._version_dialog.exec_() - if result: - self.restart() + self._version_dialog.restart_requested.connect( + self._restart_and_install + ) + + if self._version_dialog.isVisible(): + return + + expected_version = get_expected_version() + current_version = get_openpype_version() + self._version_dialog.update_versions( + current_version, expected_version + ) + self._version_dialog.exec_() + + def _restart_and_install(self): + self.restart() def execute_in_main_thread(self, callback, *args, **kwargs): if isinstance(callback, WrappedCallbackItem): From 644711c9d61f34e83b5f821d833c597b032a37b5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 18:16:23 +0100 Subject: [PATCH 153/229] status action gives information about openpype version --- .../ftrack/scripts/sub_event_status.py | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py b/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py index 004f61338c..3163642e3f 100644 --- a/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py +++ b/openpype/modules/default_modules/ftrack/scripts/sub_event_status.py @@ -16,8 +16,14 @@ from openpype_modules.ftrack.ftrack_server.lib import ( TOPIC_STATUS_SERVER_RESULT ) from openpype.api import Logger +from openpype.lib import ( + is_current_version_studio_latest, + is_running_from_build, + get_expected_version, + get_openpype_version +) -log = Logger().get_logger("Event storer") +log = Logger.get_logger("Event storer") action_identifier = ( "event.server.status" + os.environ["FTRACK_EVENT_SUB_ID"] ) @@ -203,8 +209,57 @@ class StatusFactory: }) return items + def openpype_version_items(self): + items = [] + is_latest = is_current_version_studio_latest() + items.append({ + "type": "label", + "value": "# OpenPype version" + }) + if not is_running_from_build(): + items.append({ + "type": "label", + "value": ( + "OpenPype event server is running from code {}." + ).format(str(get_openpype_version())) + }) + + elif is_latest is None: + items.append({ + "type": "label", + "value": ( + "Can't determine if OpenPype version is outdated" + " {}. OpenPype build version should be updated." + ).format(str(get_openpype_version())) + }) + elif is_latest: + items.append({ + "type": "label", + "value": "OpenPype version is up to date {}.".format( + str(get_openpype_version()) + ) + }) + else: + items.append({ + "type": "label", + "value": ( + "Using outdated OpenPype version {}." + " Expected version is {}." + "
- Please restart event server for automatic" + " updates or update manually." + ).format( + str(get_openpype_version()), + str(get_expected_version()) + ) + }) + + items.append({"type": "label", "value": "---"}) + + return items + def items(self): items = [] + items.extend(self.openpype_version_items()) items.append(self.note_item) items.extend(self.bool_items()) From 4ee86a6ce27f0a56b88926719c61bab308e5c144 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 18:26:13 +0100 Subject: [PATCH 154/229] show tray message when update dialog is ignored --- openpype/tools/tray/pype_tray.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index c32cf17e18..17251b404f 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -35,6 +35,7 @@ from .pype_info_widget import PypeInfoWidget class VersionDialog(QtWidgets.QDialog): restart_requested = QtCore.Signal() + ignore_requested = QtCore.Signal() _min_width = 400 _min_height = 130 @@ -73,9 +74,19 @@ class VersionDialog(QtWidgets.QDialog): restart_btn.clicked.connect(self._on_reset) self._label_widget = label_widget + self._restart_accepted = False self.setStyleSheet(style.load_stylesheet()) + def showEvent(self, event): + super().showEvent(event) + self._restart_accepted = False + + def closeEvent(self, event): + super().closeEvent(event) + if not self._restart_accepted: + self.ignore_requested.emit() + def update_versions(self, current_version, expected_version): message = ( "Your OpenPype version {} does" @@ -87,6 +98,7 @@ class VersionDialog(QtWidgets.QDialog): self.reject() def _on_reset(self): + self._restart_accepted = True self.restart_requested.emit() self.accept() @@ -147,6 +159,9 @@ class TrayManager: self._version_dialog.restart_requested.connect( self._restart_and_install ) + self._version_dialog.ignore_requested.connect( + self._outdated_version_ignored + ) if self._version_dialog.isVisible(): return @@ -161,6 +176,15 @@ class TrayManager: def _restart_and_install(self): self.restart() + def _outdated_version_ignored(self): + self.show_tray_message( + "Outdated OpenPype version", + ( + "Please update your OpenPype as soon as possible." + " All you have to do is to restart tray." + ) + ) + def execute_in_main_thread(self, callback, *args, **kwargs): if isinstance(callback, WrappedCallbackItem): item = callback From 687181e3825373894111f8a6267ad9fd9fe99917 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 10:58:36 +0100 Subject: [PATCH 155/229] interval of validation can be modified --- .../defaults/system_settings/general.json | 1 + .../schemas/system_schema/schema_general.json | 13 +++++++++++++ openpype/tools/tray/pype_tray.py | 17 ++++++++++++----- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index a07152eaf8..7c78de9a5c 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -4,6 +4,7 @@ "admin_password": "", "production_version": "", "staging_version": "", + "version_check_interval": 5, "environment": { "__environment_keys__": { "global": [] diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index b4c83fc85f..3af3f5ce35 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -47,6 +47,19 @@ { "type": "splitter" }, + { + "type": "label", + "label": "Trigger validation if running OpenPype is using studio defined version each 'n' minutes. Validation happens in OpenPype tray application." + }, + { + "type": "number", + "key": "version_check_interval", + "label": "Version check interval", + "minimum": 0 + }, + { + "type": "splitter" + }, { "key": "environment", "label": "Environment", diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 17251b404f..de1a8577b0 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -108,8 +108,6 @@ class TrayManager: Load submenus, actions, separators and modules into tray's context. """ - _version_check_interval = 5 * 60 * 1000 - def __init__(self, tray_widget, main_window): self.tray_widget = tray_widget self.main_window = main_window @@ -117,7 +115,15 @@ class TrayManager: self.log = Logger.get_logger(self.__class__.__name__) - self.module_settings = get_system_settings()["modules"] + system_settings = get_system_settings() + self.module_settings = system_settings["modules"] + + version_check_interval = system_settings["general"].get( + "version_check_interval" + ) + if version_check_interval is None: + version_check_interval = 5 + self._version_check_interval = version_check_interval * 60 * 1000 self.modules_manager = TrayModulesManager() @@ -247,9 +253,10 @@ class TrayManager: self.main_thread_timer = main_thread_timer version_check_timer = QtCore.QTimer() - version_check_timer.setInterval(self._version_check_interval) version_check_timer.timeout.connect(self._on_version_check_timer) - version_check_timer.start() + if self._version_check_interval > 0: + version_check_timer.setInterval(self._version_check_interval) + version_check_timer.start() self._version_check_timer = version_check_timer # For storing missing settings dialog From 0ebd7881c144a16e98a8923c4f5e2f8ea22a355e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 11:40:38 +0100 Subject: [PATCH 156/229] fixed reseting from staging --- openpype/tools/tray/pype_tray.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index de1a8577b0..7f78140211 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -18,6 +18,7 @@ from openpype.lib import ( get_openpype_execute_args, is_current_version_studio_latest, is_running_from_build, + is_running_staging, get_expected_version, get_openpype_version ) @@ -349,17 +350,25 @@ class TrayManager: First creates new process with same argument and close current tray. """ args = get_openpype_execute_args() + kwargs = { + "env": dict(os.environ.items()) + } + # Create a copy of sys.argv additional_args = list(sys.argv) # Check last argument from `get_openpype_execute_args` # - when running from code it is the same as first from sys.argv if args[-1] == additional_args[0]: additional_args.pop(0) - args.extend(additional_args) - kwargs = { - "env": dict(os.environ.items()) - } + # Pop OPENPYPE_VERSION + if reset_version: + # Add staging flag if was running from staging + if is_running_staging(): + args.append("--use-staging") + kwargs["env"].pop("OPENPYPE_VERSION", None) + + args.extend(additional_args) if platform.system().lower() == "windows": flags = ( subprocess.CREATE_NEW_PROCESS_GROUP @@ -367,9 +376,6 @@ class TrayManager: ) kwargs["creationflags"] = flags - if reset_version: - kwargs["env"].pop("OPENPYPE_VERSION", None) - subprocess.Popen(args, **kwargs) self.exit() From 4a230b710ea605fce9b9edadb455d0277301032a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 11:53:04 +0100 Subject: [PATCH 157/229] flame: add function to get flame version and root install path --- openpype/hosts/flame/api/__init__.py | 6 +++++- openpype/hosts/flame/api/utils.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index fce59af506..e7590bb36e 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -30,7 +30,9 @@ from .lib import ( get_padding_from_path ) from .utils import ( - setup + setup, + get_flame_version, + get_flame_install_root ) from .pipeline import ( install, @@ -107,6 +109,8 @@ __all__ = [ # utils "setup", + "get_flame_version", + "get_flame_install_root", # menu "FlameMenuProjectConnect", diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index b9899900f5..0e40e40aa7 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -125,3 +125,18 @@ def setup(env=None): _sync_utility_scripts(env) log.info("Flame OpenPype wrapper has been installed") + + +def get_flame_version(): + import flame + + return { + "full": flame.get_version(), + "major": flame.get_version_major(), + "minor": flame.get_version_minor(), + "patch": flame.get_version_patch() + } + + +def get_flame_install_root(): + return "/opt/Autodesk" \ No newline at end of file From cc20a22e3ad70639d58c55e65e68b67e24264fca Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 11:53:35 +0100 Subject: [PATCH 158/229] flame: add function to maintain object duplication --- openpype/hosts/flame/api/__init__.py | 4 +++- openpype/hosts/flame/api/lib.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index e7590bb36e..7f516fb11f 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -27,7 +27,8 @@ from .lib import ( get_clips_in_reels, get_reformated_path, get_frame_from_path, - get_padding_from_path + get_padding_from_path, + maintained_object_duplication ) from .utils import ( setup, @@ -93,6 +94,7 @@ __all__ = [ "get_reformated_path", "get_frame_from_path", "get_padding_from_path", + "maintained_object_duplication", # pipeline "install", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index b963a1cb39..800afebf41 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -675,3 +675,26 @@ def get_frame_from_path(path): return found.pop() else: return None + + + +@contextlib.contextmanager +def maintained_object_duplication(item): + """Maintain input item duplication + + Attributes: + item (any flame.PyObject): python api object + + Yield: + duplicate input PyObject type + """ + import flame + # Duplicate the clip to avoid modifying the original clip + duplicate = flame.duplicate(item) + + try: + # do the operation on selected segments + yield duplicate + finally: + # delete the item at the end + flame.delete(duplicate) From f6ab7f2cbaef91afa7ca5a35f3a540c22b7529e6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 12:06:35 +0100 Subject: [PATCH 159/229] flame: adding settings for `ExtractSubsetResources` plugin --- .../defaults/project_settings/flame.json | 12 ++++ .../projects_schema/schema_project_flame.json | 55 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index b6fbdecc95..ed54d631be 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -16,5 +16,17 @@ "handleStart": 10, "handleEnd": 10 } + }, + "publish": { + "ExtractSubsetResources": { + "export_presets_mapping": { + "exr16fpdwaa": { + "ext": "exr", + "xmlPresetDir": "", + "xmlPresetFile": "OpenEXR (16-bit fp DWAA).xml", + "representationTags": [] + } + } + } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index d713c37620..6ca5fc049d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -119,6 +119,61 @@ ] } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "ExtractSubsetResources", + "label": "Extract Subset Resources", + "is_group": true, + "children": [ + { + "key": "export_presets_mapping", + "label": "Export presets mapping", + "type": "dict-modifiable", + "highlight_content": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "ext", + "label": "Output extension", + "type": "text" + }, + { + "key": "xmlPresetFile", + "label": "XML preset file (with ext)", + "type": "text" + }, + { + "key": "xmlPresetDir", + "label": "XML preset folder (optional)", + "type": "text" + }, + { + "type": "separator" + }, + { + "type": "list", + "key": "representationTags", + "label": "Add representation tags", + "object_type": { + "type": "text", + "multiline": false + } + } + ] + } + } + ] + } + ] } ] } From 0e96a2e3b1d4f0481ec425ecfa0275e885185099 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 12:50:55 +0100 Subject: [PATCH 160/229] flame: update utils modules --- openpype/hosts/flame/api/__init__.py | 6 +- openpype/hosts/flame/api/render_utils.py | 135 +++++++++++++---------- openpype/hosts/flame/api/utils.py | 2 +- 3 files changed, 82 insertions(+), 61 deletions(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 7f516fb11f..656ba11617 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -63,7 +63,8 @@ from .workio import ( work_root ) from .render_utils import ( - export_clip + export_clip, + get_preset_path_by_xml_name ) __all__ = [ @@ -131,5 +132,6 @@ __all__ = [ "work_root", # render utils - "export_clip" + "export_clip", + "get_preset_path_by_xml_name" ] diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index d2e312785f..1cc94f6548 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,18 +1,15 @@ import os -SHARED_PRESET_PATH = '/opt/Autodesk/shared/export/presets' - -def export_clip(export_path, clip, export_preset, **kwargs): +def export_clip(export_path, clip, preset_path, **kwargs): """Flame exported wrapper Args: export_path (str): exporting directory path clip (PyClip): flame api object - export_preset (str): name of exporting preset xml file + preset_path (str): full export path to xml file Kwargs: - export_type (str)[optional]: name of export type folder thumb_frame_number (int)[optional]: source frame number in_mark (int)[optional]: cut in mark out_mark (int)[optional]: cut out mark @@ -20,8 +17,6 @@ def export_clip(export_path, clip, export_preset, **kwargs): Raises: KeyError: Missing input kwarg `thumb_frame_number` in case `thumbnail` in `export_preset` - KeyError: Missing input kwarg `export_type` - in case of other `export_preset` then `thumbnail` FileExistsError: Missing export preset in shared folder """ import flame @@ -33,11 +28,8 @@ def export_clip(export_path, clip, export_preset, **kwargs): exporter.foreground = True exporter.export_between_marks = True - # Duplicate the clip to avoid modifying the original clip - duplicate_clip = flame.duplicate(clip) - - if export_preset == 'thumbnail': - thumb_frame_number = kwargs.get("thumb_frame_number") + if kwargs.get("thumb_frame_number"): + thumb_frame_number = kwargs["thumb_frame_number"] # make sure it exists in kwargs if not thumb_frame_number: raise KeyError( @@ -46,61 +38,88 @@ def export_clip(export_path, clip, export_preset, **kwargs): in_mark = int(thumb_frame_number) out_mark = int(thumb_frame_number) + 1 - # In case Thumbnail is needed - preset_dir = flame.PyExporter.get_presets_dir( - flame.PyExporter.PresetVisibility.Autodesk, - flame.PyExporter.PresetType.Image_Sequence) - export_preset_path = os.path.join( - preset_dir, "Jpeg", "Jpeg (8-bit).xml") - + elif kwargs.get("in_mark") and kwargs.get("out_mark"): + in_mark = int(kwargs["in_mark"]) + out_mark = int(kwargs["out_mark"]) else: - # In case other output is needed - # get compulsory kwargs - export_type = kwargs.get("export_type") - # make sure it exists in kwargs - if not export_type: - raise KeyError( - "Missing key `export_type` in input kwargs") - - # create full shared preset path - shared_preset_dir = os.path.join( - SHARED_PRESET_PATH, export_type - ) - - # check if export preset is available in shared presets - shared_presets = [ - preset[:-4] for preset in os.listdir(shared_preset_dir)] - if export_preset not in shared_presets: - raise FileExistsError( - "Missing preset file `{}` in `{}`".format( - export_preset, - shared_preset_dir - )) - - export_preset_path = os.path.join( - shared_preset_dir, export_preset + '.xml') - - # check if mark in/out is set in kwargs - if kwargs.get("in_mark") and kwargs.get("out_mark"): - in_mark = int(kwargs["in_mark"]) - out_mark = int(kwargs["out_mark"]) - else: - exporter.export_between_marks = False + exporter.export_between_marks = False try: # set in and out marks if they are available if in_mark and out_mark: - duplicate_clip.in_mark = in_mark - duplicate_clip.out_mark = out_mark + clip.in_mark = in_mark + clip.out_mark = out_mark # export with exporter - exporter.export(duplicate_clip, export_preset_path, export_path) + exporter.export(clip, preset_path, export_path) finally: print('Exported: {} at {}-{}'.format( clip.name.get_value(), - duplicate_clip.in_mark, - duplicate_clip.out_mark + clip.in_mark, + clip.out_mark )) - # delete duplicated clip it is not needed anymore - flame.delete(duplicate_clip) + +def get_preset_path_by_xml_name(xml_preset_name): + def _search_path(root): + output = [] + for root, dirs, files in os.walk(root): + for f in files: + if f != xml_preset_name: + continue + file_path = os.path.join(root, f) + output.append(file_path) + return output + + def _validate_results(results): + if results and len(results) == 1: + return results.pop() + elif results and len(results) > 1: + print(( + "More matching presets for `{}`: /n" + "{}").format(xml_preset_name, results)) + return results.pop() + else: + return None + + from .utils import ( + get_flame_install_root, + get_flame_version + ) + + # get actual flame version and install path + _version = get_flame_version()["full"] + _install_root = get_flame_install_root() + + # search path templates + shared_search_root = "{install_root}/shared/export/presets" + install_search_root = ( + "{install_root}/presets/{version}/export/presets/flame") + + # fill templates + shared_search_root = shared_search_root.format( + install_root=_install_root + ) + install_search_root = install_search_root.format( + install_root=_install_root, + version=_version + ) + + # get search results + shared_results = _search_path(shared_search_root) + installed_results = _search_path(install_search_root) + + # first try to return shared results + shared_preset_path = _validate_results(shared_results) + + if shared_preset_path: + return os.path.dirname(shared_preset_path) + + # then try installed results + installed_preset_path = _validate_results(installed_results) + + if installed_preset_path: + return os.path.dirname(installed_preset_path) + + # if nothing found then return None + return False diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 0e40e40aa7..9939371358 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -139,4 +139,4 @@ def get_flame_version(): def get_flame_install_root(): - return "/opt/Autodesk" \ No newline at end of file + return "/opt/Autodesk" From 183acf4bd3b34dd046401434c67932ad9e8b6050 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 12:51:48 +0100 Subject: [PATCH 161/229] flame: update export plugin with more dynamic preset path abstraction --- .../publish/extract_subset_resources.py | 155 +++++++++++------- 1 file changed, 96 insertions(+), 59 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 6061c80762..3a8fd631d8 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -1,4 +1,5 @@ import os +from copy import deepcopy import pyblish.api import openpype.api from openpype.hosts.flame import api as opfapi @@ -14,25 +15,29 @@ class ExtractSubsetResources(openpype.api.Extractor): families = ["clip"] hosts = ["flame"] + # plugin defaults + default_presets = { + "thumbnail": { + "ext": "jpg", + "xmlPresetFile": "Jpeg (8-bit).xml", + "xmlPresetDir": "", + "representationTags": ["thumbnail"] + }, + "ftrackpreview": { + "ext": "mov", + "xmlPresetFile": "Apple iPad (1920x1080).xml", + "xmlPresetDir": "", + "representationTags": [ + "review", + "delete" + ] + } + } # hide publisher during exporting hide_ui_on_process = True - export_presets_mapping = { - "thumbnail": { - "ext": "jpg", - "uniqueName": "thumbnail" - }, - "OpenEXR (16-bit fp DWAA)_custom": { - "ext": "exr", - "preset_type": "file_sequence", - "uniqueName": "exr16fpdwaa" - }, - "QuickTime (H.264 1080p 8Mbits)_custom": { - "ext": "mov", - "preset_type": "movie_file", - "uniqueName": "ftrackpreview" - } - } + # settings + export_presets_mapping = {} def process(self, instance): # create representation data @@ -53,54 +58,86 @@ class ExtractSubsetResources(openpype.api.Extractor): staging_dir = self.staging_dir(instance) - # loop all preset names and - for preset_name, preset_config in self.export_presets_mapping.items(): - kwargs = {} - unique_name = preset_config["uniqueName"] - preset_type = None + # add default preset type for thumbnail and reviewable video + # update them with settings and overide in case the same + # are found in there + export_presets = deepcopy(self.default_presets) + export_presets.update(self.export_presets_mapping) - # define kwargs based on preset type - if "thumbnail" in preset_name: - kwargs["thumb_frame_number"] = in_mark + ( - source_duration_handles / 2) - else: - preset_type = preset_config["preset_type"] - kwargs.update({ - "in_mark": in_mark, - "out_mark": out_mark, - "export_type": preset_type - }) + # with maintained duplication loop all presets + with opfapi.maintained_object_duplication(clip) as duplclip: + # loop all preset names and + for unique_name, preset_config in export_presets.items(): + kwargs = {} + preset_file = preset_config["xmlPresetFile"] + preset_dir = preset_config["xmlPresetDir"] - export_dir_path = os.path.join( - staging_dir, unique_name - ) - os.makedirs(export_dir_path) + # validate xml preset file is filled + if preset_file == "": + raise ValueError( + ("Check Settings for {} preset: " + "`XML preset file` is not filled").format( + unique_name) + ) - # export - opfapi.export_clip( - export_dir_path, clip, preset_name, **kwargs) + # resolve xml preset dir if not filled + if preset_dir == "": + preset_dir = opfapi.get_preset_path_by_xml_name( + preset_file) - # create representation data - representation_data = { - 'name': unique_name, - 'ext': preset_config["ext"], - "stagingDir": export_dir_path, - } + if not preset_dir: + raise ValueError( + ("Check Settings for {} preset: " + "`XML preset file` {} is not found").format( + unique_name, preset_file) + ) - files = os.listdir(export_dir_path) + # create preset path + preset_path = os.path.join( + preset_dir, preset_file + ) - # add files to represetation but add - # imagesequence as list - if ( - preset_type - and preset_type == "movie_file" - or preset_name == "thumbnail" - ): - representation_data["files"] = files.pop() - else: - representation_data["files"] = files + # define kwargs based on preset type + if "thumbnail" in unique_name: + kwargs["thumb_frame_number"] = in_mark + ( + source_duration_handles / 2) + else: + kwargs.update({ + "in_mark": in_mark, + "out_mark": out_mark + }) - instance.data["representations"].append(representation_data) + export_dir_path = os.path.join( + staging_dir, unique_name + ) + os.makedirs(export_dir_path) - self.log.info("Added representation: {}".format( - representation_data)) + # export + opfapi.export_clip( + export_dir_path, duplclip, preset_path, **kwargs) + + # create representation data + representation_data = { + "name": unique_name, + "outputName": unique_name, + "ext": preset_config["ext"], + "stagingDir": export_dir_path, + "tags": preset_config["representationTags"] + } + + files = os.listdir(export_dir_path) + + # add files to represetation but add + # imagesequence as list + if ( + "movie_file" in preset_path + or unique_name == "thumbnail" + ): + representation_data["files"] = files.pop() + else: + representation_data["files"] = files + + instance.data["representations"].append(representation_data) + + self.log.info("Added representation: {}".format( + representation_data)) From aa39f98ae626b702d02b5b57b786ee22bd0c8252 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 12:57:26 +0100 Subject: [PATCH 162/229] flame: add bool to settings to control if range will be added to repres --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 2 ++ openpype/settings/defaults/project_settings/flame.json | 1 + .../schemas/projects_schema/schema_project_flame.json | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 3a8fd631d8..b2a737cbcb 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -21,12 +21,14 @@ class ExtractSubsetResources(openpype.api.Extractor): "ext": "jpg", "xmlPresetFile": "Jpeg (8-bit).xml", "xmlPresetDir": "", + "representationAddRange": False, "representationTags": ["thumbnail"] }, "ftrackpreview": { "ext": "mov", "xmlPresetFile": "Apple iPad (1920x1080).xml", "xmlPresetDir": "", + "representationAddRange": False, "representationTags": [ "review", "delete" diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index ed54d631be..dfecd8a12e 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -24,6 +24,7 @@ "ext": "exr", "xmlPresetDir": "", "xmlPresetFile": "OpenEXR (16-bit fp DWAA).xml", + "representationAddRange": false, "representationTags": [] } } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index 6ca5fc049d..8ad2b11616 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -159,6 +159,11 @@ { "type": "separator" }, + { + "type": "boolean", + "key": "representationAddRange", + "label": "Add frame range to representation" + }, { "type": "list", "key": "representationTags", From 438d6df439cbec5ebe9da311dbad4a6cda7144d2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 15:56:36 +0100 Subject: [PATCH 163/229] burnins fix bit rate for dnxhd mxf passing metadata to burnins --- openpype/scripts/otio_burnin.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 3fc1412e62..639657d68f 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -157,6 +157,16 @@ def _dnxhd_codec_args(stream_data, source_ffmpeg_cmd): if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-b:v", "-vb", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + output.extend([arg, args[idx + 1]]) + output.extend(["-g", "1"]) return output @@ -716,6 +726,15 @@ def burnins_from_data( ffmpeg_args.extend( get_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd) ) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-metadata", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + ffmpeg_args.extend([arg, args[idx + 1]]) # Use group one (same as `-intra` argument, which is deprecated) ffmpeg_args_str = " ".join(ffmpeg_args) From 1150de03b307105f39d99a6f96ec8cab5a0ccb2b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 Jan 2022 11:02:21 +0100 Subject: [PATCH 164/229] format output arguments with anatomy data --- openpype/plugins/publish/extract_review.py | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b6c2e49385..be29c7bf9c 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -187,6 +187,7 @@ class ExtractReview(pyblish.api.InstancePlugin): outputs_per_repres = self._get_outputs_per_representations( instance, profile_outputs ) + fill_data = copy.deepcopy(instance.data["anatomyData"]) for repre, outputs in outputs_per_repres: # Check if input should be preconverted before processing # Store original staging dir (it's value may change) @@ -293,7 +294,7 @@ class ExtractReview(pyblish.api.InstancePlugin): try: # temporary until oiiotool is supported cross platform ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data + output_def, instance, new_repre, temp_data, fill_data ) except ZeroDivisionError: if 'exr' in temp_data["origin_repre"]["ext"]: @@ -446,7 +447,9 @@ class ExtractReview(pyblish.api.InstancePlugin): "handles_are_set": handles_are_set } - def _ffmpeg_arguments(self, output_def, instance, new_repre, temp_data): + def _ffmpeg_arguments( + self, output_def, instance, new_repre, temp_data, fill_data + ): """Prepares ffmpeg arguments for expected extraction. Prepares input and output arguments based on output definition and @@ -472,9 +475,6 @@ class ExtractReview(pyblish.api.InstancePlugin): ffmpeg_input_args = [ value for value in _ffmpeg_input_args if value.strip() ] - ffmpeg_output_args = [ - value for value in _ffmpeg_output_args if value.strip() - ] ffmpeg_video_filters = [ value for value in _ffmpeg_video_filters if value.strip() ] @@ -482,6 +482,21 @@ class ExtractReview(pyblish.api.InstancePlugin): value for value in _ffmpeg_audio_filters if value.strip() ] + ffmpeg_output_args = [] + for value in _ffmpeg_output_args: + value = value.strip() + if not value: + continue + try: + value = value.format(**fill_data) + except Exception: + self.log.warning( + "Failed to format ffmpeg argument: {}".format(value), + exc_info=True + ) + pass + ffmpeg_output_args.append(value) + # Prepare input and output filepaths self.input_output_paths(new_repre, output_def, temp_data) From eed31e543372d3616433aa08d8250993fbc7d0e2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 15:56:36 +0100 Subject: [PATCH 165/229] burnins fix bit rate for dnxhd mxf passing metadata to burnins --- openpype/scripts/otio_burnin.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 15a62ef38e..63a8b064db 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -157,6 +157,16 @@ def _dnxhd_codec_args(stream_data, source_ffmpeg_cmd): if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-b:v", "-vb", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + output.extend([arg, args[idx + 1]]) + output.extend(["-g", "1"]) return output @@ -715,6 +725,15 @@ def burnins_from_data( ffmpeg_args.extend( get_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd) ) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-metadata", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + ffmpeg_args.extend([arg, args[idx + 1]]) # Use group one (same as `-intra` argument, which is deprecated) ffmpeg_args_str = " ".join(ffmpeg_args) From 17578c54471ad931bc48afc82b5aa8ccd4a29908 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 16:20:05 +0100 Subject: [PATCH 166/229] fix import if 'is_running_staging' --- openpype/lib/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index a2a16bcc00..62d204186d 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -173,6 +173,7 @@ from .openpype_version import ( get_build_version, get_expected_version, is_running_from_build, + is_running_staging, is_current_version_studio_latest ) @@ -309,5 +310,6 @@ __all__ = [ "get_build_version", "get_expected_version", "is_running_from_build", + "is_running_staging", "is_current_version_studio_latest", ] From 26c3ba7e1be4e7a94c3cf8d6869e813dd716bc25 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 17:37:19 +0100 Subject: [PATCH 167/229] flame: add frame ranges to representation --- .../plugins/publish/extract_subset_resources.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index b2a737cbcb..3495309409 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -28,7 +28,7 @@ class ExtractSubsetResources(openpype.api.Extractor): "ext": "mov", "xmlPresetFile": "Apple iPad (1920x1080).xml", "xmlPresetDir": "", - "representationAddRange": False, + "representationAddRange": True, "representationTags": [ "review", "delete" @@ -46,6 +46,9 @@ class ExtractSubsetResources(openpype.api.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] + frame_start = instance.data["frameStart"] + handle_start = instance.data["handleStart"] + frame_start_handle = frame_start - handle_start source_first_frame = instance.data["sourceFirstFrame"] source_start_handles = instance.data["sourceStartH"] source_end_handles = instance.data["sourceEndH"] @@ -139,6 +142,15 @@ class ExtractSubsetResources(openpype.api.Extractor): else: representation_data["files"] = files + # add frame range + if preset_config["representationAddRange"]: + representation_data.update({ + "frameStart": frame_start_handle, + "frameEnd": ( + frame_start_handle + source_duration_handles), + "fps": instance.data["fps"] + }) + instance.data["representations"].append(representation_data) self.log.info("Added representation: {}".format( From ce5c70e28d99d1528ab12e75be91c1a219b38aae Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 17:47:11 +0100 Subject: [PATCH 168/229] change back project manager styles --- openpype/style/data.json | 5 ++--- openpype/style/style.css | 13 +++++++++---- .../tools/project_manager/project_manager/style.py | 2 +- .../project_manager/project_manager/widgets.py | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/openpype/style/data.json b/openpype/style/data.json index 6e1b6e822b..c8adc0674a 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,10 +51,9 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", - "warning-btn-bg": "rgb(201, 54, 54)", - "warning-btn-bg": "rgb(201, 54, 54)", - "warning-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "delete-btn-bg": "rgb(201, 54, 54)", + "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", "tab-widget": { "bg": "#21252B", diff --git a/openpype/style/style.css b/openpype/style/style.css index 65e8d0cb40..d9b0ff7421 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -734,11 +734,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-view-hover}; } -#WarningButton { - background: {color:warning-btn-bg}; +#DeleteButton { + background: {color:delete-btn-bg}; } -#WarningButton:disabled { - background: {color:warning-btn-bg-disabled}; +#DeleteButton:disabled { + background: {color:delete-btn-bg-disabled}; } /* Launcher specific stylesheets */ @@ -1228,6 +1228,11 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: #21252B; } +/* Tray */ +#TrayRestartButton { + background: {color:restart-btn-bg}; +} + /* Globally used names */ #Separator { background: {color:bg-menu-separator}; diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index 980c637bca..9fa7a5520b 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -95,7 +95,7 @@ class ResourceCache: def get_warning_pixmap(cls): src_image = get_warning_image() colors = get_objected_colors() - color_value = colors["warning-btn-bg"] + color_value = colors["delete-btn-bg"] return paint_image_with_color( src_image, diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index e58dcc7d0c..4b5aca35ef 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -369,7 +369,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): cancel_btn = QtWidgets.QPushButton("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") confirm_btn = QtWidgets.QPushButton("Permanently Delete Project", self) - confirm_btn.setObjectName("WarningButton") + confirm_btn.setObjectName("DeleteButton") confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") From a18bdbc418e004bb8d3c0606296d1649872fa74b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 Jan 2022 17:53:56 +0100 Subject: [PATCH 169/229] changed dialog and added restart and update action to tray --- openpype/style/data.json | 1 + openpype/tools/tray/images/gifts.png | Bin 0 -> 8605 bytes openpype/tools/tray/pype_tray.py | 118 +++++++++++++++++++++++---- openpype/tools/utils/__init__.py | 6 +- 4 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 openpype/tools/tray/images/gifts.png diff --git a/openpype/style/data.json b/openpype/style/data.json index c8adc0674a..1db0c732cf 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,6 +51,7 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", + "restart-btn-bg": "#458056", "delete-btn-bg": "rgb(201, 54, 54)", "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", diff --git a/openpype/tools/tray/images/gifts.png b/openpype/tools/tray/images/gifts.png new file mode 100644 index 0000000000000000000000000000000000000000..57fb3f286312878c641f27362653ede1b7746810 GIT binary patch literal 8605 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 T%VmTC_`iqu)s>1NPv86(+fBse literal 0 HcmV?d00001 diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 7f78140211..0d3e7ae04c 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -29,11 +29,51 @@ from openpype.settings import ( ProjectSettings, DefaultsNotDefined ) -from openpype.tools.utils import WrappedCallbackItem +from openpype.tools.utils import ( + WrappedCallbackItem, + paint_image_with_color +) from .pype_info_widget import PypeInfoWidget +# TODO PixmapLabel should be moved to 'utils' in other future PR so should be +# imported from there +class PixmapLabel(QtWidgets.QLabel): + """Label resizing image to height of font.""" + def __init__(self, pixmap, parent): + super(PixmapLabel, self).__init__(parent) + self._empty_pixmap = QtGui.QPixmap(0, 0) + self._source_pixmap = pixmap + + def set_source_pixmap(self, pixmap): + """Change source image.""" + self._source_pixmap = pixmap + self._set_resized_pix() + + def _get_pix_size(self): + size = self.fontMetrics().height() * 3 + return size, size + + def _set_resized_pix(self): + if self._source_pixmap is None: + self.setPixmap(self._empty_pixmap) + return + width, height = self._get_pix_size() + self.setPixmap( + self._source_pixmap.scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) + + def resizeEvent(self, event): + self._set_resized_pix() + super(PixmapLabel, self).resizeEvent(event) + + class VersionDialog(QtWidgets.QDialog): restart_requested = QtCore.Signal() ignore_requested = QtCore.Signal() @@ -43,7 +83,7 @@ class VersionDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(VersionDialog, self).__init__(parent) - self.setWindowTitle("Wrong OpenPype version") + self.setWindowTitle("OpenPype update is needed") icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( @@ -54,12 +94,23 @@ class VersionDialog(QtWidgets.QDialog): self.setMinimumWidth(self._min_width) self.setMinimumHeight(self._min_height) - label_widget = QtWidgets.QLabel(self) + top_widget = QtWidgets.QWidget(self) + + gift_pixmap = self._get_gift_pixmap() + gift_icon_label = PixmapLabel(gift_pixmap, top_widget) + + label_widget = QtWidgets.QLabel(top_widget) label_widget.setWordWrap(True) - ignore_btn = QtWidgets.QPushButton("Ignore", self) - ignore_btn.setObjectName("WarningButton") - restart_btn = QtWidgets.QPushButton("Restart and Change", self) + top_layout = QtWidgets.QHBoxLayout(top_widget) + # top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.setSpacing(10) + top_layout.addWidget(gift_icon_label, 0, QtCore.Qt.AlignCenter) + top_layout.addWidget(label_widget, 1) + + ignore_btn = QtWidgets.QPushButton("Later", self) + restart_btn = QtWidgets.QPushButton("Restart && Update", self) + restart_btn.setObjectName("TrayRestartButton") btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) @@ -67,7 +118,7 @@ class VersionDialog(QtWidgets.QDialog): btns_layout.addWidget(restart_btn, 0) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(label_widget, 0) + layout.addWidget(top_widget, 0) layout.addStretch(1) layout.addLayout(btns_layout, 0) @@ -79,6 +130,21 @@ class VersionDialog(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) + def _get_gift_pixmap(self): + image_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + "gifts.png" + ) + src_image = QtGui.QImage(image_path) + colors = style.get_objected_colors() + color_value = colors["font"] + + return paint_image_with_color( + src_image, + color_value.get_qcolor() + ) + def showEvent(self, event): super().showEvent(event) self._restart_accepted = False @@ -90,8 +156,8 @@ class VersionDialog(QtWidgets.QDialog): def update_versions(self, current_version, expected_version): message = ( - "Your OpenPype version {} does" - " not match to studio version {}" + "Running OpenPype version is {}." + " Your production has been updated to version {}." ).format(str(current_version), str(expected_version)) self._label_widget.setText(message) @@ -113,6 +179,7 @@ class TrayManager: self.tray_widget = tray_widget self.main_window = main_window self.pype_info_widget = None + self._restart_action = None self.log = Logger.get_logger(self.__class__.__name__) @@ -158,7 +225,14 @@ class TrayManager: self.validate_openpype_version() def validate_openpype_version(self): - if is_current_version_studio_latest(): + using_requested = is_current_version_studio_latest() + self._restart_action.setVisible(not using_requested) + if using_requested: + if ( + self._version_dialog is not None + and self._version_dialog.isVisible() + ): + self._version_dialog.close() return if self._version_dialog is None: @@ -170,25 +244,24 @@ class TrayManager: self._outdated_version_ignored ) - if self._version_dialog.isVisible(): - return - expected_version = get_expected_version() current_version = get_openpype_version() self._version_dialog.update_versions( current_version, expected_version ) - self._version_dialog.exec_() + self._version_dialog.show() + self._version_dialog.raise_() + self._version_dialog.activateWindow() def _restart_and_install(self): self.restart() def _outdated_version_ignored(self): self.show_tray_message( - "Outdated OpenPype version", + "OpenPype version is outdated", ( "Please update your OpenPype as soon as possible." - " All you have to do is to restart tray." + " To update, restart OpenPype Tray application." ) ) @@ -341,9 +414,22 @@ class TrayManager: version_action = QtWidgets.QAction(version_string, self.tray_widget) version_action.triggered.connect(self._on_version_action) + + restart_action = QtWidgets.QAction( + "Restart && Update", self.tray_widget + ) + restart_action.triggered.connect(self._on_restart_action) + restart_action.setVisible(False) + self.tray_widget.menu.addAction(version_action) + self.tray_widget.menu.addAction(restart_action) self.tray_widget.menu.addSeparator() + self._restart_action = restart_action + + def _on_restart_action(self): + self.restart() + def restart(self, reset_version=True): """Restart Tray tool. diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 65025ac358..eb0cb1eef5 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -6,7 +6,10 @@ from .widgets import ( ) from .error_dialog import ErrorMessageBox -from .lib import WrappedCallbackItem +from .lib import ( + WrappedCallbackItem, + paint_image_with_color +) __all__ = ( @@ -18,4 +21,5 @@ __all__ = ( "ErrorMessageBox", "WrappedCallbackItem", + "paint_image_with_color", ) From ea469e213031bedc412a9368e4668e6b0d18bc98 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 17:55:43 +0100 Subject: [PATCH 170/229] flame: fixing extract exporter --- .../plugins/publish/extract_subset_resources.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 3495309409..8bdcf989b6 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -76,6 +76,7 @@ class ExtractSubsetResources(openpype.api.Extractor): kwargs = {} preset_file = preset_config["xmlPresetFile"] preset_dir = preset_config["xmlPresetDir"] + repre_tags = preset_config["representationTags"] # validate xml preset file is filled if preset_file == "": @@ -98,9 +99,9 @@ class ExtractSubsetResources(openpype.api.Extractor): ) # create preset path - preset_path = os.path.join( + preset_path = str(os.path.join( preset_dir, preset_file - ) + )) # define kwargs based on preset type if "thumbnail" in unique_name: @@ -112,9 +113,9 @@ class ExtractSubsetResources(openpype.api.Extractor): "out_mark": out_mark }) - export_dir_path = os.path.join( + export_dir_path = str(os.path.join( staging_dir, unique_name - ) + )) os.makedirs(export_dir_path) # export @@ -127,7 +128,7 @@ class ExtractSubsetResources(openpype.api.Extractor): "outputName": unique_name, "ext": preset_config["ext"], "stagingDir": export_dir_path, - "tags": preset_config["representationTags"] + "tags": repre_tags } files = os.listdir(export_dir_path) @@ -153,5 +154,9 @@ class ExtractSubsetResources(openpype.api.Extractor): instance.data["representations"].append(representation_data) + # add review family if found in tags + if "review" in repre_tags: + instance.data["families"].append("review") + self.log.info("Added representation: {}".format( representation_data)) From 25b54be8727d35a608e64236f1189468ca9fe4ae Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 14 Jan 2022 17:56:16 +0100 Subject: [PATCH 171/229] flame: adding host to some extract plugins --- openpype/plugins/publish/extract_burnin.py | 3 ++- openpype/plugins/publish/extract_otio_audio_tracks.py | 2 +- openpype/plugins/publish/extract_otio_review.py | 2 +- openpype/plugins/publish/extract_otio_trimming_video.py | 2 +- openpype/plugins/publish/extract_review.py | 3 ++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 459c66ee43..7ff1b24689 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -48,7 +48,8 @@ class ExtractBurnin(openpype.api.Extractor): "tvpaint", "webpublisher", "aftereffects", - "photoshop" + "photoshop", + "flame" # "resolve" ] optional = True diff --git a/openpype/plugins/publish/extract_otio_audio_tracks.py b/openpype/plugins/publish/extract_otio_audio_tracks.py index be0bae5cdc..00c1748cdc 100644 --- a/openpype/plugins/publish/extract_otio_audio_tracks.py +++ b/openpype/plugins/publish/extract_otio_audio_tracks.py @@ -19,7 +19,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): order = pyblish.api.ExtractorOrder - 0.44 label = "Extract OTIO Audio Tracks" - hosts = ["hiero", "resolve"] + hosts = ["hiero", "resolve", "flame"] # FFmpeg tools paths ffmpeg_path = get_ffmpeg_tool_path("ffmpeg") diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index ed2ba017d5..79d5b2fc8f 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -41,7 +41,7 @@ class ExtractOTIOReview(openpype.api.Extractor): order = api.ExtractorOrder - 0.45 label = "Extract OTIO review" families = ["review"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] # plugin default attributes temp_file_head = "tempFile." diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index 3e2d39c99c..30b57e2c69 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -19,7 +19,7 @@ class ExtractOTIOTrimmingVideo(openpype.api.Extractor): order = api.ExtractorOrder label = "Extract OTIO trim longer video" families = ["trim"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance): self.staging_dir = self.staging_dir(instance) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b6c2e49385..b27cca0085 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -51,7 +51,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "tvpaint", "resolve", "webpublisher", - "aftereffects" + "aftereffects", + "flame" ] # Supported extensions From 3495ed1b06be855a9551fb6312815ce79cadcd18 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 15 Jan 2022 03:44:00 +0000 Subject: [PATCH 172/229] [Automated] Bump version --- CHANGELOG.md | 20 ++++++++++++++------ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e92c16dc5f..e7cd3cb7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,26 @@ # Changelog -## [3.8.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) +### πŸ“– Documentation + +- Slack: Add review to notification message [\#2498](https://github.com/pypeclub/OpenPype/pull/2498) + **πŸ†• New features** - Flame: OpenTimelineIO Export Modul [\#2398](https://github.com/pypeclub/OpenPype/pull/2398) **πŸš€ Enhancements** +- General: Be able to use anatomy data in ffmpeg output arguments [\#2525](https://github.com/pypeclub/OpenPype/pull/2525) +- Expose toggle publish plug-in settings for Maya Look Shading Engine Naming [\#2521](https://github.com/pypeclub/OpenPype/pull/2521) - Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) - TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) +- Slack: notifications are sent with Openpype logo and bot name [\#2499](https://github.com/pypeclub/OpenPype/pull/2499) - Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) +- Flame - create publishable clips [\#2495](https://github.com/pypeclub/OpenPype/pull/2495) - Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) - Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) - Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) @@ -23,23 +31,27 @@ - Fix \#2453 Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) - Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) - Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) +- General: Validate third party before build [\#2425](https://github.com/pypeclub/OpenPype/pull/2425) - Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) **πŸ› Bug fixes** +- Fix published frame content for sequence starting with 0 [\#2513](https://github.com/pypeclub/OpenPype/pull/2513) +- Fix \#2497: reset empty string attributes correctly to "" instead of "None" [\#2506](https://github.com/pypeclub/OpenPype/pull/2506) - General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) - General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) - Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) - General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) - Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) - General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) +- Maya: Validate Shape Zero do not keep fixed geometry vertices selected/active after repair [\#2456](https://github.com/pypeclub/OpenPype/pull/2456) **Merged pull requests:** +- Fix create zip tool - path argument [\#2522](https://github.com/pypeclub/OpenPype/pull/2522) - General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) - AE: fix hiding of alert window below Publish [\#2491](https://github.com/pypeclub/OpenPype/pull/2491) - Maya: Validate NGONs re-use polyConstraint code from openpype.host.maya.api.lib [\#2458](https://github.com/pypeclub/OpenPype/pull/2458) -- Version handling [\#2363](https://github.com/pypeclub/OpenPype/pull/2363) ## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) @@ -68,7 +80,6 @@ - Blender 3: Support auto install for new blender version [\#2377](https://github.com/pypeclub/OpenPype/pull/2377) - Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375) - Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365) -- Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356) **πŸ› Bug fixes** @@ -87,8 +98,6 @@ - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) - Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) -- Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359) -- Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) **Merged pull requests:** @@ -96,7 +105,6 @@ - Maya: Replaced PATH usage with vendored oiio path for maketx utility [\#2405](https://github.com/pypeclub/OpenPype/pull/2405) - \[Fix\]\[MAYA\] Handle message type attribute within CollectLook [\#2394](https://github.com/pypeclub/OpenPype/pull/2394) - Add validator to check correct version of extension for PS and AE [\#2387](https://github.com/pypeclub/OpenPype/pull/2387) -- Linux : flip updating submodules logic [\#2357](https://github.com/pypeclub/OpenPype/pull/2357) ## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23) diff --git a/openpype/version.py b/openpype/version.py index 1f005d6952..520048bca7 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.0-nightly.3" +__version__ = "3.8.0-nightly.4" diff --git a/pyproject.toml b/pyproject.toml index f9155f05a3..598d2b4798 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.0-nightly.3" # OpenPype +version = "3.8.0-nightly.4" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From ab97a3266a9bfdb563ae74692656f8c8b86e4f4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Jan 2022 07:05:47 +0000 Subject: [PATCH 173/229] build(deps): bump shelljs from 0.8.4 to 0.8.5 in /website Bumps [shelljs](https://github.com/shelljs/shelljs) from 0.8.4 to 0.8.5. - [Release notes](https://github.com/shelljs/shelljs/releases) - [Changelog](https://github.com/shelljs/shelljs/blob/master/CHANGELOG.md) - [Commits](https://github.com/shelljs/shelljs/compare/v0.8.4...v0.8.5) --- updated-dependencies: - dependency-name: shelljs dependency-type: indirect ... Signed-off-by: dependabot[bot] --- website/yarn.lock | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 89da2289de..e34f951572 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2250,9 +2250,9 @@ bail@^1.0.0: integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== base16@^1.0.0: version "1.0.0" @@ -4136,9 +4136,9 @@ glob-to-regexp@^0.4.1: integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== glob@^7.0.0, glob@^7.0.3, glob@^7.1.3: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -4825,6 +4825,13 @@ is-core-module@^2.2.0: dependencies: has "^1.0.3" +is-core-module@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" + integrity sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA== + dependencies: + has "^1.0.3" + is-data-descriptor@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" @@ -6167,7 +6174,7 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.6: +path-parse@^1.0.6, path-parse@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== @@ -7208,7 +7215,16 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.1.6, resolve@^1.14.2, resolve@^1.3.2: +resolve@^1.1.6: + version "1.21.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.21.0.tgz#b51adc97f3472e6a5cf4444d34bc9d6b9037591f" + integrity sha512-3wCbTpk5WJlyE4mSOtDLhqQmGFi0/TD9VPwmiolnk8U0wRgMEktqCXd3vy5buTO3tljvalNvKrjHEfrd2WpEKA== + dependencies: + is-core-module "^2.8.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +resolve@^1.14.2, resolve@^1.3.2: version "1.20.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== @@ -7533,9 +7549,9 @@ shell-quote@1.7.2: integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg== shelljs@^0.8.4: - version "0.8.4" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2" - integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ== + version "0.8.5" + resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" + integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== dependencies: glob "^7.0.0" interpret "^1.0.0" @@ -7896,6 +7912,11 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + svg-parser@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" From b432613e726770d4f21360f8db65ff3936af8429 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sun, 16 Jan 2022 16:13:10 +0100 Subject: [PATCH 174/229] moved implementation from avalon to openpype --- openpype/hosts/aftereffects/api/README.md | 66 + openpype/hosts/aftereffects/api/__init__.py | 167 +-- openpype/hosts/aftereffects/api/extension.zxp | Bin 0 -> 100915 bytes .../hosts/aftereffects/api/extension/.debug | 32 + .../api/extension/CSXS/manifest.xml | 79 ++ .../api/extension/css/boilerplate.css | 327 +++++ .../aftereffects/api/extension/css/styles.css | 51 + .../css/topcoat-desktop-dark.min.css | 1 + .../api/extension/icons/iconDarkNormal.png | Bin 0 -> 18659 bytes .../api/extension/icons/iconDarkRollover.png | Bin 0 -> 18663 bytes .../api/extension/icons/iconDisabled.png | Bin 0 -> 18663 bytes .../api/extension/icons/iconNormal.png | Bin 0 -> 18225 bytes .../api/extension/icons/iconRollover.png | Bin 0 -> 18664 bytes .../aftereffects/api/extension/index.html | 136 ++ .../api/extension/js/libs/CSInterface.js | 1193 +++++++++++++++++ .../api/extension/js/libs/jquery-2.0.2.min.js | 6 + .../api/extension/js/libs/json.js | 530 ++++++++ .../api/extension/js/libs/loglevel.min.js | 2 + .../api/extension/js/libs/wsrpc.js | 393 ++++++ .../api/extension/js/libs/wsrpc.min.js | 1 + .../aftereffects/api/extension/js/main.js | 347 +++++ .../api/extension/js/themeManager.js | 128 ++ .../api/extension/jsx/hostscript.jsx | 723 ++++++++++ .../hosts/aftereffects/api/launch_logic.py | 319 +++++ openpype/hosts/aftereffects/api/lib.py | 71 + openpype/hosts/aftereffects/api/panel.PNG | Bin 0 -> 8756 bytes .../hosts/aftereffects/api/panel_failure.PNG | Bin 0 -> 13568 bytes openpype/hosts/aftereffects/api/pipeline.py | 272 ++++ openpype/hosts/aftereffects/api/plugin.py | 46 + openpype/hosts/aftereffects/api/workio.py | 49 + openpype/hosts/aftereffects/api/ws_stub.py | 605 +++++++++ .../plugins/create/create_local_render.py | 4 - .../plugins/create/create_render.py | 42 +- .../plugins/load/load_background.py | 22 +- .../aftereffects/plugins/load/load_file.py | 19 +- .../plugins/publish/add_publish_highlight.py | 4 +- .../aftereffects/plugins/publish/closeAE.py | 4 +- .../plugins/publish/collect_audio.py | 5 +- .../plugins/publish/collect_current_file.py | 4 +- .../publish/collect_extension_version.py | 10 +- .../plugins/publish/collect_render.py | 6 +- .../plugins/publish/extract_local_render.py | 9 +- .../plugins/publish/extract_save_scene.py | 4 +- .../plugins/publish/increment_workfile.py | 4 +- .../publish/remove_publish_highlight.py | 4 +- .../publish/validate_instance_asset.py | 4 +- .../publish/validate_scene_settings.py | 10 +- openpype/scripts/non_python_host_launch.py | 2 +- 48 files changed, 5523 insertions(+), 178 deletions(-) create mode 100644 openpype/hosts/aftereffects/api/README.md create mode 100644 openpype/hosts/aftereffects/api/extension.zxp create mode 100644 openpype/hosts/aftereffects/api/extension/.debug create mode 100644 openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml create mode 100644 openpype/hosts/aftereffects/api/extension/css/boilerplate.css create mode 100644 openpype/hosts/aftereffects/api/extension/css/styles.css create mode 100644 openpype/hosts/aftereffects/api/extension/css/topcoat-desktop-dark.min.css create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconDarkNormal.png create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconDarkRollover.png create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconDisabled.png create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconNormal.png create mode 100644 openpype/hosts/aftereffects/api/extension/icons/iconRollover.png create mode 100644 openpype/hosts/aftereffects/api/extension/index.html create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/jquery-2.0.2.min.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/json.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/loglevel.min.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/wsrpc.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/libs/wsrpc.min.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/main.js create mode 100644 openpype/hosts/aftereffects/api/extension/js/themeManager.js create mode 100644 openpype/hosts/aftereffects/api/extension/jsx/hostscript.jsx create mode 100644 openpype/hosts/aftereffects/api/launch_logic.py create mode 100644 openpype/hosts/aftereffects/api/lib.py create mode 100644 openpype/hosts/aftereffects/api/panel.PNG create mode 100644 openpype/hosts/aftereffects/api/panel_failure.PNG create mode 100644 openpype/hosts/aftereffects/api/pipeline.py create mode 100644 openpype/hosts/aftereffects/api/plugin.py create mode 100644 openpype/hosts/aftereffects/api/workio.py create mode 100644 openpype/hosts/aftereffects/api/ws_stub.py diff --git a/openpype/hosts/aftereffects/api/README.md b/openpype/hosts/aftereffects/api/README.md new file mode 100644 index 0000000000..667324f7a4 --- /dev/null +++ b/openpype/hosts/aftereffects/api/README.md @@ -0,0 +1,66 @@ +# Photoshop Integration + +Requirements: This extension requires use of Javascript engine, which is +available since CC 16.0. +Please check your File>Project Settings>Expressions>Expressions Engine + +## Setup + +The After Effects integration requires two components to work; `extension` and `server`. + +### Extension + +To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). + +``` +ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp +``` +OR +download [Anastasiy’s Extension Manager](https://install.anastasiy.com/) + +### Server + +The easiest way to get the server and After Effects launch is with: + +``` +python -c ^"import avalon.photoshop;avalon.aftereffects.launch(""c:\Program Files\Adobe\Adobe After Effects 2020\Support Files\AfterFX.exe"")^" +``` + +`avalon.aftereffects.launch` launches the application and server, and also closes the server when After Effects exists. + +## Usage + +The After Effects extension can be found under `Window > Extensions > OpenPype`. Once launched you should be presented with a panel like this: + +![Avalon Panel](panel.PNG "Avalon Panel") + + +## Developing + +### Extension +When developing the extension you can load it [unsigned](https://github.com/Adobe-CEP/CEP-Resources/blob/master/CEP_9.x/Documentation/CEP%209.0%20HTML%20Extension%20Cookbook.md#debugging-unsigned-extensions). + +When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). + +``` +ZXPSignCmd -selfSignedCert NA NA Avalon Avalon-After-Effects avalon extension.p12 +ZXPSignCmd -sign {path to avalon-core}\avalon\aftereffects\extension {path to avalon-core}\avalon\aftereffects\extension.zxp extension.p12 avalon +``` + +### Plugin Examples + +These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). + +Expected deployed extension location on default Windows: +`c:\Program Files (x86)\Common Files\Adobe\CEP\extensions\com.openpype.AE.panel` + +For easier debugging of Javascript: +https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 +Add (optional) --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome +then localhost:8092 + +Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 +## Resources + - https://javascript-tools-guide.readthedocs.io/introduction/index.html + - https://github.com/Adobe-CEP/Getting-Started-guides + - https://github.com/Adobe-CEP/CEP-Resources diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py index b1edb91a5c..a7bbd8e604 100644 --- a/openpype/hosts/aftereffects/api/__init__.py +++ b/openpype/hosts/aftereffects/api/__init__.py @@ -1,115 +1,66 @@ -import os -import sys -import logging +"""Public API -from avalon import io -from avalon import api as avalon -from Qt import QtWidgets -from openpype import lib, api -import pyblish.api as pyblish -import openpype.hosts.aftereffects +Anything that isn't defined here is INTERNAL and unreliable for external use. + +""" + +from .launch_logic import ( + get_stub, + stub, +) + +from .pipeline import ( + ls, + Creator, + install, + list_instances, + remove_instance, + containerise +) + +from .workio import ( + file_extensions, + has_unsaved_changes, + save_file, + open_file, + current_file, + work_root, +) + +from .lib import ( + maintained_selection, + get_extension_manifest_path +) + +from .plugin import ( + AfterEffectsLoader +) -log = logging.getLogger("openpype.hosts.aftereffects") +__all__ = [ + # launch_logic + "get_stub", + "stub", + # pipeline + "ls", + "Creator", + "install", + "list_instances", + "remove_instance", + "containerise", -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.aftereffects.__file__)) -PLUGINS_DIR = os.path.join(HOST_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") + "file_extensions", + "has_unsaved_changes", + "save_file", + "open_file", + "current_file", + "work_root", + # lib + "maintained_selection", + "get_extension_manifest_path", -def check_inventory(): - if not lib.any_outdated(): - return - - host = pyblish.registered_host() - outdated_containers = [] - for container in host.ls(): - representation = container['representation'] - representation_doc = io.find_one( - { - "_id": io.ObjectId(representation), - "type": "representation" - }, - projection={"parent": True} - ) - if representation_doc and not lib.is_latest(representation_doc): - outdated_containers.append(container) - - # Warn about outdated containers. - print("Starting new QApplication..") - app = QtWidgets.QApplication(sys.argv) - - message_box = QtWidgets.QMessageBox() - message_box.setIcon(QtWidgets.QMessageBox.Warning) - msg = "There are outdated containers in the scene." - message_box.setText(msg) - message_box.exec_() - - # Garbage collect QApplication. - del app - - -def application_launch(): - check_inventory() - - -def install(): - print("Installing Pype config...") - - pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - - avalon.on("application.launched", application_launch) - - -def uninstall(): - pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) - - -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - -def get_asset_settings(): - """Get settings on current asset from database. - - Returns: - dict: Scene data. - - """ - asset_data = lib.get_asset()["data"] - fps = asset_data.get("fps") - frame_start = asset_data.get("frameStart") - frame_end = asset_data.get("frameEnd") - handle_start = asset_data.get("handleStart") - handle_end = asset_data.get("handleEnd") - resolution_width = asset_data.get("resolutionWidth") - resolution_height = asset_data.get("resolutionHeight") - duration = (frame_end - frame_start + 1) + handle_start + handle_end - entity_type = asset_data.get("entityType") - - scene_data = { - "fps": fps, - "frameStart": frame_start, - "frameEnd": frame_end, - "handleStart": handle_start, - "handleEnd": handle_end, - "resolutionWidth": resolution_width, - "resolutionHeight": resolution_height, - "duration": duration - } - - return scene_data + # plugin + "AfterEffectsLoader", +] diff --git a/openpype/hosts/aftereffects/api/extension.zxp b/openpype/hosts/aftereffects/api/extension.zxp new file mode 100644 index 0000000000000000000000000000000000000000..35b0c0fc42a731150521187312ef21243882f300 GIT binary patch literal 100915 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

CHAj%#mo5y_a_ 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�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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml new file mode 100644 index 0000000000..2480089825 --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/CSXS/manifest.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ./index.html + ./jsx/hostscript.jsx + + + true + + + Panel +

OpenPype + + + 200 + 100 + + + + + + ./icons/iconNormal.png + ./icons/iconRollover.png + ./icons/iconDisabled.png + ./icons/iconDarkNormal.png + ./icons/iconDarkRollover.png + + + + + + \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/css/boilerplate.css b/openpype/hosts/aftereffects/api/extension/css/boilerplate.css new file mode 100644 index 0000000000..d208999b8a --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/css/boilerplate.css @@ -0,0 +1,327 @@ +/* + * HTML5 ✰ Boilerplate + * + * What follows is the result of much research on cross-browser styling. + * Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal, + * Kroc Camen, and the H5BP dev community and team. + * + * Detailed information about this CSS: h5bp.com/css + * + * ==|== normalize ========================================================== + */ + + +/* ============================================================================= + HTML5 display definitions + ========================================================================== */ + +article, aside, details, figcaption, figure, footer, header, hgroup, nav, section { display: block; } +audio, canvas, video { display: inline-block; *display: inline; *zoom: 1; } +audio:not([controls]) { display: none; } +[hidden] { display: none; } + + +/* ============================================================================= + Base + ========================================================================== */ + +/* + * 1. Correct text resizing oddly in IE6/7 when body font-size is set using em units + * 2. Force vertical scrollbar in non-IE + * 3. Prevent iOS text size adjust on device orientation change, without disabling user zoom: h5bp.com/g + */ + +html { font-size: 100%; overflow-y: scroll; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } + +body { margin: 0; font-size: 100%; line-height: 1.231; } + +body, button, input, select, textarea { font-family: helvetica, arial,"lucida grande", verdana, "パむγƒͺγ‚ͺ", "οΌ­οΌ³ Pゴシック", sans-serif; color: #222; } +/* + * Remove text-shadow in selection highlight: h5bp.com/i + * These selection declarations have to be separate + * Also: hot pink! (or customize the background color to match your design) + */ + +::selection { text-shadow: none; background-color: highlight; color: highlighttext; } + + +/* ============================================================================= + Links + ========================================================================== */ + +a { color: #00e; } +a:visited { color: #551a8b; } +a:hover { color: #06e; } +a:focus { outline: thin dotted; } + +/* Improve readability when focused and hovered in all browsers: h5bp.com/h */ +a:hover, a:active { outline: 0; } + + +/* ============================================================================= + Typography + ========================================================================== */ + +abbr[title] { border-bottom: 1px dotted; } + +b, strong { font-weight: bold; } + +blockquote { margin: 1em 40px; } + +dfn { font-style: italic; } + +hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; } + +ins { background: #ff9; color: #000; text-decoration: none; } + +mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; } + +/* Redeclare monospace font family: h5bp.com/j */ +pre, code, kbd, samp { font-family: monospace, serif; _font-family: 'courier new', monospace; font-size: 1em; } + +/* Improve readability of pre-formatted text in all browsers */ +pre { white-space: pre; white-space: pre-wrap; word-wrap: break-word; } + +q { quotes: none; } +q:before, q:after { content: ""; content: none; } + +small { font-size: 85%; } + +/* Position subscript and superscript content without affecting line-height: h5bp.com/k */ +sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } +sup { top: -0.5em; } +sub { bottom: -0.25em; } + + +/* ============================================================================= + Lists + ========================================================================== */ + +ul, ol { margin: 1em 0; padding: 0 0 0 40px; } +dd { margin: 0 0 0 40px; } +nav ul, nav ol { list-style: none; list-style-image: none; margin: 0; padding: 0; } + + +/* ============================================================================= + Embedded content + ========================================================================== */ + +/* + * 1. Improve image quality when scaled in IE7: h5bp.com/d + * 2. Remove the gap between images and borders on image containers: h5bp.com/e + */ + +img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; } + +/* + * Correct overflow not hidden in IE9 + */ + +svg:not(:root) { overflow: hidden; } + + +/* ============================================================================= + Figures + ========================================================================== */ + +figure { margin: 0; } + + +/* ============================================================================= + Forms + ========================================================================== */ + +form { margin: 0; } +fieldset { border: 0; margin: 0; padding: 0; } + +/* Indicate that 'label' will shift focus to the associated form element */ +label { cursor: pointer; } + +/* + * 1. Correct color not inheriting in IE6/7/8/9 + * 2. Correct alignment displayed oddly in IE6/7 + */ + +legend { border: 0; *margin-left: -7px; padding: 0; } + +/* + * 1. Correct font-size not inheriting in all browsers + * 2. Remove margins in FF3/4 S5 Chrome + * 3. Define consistent vertical alignment display in all browsers + */ + +button, input, select, textarea { font-size: 100%; margin: 0; vertical-align: baseline; *vertical-align: middle; } + +/* + * 1. Define line-height as normal to match FF3/4 (set using !important in the UA stylesheet) + */ + +button, input { line-height: normal; } + +/* + * 1. Display hand cursor for clickable form elements + * 2. Allow styling of clickable form elements in iOS + * 3. Correct inner spacing displayed oddly in IE7 (doesn't effect IE6) + */ + +button, input[type="button"], input[type="reset"], input[type="submit"] { cursor: pointer; -webkit-appearance: button; *overflow: visible; } + +/* + * Consistent box sizing and appearance + */ + +input[type="checkbox"], input[type="radio"] { box-sizing: border-box; padding: 0; } +input[type="search"] { -webkit-appearance: textfield; -moz-box-sizing: content-box; -webkit-box-sizing: content-box; box-sizing: content-box; } +input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } + +/* + * Remove inner padding and border in FF3/4: h5bp.com/l + */ + +button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } + +/* + * 1. Remove default vertical scrollbar in IE6/7/8/9 + * 2. Allow only vertical resizing + */ + +textarea { overflow: auto; vertical-align: top; resize: vertical; } + +/* Colors for form validity */ +input:valid, textarea:valid { } +input:invalid, textarea:invalid { background-color: #f0dddd; } + + +/* ============================================================================= + Tables + ========================================================================== */ + +table { border-collapse: collapse; border-spacing: 0; } +td { vertical-align: top; } + + +/* ==|== primary styles ===================================================== + Author: + ========================================================================== */ + +/* ==|== media queries ====================================================== + PLACEHOLDER Media Queries for Responsive Design. + These override the primary ('mobile first') styles + Modify as content requires. + ========================================================================== */ + +@media only screen and (min-width: 480px) { + /* Style adjustments for viewports 480px and over go here */ + +} + +@media only screen and (min-width: 768px) { + /* Style adjustments for viewports 768px and over go here */ + +} + + + +/* ==|== non-semantic helper classes ======================================== + Please define your styles before this section. + ========================================================================== */ + +/* For image replacement */ +.ir { display: block; border: 0; text-indent: -999em; overflow: hidden; background-color: transparent; background-repeat: no-repeat; text-align: left; direction: ltr; } +.ir br { display: none; } + +/* Hide from both screenreaders and browsers: h5bp.com/u */ +.hidden { display: none !important; visibility: hidden; } + +/* Hide only visually, but have it available for screenreaders: h5bp.com/v */ +.visuallyhidden { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; } + +/* Extends the .visuallyhidden class to allow the element to be focusable when navigated to via the keyboard: h5bp.com/p */ +.visuallyhidden.focusable:active, .visuallyhidden.focusable:focus { clip: auto; height: auto; margin: 0; overflow: visible; position: static; width: auto; } + +/* Hide visually and from screenreaders, but maintain layout */ +.invisible { visibility: hidden; } + +/* Contain floats: h5bp.com/q */ +.clearfix:before, .clearfix:after { content: ""; display: table; } +.clearfix:after { clear: both; } +.clearfix { *zoom: 1; } + + + +/* ==|== print styles ======================================================= + Print styles. + Inlined to avoid required HTTP connection: h5bp.com/r + ========================================================================== */ + +@media print { + * { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } /* Black prints faster: h5bp.com/s */ + a, a:visited { text-decoration: underline; } + a[href]:after { content: " (" attr(href) ")"; } + abbr[title]:after { content: " (" attr(title) ")"; } + .ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } /* Don't show links for images, or javascript/internal links */ + pre, blockquote { border: 1px solid #999; page-break-inside: avoid; } + table { display: table-header-group; } /* h5bp.com/t */ + tr, img { page-break-inside: avoid; } + img { max-width: 100% !important; } + @page { margin: 0.5cm; } + p, h2, h3 { orphans: 3; widows: 3; } + h2, h3 { page-break-after: avoid; } +} + +/* reflow reset for -webkit-margin-before: 1em */ +p { margin: 0; } + +html { + overflow-y: auto; + background-color: transparent; + height: 100%; +} + +body { + background: #fff; + font: normal 100%; + position: relative; + height: 100%; +} + +body, div, img, p, button, input, select, textarea { + box-sizing: border-box; +} + +.image { + display: block; +} + +input { + cursor: default; + display: block; +} + +input[type=button] { + background-color: #e5e9e8; + border: 1px solid #9daca9; + border-radius: 4px; + box-shadow: inset 0 1px #fff; + font: inherit; + letter-spacing: inherit; + text-indent: inherit; + color: inherit; +} + +input[type=button]:hover { + background-color: #eff1f1; +} + +input[type=button]:active { + background-color: #d2d6d6; + border: 1px solid #9daca9; + box-shadow: inset 0 1px rgba(0,0,0,0.1); +} + +/* Reset anchor styles to an unstyled default to be in parity with design surface. It + is presumed that most link styles in real-world designs are custom (non-default). */ +a, a:visited, a:hover, a:active { + color: inherit; + text-decoration: inherit; +} \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/css/styles.css b/openpype/hosts/aftereffects/api/extension/css/styles.css new file mode 100644 index 0000000000..c9cf2b93ac --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/css/styles.css @@ -0,0 +1,51 @@ +/*Your styles*/ + + body { + margin: 10px; +} + + +#content { + margin-right:auto; + margin-left:auto; + vertical-align:middle; + width:100%; +} + + +#btn_test{ + width: 100%; +} + + + + +/* +Those classes will be edited at runtime with values specified +by the settings of the CC application +*/ +.hostFontColor{} +.hostFontFamily{} +.hostFontSize{} + +/*font family, color and size*/ +.hostFont{} +/*background color*/ +.hostBgd{} +/*lighter background color*/ +.hostBgdLight{} +/*darker background color*/ +.hostBgdDark{} +/*background color and font*/ +.hostElt{} + + +.hostButton{ + border:1px solid; + border-radius:2px; + height:20px; + vertical-align:bottom; + font-family:inherit; + color:inherit; + font-size:inherit; +} \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/css/topcoat-desktop-dark.min.css b/openpype/hosts/aftereffects/api/extension/css/topcoat-desktop-dark.min.css new file mode 100644 index 0000000000..6b479def43 --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/css/topcoat-desktop-dark.min.css @@ -0,0 +1 @@ +.button-bar{display:table;table-layout:fixed;white-space:nowrap;margin:0;padding:0}.button-bar__item{display:table-cell;width:auto;border-radius:0}.button-bar__item>input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.button-bar__button{border-radius:inherit}.button-bar__item:disabled{opacity:.3;cursor:default;pointer-events:none}.button,.topcoat-button,.topcoat-button--quiet,.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button--cta,.topcoat-button--large--cta,.topcoat-button-bar__button,.topcoat-button-bar__button--large{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled,.topcoat-button:disabled,.topcoat-button--quiet:disabled,.topcoat-button--large:disabled,.topcoat-button--large--quiet:disabled,.topcoat-button--cta:disabled,.topcoat-button--large--cta:disabled,.topcoat-button-bar__button:disabled,.topcoat-button-bar__button--large:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-button,.topcoat-button--quiet,.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button--cta,.topcoat-button--large--cta,.topcoat-button-bar__button,.topcoat-button-bar__button--large{padding:0 .563rem;font-size:12px;line-height:1.313rem;letter-spacing:0;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);vertical-align:top;background-color:#595b5b;box-shadow:inset 0 1px #737373;border:1px solid #333434;border-radius:4px}.topcoat-button:hover,.topcoat-button--quiet:hover,.topcoat-button--large:hover,.topcoat-button--large--quiet:hover,.topcoat-button-bar__button:hover,.topcoat-button-bar__button--large:hover{background-color:#626465}.topcoat-button:focus,.topcoat-button--quiet:focus,.topcoat-button--quiet:hover:focus,.topcoat-button--large:focus,.topcoat-button--large--quiet:focus,.topcoat-button--large--quiet:hover:focus,.topcoat-button--cta:focus,.topcoat-button--large--cta:focus,.topcoat-button-bar__button:focus,.topcoat-button-bar__button--large:focus{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1;outline:0}.topcoat-button:active,.topcoat-button--large:active,.topcoat-button-bar__button:active,.topcoat-button-bar__button--large:active,:checked+.topcoat-button-bar__button{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-button--quiet:hover,.topcoat-button--large--quiet:hover{text-shadow:0 -1px rgba(0,0,0,.69);border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-button--quiet:active,.topcoat-button--quiet:focus:active,.topcoat-button--large--quiet:active,.topcoat-button--large--quiet:focus:active{color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);background-color:#3f4041;border:1px solid #333434;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button-bar__button--large{font-size:.875rem;font-weight:600;line-height:1.688rem;padding:0 .875rem}.topcoat-button--large--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-button--cta,.topcoat-button--large--cta{border:1px solid #134f7f;background-color:#288edf;box-shadow:inset 0 1px rgba(255,255,255,.36);color:#fff;font-weight:500;text-shadow:0 -1px rgba(0,0,0,.36)}.topcoat-button--cta:hover,.topcoat-button--large--cta:hover{background-color:#4ca1e4}.topcoat-button--cta:active,.topcoat-button--large--cta:active{background-color:#1e7dc8;box-shadow:inset 0 1px rgba(0,0,0,.12)}.topcoat-button--large--cta{font-size:.875rem;font-weight:600;line-height:1.688rem;padding:0 .875rem}.button-bar,.topcoat-button-bar{display:table;table-layout:fixed;white-space:nowrap;margin:0;padding:0}.button-bar__item,.topcoat-button-bar__item{display:table-cell;width:auto;border-radius:0}.button-bar__item>input,.topcoat-button-bar__item>input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.button-bar__button{border-radius:inherit}.button-bar__item:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-button-bar>.topcoat-button-bar__item:first-child{border-top-left-radius:4px;border-bottom-left-radius:4px}.topcoat-button-bar>.topcoat-button-bar__item:last-child{border-top-right-radius:4px;border-bottom-right-radius:4px}.topcoat-button-bar__item:first-child>.topcoat-button-bar__button,.topcoat-button-bar__item:first-child>.topcoat-button-bar__button--large{border-right:0}.topcoat-button-bar__item:last-child>.topcoat-button-bar__button,.topcoat-button-bar__item:last-child>.topcoat-button-bar__button--large{border-left:0}.topcoat-button-bar__button{border-radius:inherit}.topcoat-button-bar__button:focus,.topcoat-button-bar__button--large:focus{z-index:1}.topcoat-button-bar__button--large{border-radius:inherit}.button{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled{opacity:.3;cursor:default;pointer-events:none}.button,.topcoat-button,.topcoat-button--quiet,.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button--cta,.topcoat-button--large--cta{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled,.topcoat-button:disabled,.topcoat-button--quiet:disabled,.topcoat-button--large:disabled,.topcoat-button--large--quiet:disabled,.topcoat-button--cta:disabled,.topcoat-button--large--cta:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-button,.topcoat-button--quiet,.topcoat-button--large,.topcoat-button--large--quiet,.topcoat-button--cta,.topcoat-button--large--cta{padding:0 .563rem;font-size:12px;line-height:1.313rem;letter-spacing:0;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);vertical-align:top;background-color:#595b5b;box-shadow:inset 0 1px #737373;border:1px solid #333434;border-radius:4px}.topcoat-button:hover,.topcoat-button--quiet:hover,.topcoat-button--large:hover,.topcoat-button--large--quiet:hover{background-color:#626465}.topcoat-button:focus,.topcoat-button--quiet:focus,.topcoat-button--quiet:hover:focus,.topcoat-button--large:focus,.topcoat-button--large--quiet:focus,.topcoat-button--large--quiet:hover:focus,.topcoat-button--cta:focus,.topcoat-button--large--cta:focus{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1;outline:0}.topcoat-button:active,.topcoat-button--large:active{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-button--quiet:hover,.topcoat-button--large--quiet:hover{text-shadow:0 -1px rgba(0,0,0,.69);border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-button--quiet:active,.topcoat-button--quiet:focus:active,.topcoat-button--large--quiet:active,.topcoat-button--large--quiet:focus:active{color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);background-color:#3f4041;border:1px solid #333434;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-button--large,.topcoat-button--large--quiet{font-size:.875rem;font-weight:600;line-height:1.688rem;padding:0 .875rem}.topcoat-button--large--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-button--cta,.topcoat-button--large--cta{border:1px solid #134f7f;background-color:#288edf;box-shadow:inset 0 1px rgba(255,255,255,.36);color:#fff;font-weight:500;text-shadow:0 -1px rgba(0,0,0,.36)}.topcoat-button--cta:hover,.topcoat-button--large--cta:hover{background-color:#4ca1e4}.topcoat-button--cta:active,.topcoat-button--large--cta:active{background-color:#1e7dc8;box-shadow:inset 0 1px rgba(0,0,0,.12)}.topcoat-button--large--cta{font-size:.875rem;font-weight:600;line-height:1.688rem;padding:0 .875rem}input[type=checkbox]{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.checkbox{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.checkbox__label{position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.checkbox--disabled{opacity:.3;cursor:default;pointer-events:none}.checkbox:before,.checkbox:after{content:'';position:absolute}.checkbox:before{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}input[type=checkbox]{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.checkbox,.topcoat-checkbox__checkmark{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.checkbox__label,.topcoat-checkbox{position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.checkbox--disabled,input[type=checkbox]:disabled+.topcoat-checkbox__checkmark{opacity:.3;cursor:default;pointer-events:none}.checkbox:before,.checkbox:after,.topcoat-checkbox__checkmark:before,.topcoat-checkbox__checkmark:after{content:'';position:absolute}.checkbox:before,.topcoat-checkbox__checkmark:before{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.topcoat-checkbox__checkmark{height:1rem}input[type=checkbox]{height:1rem;width:1rem;margin-top:0;margin-right:-1rem;margin-bottom:-1rem;margin-left:0}input[type=checkbox]:checked+.topcoat-checkbox__checkmark:after{opacity:1}.topcoat-checkbox{line-height:1rem}.topcoat-checkbox__checkmark:before{width:1rem;height:1rem;background:#595b5b;border:1px solid #333434;border-radius:3px;box-shadow:inset 0 1px #737373}.topcoat-checkbox__checkmark{width:1rem;height:1rem}.topcoat-checkbox__checkmark:after{top:2px;left:1px;opacity:0;width:14px;height:4px;background:transparent;border:7px solid #c6c8c8;border-width:3px;border-top:0;border-right:0;border-radius:1px;-webkit-transform:rotate(-50deg);-ms-transform:rotate(-50deg);transform:rotate(-50deg)}input[type=checkbox]:focus+.topcoat-checkbox__checkmark:before{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1}input[type=checkbox]:active+.topcoat-checkbox__checkmark:before{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}input[type=checkbox]:disabled:active+.topcoat-checkbox__checkmark:before{border:1px solid #333434;background:#595b5b;box-shadow:inset 0 1px #737373}.button,.topcoat-icon-button,.topcoat-icon-button--quiet,.topcoat-icon-button--large,.topcoat-icon-button--large--quiet{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled,.topcoat-icon-button:disabled,.topcoat-icon-button--quiet:disabled,.topcoat-icon-button--large:disabled,.topcoat-icon-button--large--quiet:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-icon-button,.topcoat-icon-button--quiet,.topcoat-icon-button--large,.topcoat-icon-button--large--quiet{padding:0 .25rem;line-height:1.313rem;letter-spacing:0;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);vertical-align:baseline;background-color:#595b5b;box-shadow:inset 0 1px #737373;border:1px solid #333434;border-radius:4px}.topcoat-icon-button:hover,.topcoat-icon-button--quiet:hover,.topcoat-icon-button--large:hover,.topcoat-icon-button--large--quiet:hover{background-color:#626465}.topcoat-icon-button:focus,.topcoat-icon-button--quiet:focus,.topcoat-icon-button--quiet:hover:focus,.topcoat-icon-button--large:focus,.topcoat-icon-button--large--quiet:focus,.topcoat-icon-button--large--quiet:hover:focus{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1;outline:0}.topcoat-icon-button:active,.topcoat-icon-button--large:active{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-icon-button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-icon-button--quiet:hover,.topcoat-icon-button--large--quiet:hover{text-shadow:0 -1px rgba(0,0,0,.69);border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-icon-button--quiet:active,.topcoat-icon-button--quiet:focus:active,.topcoat-icon-button--large--quiet:active,.topcoat-icon-button--large--quiet:focus:active{color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);background-color:#3f4041;border:1px solid #333434;box-shadow:inset 0 1px rgba(0,0,0,.05)}.topcoat-icon-button--large,.topcoat-icon-button--large--quiet{width:1.688rem;height:1.688rem;line-height:1.688rem}.topcoat-icon-button--large--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.topcoat-icon,.topcoat-icon--large{position:relative;display:inline-block;vertical-align:top;overflow:hidden;width:.81406rem;height:.81406rem;vertical-align:middle;top:-1px}.topcoat-icon--large{width:1.06344rem;height:1.06344rem;top:-2px}.input{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0}.input:disabled{opacity:.3;cursor:default;pointer-events:none}.list{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:auto;-webkit-overflow-scrolling:touch}.list__header{margin:0}.list__container{padding:0;margin:0;list-style-type:none}.list__item{margin:0;padding:0}.navigation-bar{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;white-space:nowrap;overflow:hidden;word-spacing:0;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.navigation-bar__item{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0}.navigation-bar__title{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.notification{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.notification,.topcoat-notification{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.topcoat-notification{padding:.15em .5em .2em;border-radius:2px;background-color:#ec514e;color:#fff}input[type=radio]{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.radio-button{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.radio-button__label{position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.radio-button:before,.radio-button:after{content:'';position:absolute;border-radius:100%}.radio-button:after{top:50%;left:50%;-webkit-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}.radio-button:before{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.radio-button--disabled{opacity:.3;cursor:default;pointer-events:none}input[type=radio]{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.radio-button,.topcoat-radio-button__checkmark{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.radio-button__label,.topcoat-radio-button{position:relative;display:inline-block;vertical-align:top;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.radio-button:before,.radio-button:after,.topcoat-radio-button__checkmark:before,.topcoat-radio-button__checkmark:after{content:'';position:absolute;border-radius:100%}.radio-button:after,.topcoat-radio-button__checkmark:after{top:50%;left:50%;-webkit-transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%);transform:translate(-50%,-50%)}.radio-button:before,.topcoat-radio-button__checkmark:before{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.radio-button--disabled,input[type=radio]:disabled+.topcoat-radio-button__checkmark{opacity:.3;cursor:default;pointer-events:none}input[type=radio]{height:1.063rem;width:1.063rem;margin-top:0;margin-right:-1.063rem;margin-bottom:-1.063rem;margin-left:0}input[type=radio]:checked+.topcoat-radio-button__checkmark:after{opacity:1}.topcoat-radio-button{color:#c6c8c8;line-height:1.063rem}.topcoat-radio-button__checkmark:before{width:1.063rem;height:1.063rem;background:#595b5b;border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-radio-button__checkmark{position:relative;width:1.063rem;height:1.063rem}.topcoat-radio-button__checkmark:after{opacity:0;width:.313rem;height:.313rem;background:#c6c8c8;border:1px solid rgba(0,0,0,.05);box-shadow:0 1px rgba(255,255,255,.1);-webkit-transform:none;-ms-transform:none;transform:none;top:.313rem;left:.313rem}input[type=radio]:focus+.topcoat-radio-button__checkmark:before{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1}input[type=radio]:active+.topcoat-radio-button__checkmark:before{border:1px solid #333434;background-color:#3f4041;box-shadow:inset 0 1px rgba(0,0,0,.05)}input[type=radio]:disabled:active+.topcoat-radio-button__checkmark:before{border:1px solid #333434;background:#595b5b;box-shadow:inset 0 1px #737373}.range{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0;-webkit-appearance:none}.range__thumb{cursor:pointer}.range__thumb--webkit{cursor:pointer;-webkit-appearance:none}.range:disabled{opacity:.3;cursor:default;pointer-events:none}.range,.topcoat-range{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0;-webkit-appearance:none}.range__thumb,.topcoat-range::-moz-range-thumb{cursor:pointer}.range__thumb--webkit,.topcoat-range::-webkit-slider-thumb{cursor:pointer;-webkit-appearance:none}.range:disabled,.topcoat-range:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-range{border-radius:4px;border:1px solid #333434;background-color:#454646;height:.5rem;border-radius:15px}.topcoat-range::-moz-range-track{border-radius:4px;border:1px solid #333434;background-color:#454646;height:.5rem;border-radius:15px}.topcoat-range::-webkit-slider-thumb{height:1.313rem;width:.75rem;background-color:#595b5b;border:1px solid #333434;border-radius:4px;box-shadow:inset 0 1px #737373}.topcoat-range::-moz-range-thumb{height:1.313rem;width:.75rem;background-color:#595b5b;border:1px solid #333434;border-radius:4px;box-shadow:inset 0 1px #737373}.topcoat-range:focus::-webkit-slider-thumb{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1}.topcoat-range:focus::-moz-range-thumb{border:1px solid #0036ff;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1}.topcoat-range:active::-webkit-slider-thumb{border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-range:active::-moz-range-thumb{border:1px solid #333434;box-shadow:inset 0 1px #737373}.search-input{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0;-webkit-appearance:none}input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none}.search-input:disabled{opacity:.3;cursor:default;pointer-events:none}.search-input,.topcoat-search-input,.topcoat-search-input--large{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0;-webkit-appearance:none}input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none}.search-input:disabled,.topcoat-search-input:disabled,.topcoat-search-input--large:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-search-input,.topcoat-search-input--large{line-height:1.313rem;height:1.313rem;font-size:12px;border:1px solid #333434;background-color:#454646;box-shadow:inset 0 1px 0 rgba(0,0,0,.23);color:#c6c8c8;padding:0 0 0 1.3rem;border-radius:15px;background-image:url(../img/search.svg);background-position:1rem center;background-repeat:no-repeat;background-size:12px}.topcoat-search-input:focus,.topcoat-search-input--large:focus{background-color:#595b5b;color:#fff;border:1px solid #0036ff;box-shadow:inset 0 1px 0 rgba(0,0,0,.23),0 0 0 2px #6fb5f1}.topcoat-search-input::-webkit-search-cancel-button,.topcoat-search-input::-webkit-search-decoration,.topcoat-search-input--large::-webkit-search-cancel-button,.topcoat-search-input--large::-webkit-search-decoration{margin-right:5px}.topcoat-search-input:focus::-webkit-input-placeholder,.topcoat-search-input:focus::-webkit-input-placeholder{color:#c6c8c8}.topcoat-search-input:disabled::-webkit-input-placeholder{color:#fff}.topcoat-search-input:disabled::-moz-placeholder{color:#fff}.topcoat-search-input:disabled:-ms-input-placeholder{color:#fff}.topcoat-search-input--large{line-height:1.688rem;height:1.688rem;font-size:.875rem;font-weight:400;padding:0 0 0 1.8rem;border-radius:25px;background-position:1.2rem center;background-size:.875rem}.topcoat-search-input--large:disabled{color:#fff}.topcoat-search-input--large:disabled::-webkit-input-placeholder{color:#fff}.topcoat-search-input--large:disabled::-moz-placeholder{color:#fff}.topcoat-search-input--large:disabled:-ms-input-placeholder{color:#fff}.switch{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.switch__input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.switch__toggle{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.switch__toggle:before,.switch__toggle:after{content:'';position:absolute;z-index:-1;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.switch--disabled{opacity:.3;cursor:default;pointer-events:none}.switch,.topcoat-switch{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.switch__input,.topcoat-switch__input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.switch__toggle,.topcoat-switch__toggle{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.switch__toggle:before,.switch__toggle:after,.topcoat-switch__toggle:before,.topcoat-switch__toggle:after{content:'';position:absolute;z-index:-1;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box}.switch--disabled,.topcoat-switch__input:disabled+.topcoat-switch__toggle{opacity:.3;cursor:default;pointer-events:none}.topcoat-switch{font-size:12px;padding:0 .563rem;border-radius:4px;border:1px solid #333434;overflow:hidden;width:3.5rem}.topcoat-switch__toggle:before,.topcoat-switch__toggle:after{top:-1px;width:2.6rem}.topcoat-switch__toggle:before{content:'ON';color:#288edf;background-color:#3f4041;right:.8rem;padding-left:.75rem}.topcoat-switch__toggle{line-height:1.313rem;height:1.313rem;width:1rem;border-radius:4px;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);background-color:#595b5b;border:1px solid #333434;margin-left:-.6rem;margin-bottom:-1px;margin-top:-1px;box-shadow:inset 0 1px #737373;-webkit-transition:margin-left .05s ease-in-out;transition:margin-left .05s ease-in-out}.topcoat-switch__toggle:after{content:'OFF';background-color:#3f4041;left:.8rem;padding-left:.6rem}.topcoat-switch__input:checked+.topcoat-switch__toggle{margin-left:1.85rem}.topcoat-switch__input:active+.topcoat-switch__toggle{border:1px solid #333434;box-shadow:inset 0 1px #737373}.topcoat-switch__input:focus+.topcoat-switch__toggle{border:1px solid #0036ff;box-shadow:0 0 0 2px #6fb5f1}.topcoat-switch__input:disabled+.topcoat-switch__toggle:after,.topcoat-switch__input:disabled+.topcoat-switch__toggle:before{background:transparent}.button,.topcoat-tab-bar__button{position:relative;display:inline-block;vertical-align:top;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-decoration:none}.button--quiet{background:transparent;border:1px solid transparent;box-shadow:none}.button--disabled,.topcoat-tab-bar__button:disabled{opacity:.3;cursor:default;pointer-events:none}.button-bar,.topcoat-tab-bar{display:table;table-layout:fixed;white-space:nowrap;margin:0;padding:0}.button-bar__item,.topcoat-tab-bar__item{display:table-cell;width:auto;border-radius:0}.button-bar__item>input,.topcoat-tab-bar__item>input{position:absolute;overflow:hidden;padding:0;border:0;opacity:.001;z-index:1;vertical-align:top;outline:0}.button-bar__button{border-radius:inherit}.button-bar__item:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-tab-bar__button{padding:0 .563rem;height:1.313rem;line-height:1.313rem;letter-spacing:0;color:#c6c8c8;text-shadow:0 -1px rgba(0,0,0,.69);vertical-align:top;background-color:#595b5b;box-shadow:inset 0 1px #737373;border-top:1px solid #333434}.topcoat-tab-bar__button:active,.topcoat-tab-bar__button--large:active,:checked+.topcoat-tab-bar__button{color:#288edf;background-color:#3f4041;box-shadow:inset 0 0 1px rgba(0,0,0,.05)}.topcoat-tab-bar__button:focus,.topcoat-tab-bar__button--large:focus{z-index:1;box-shadow:inset 0 1px rgba(255,255,255,.36),0 0 0 2px #6fb5f1;outline:0}.input,.topcoat-text-input,.topcoat-text-input--large{padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;vertical-align:top;outline:0}.input:disabled,.topcoat-text-input:disabled,.topcoat-text-input--large:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-text-input,.topcoat-text-input--large{line-height:1.313rem;font-size:12px;letter-spacing:0;padding:0 .563rem;border:1px solid #333434;border-radius:4px;background-color:#454646;box-shadow:inset 0 1px rgba(0,0,0,.05);color:#c6c8c8;vertical-align:top}.topcoat-text-input:focus,.topcoat-text-input--large:focus{background-color:#595b5b;color:#fff;border:1px solid #0036ff;box-shadow:0 0 0 2px #6fb5f1}.topcoat-text-input:disabled::-webkit-input-placeholder{color:#fff}.topcoat-text-input:disabled::-moz-placeholder{color:#fff}.topcoat-text-input:disabled:-ms-input-placeholder{color:#fff}.topcoat-text-input:invalid{border:1px solid #ec514e}.topcoat-text-input--large{line-height:1.688rem;font-size:.875rem}.topcoat-text-input--large:disabled{color:#fff}.topcoat-text-input--large:disabled::-webkit-input-placeholder{color:#fff}.topcoat-text-input--large:disabled::-moz-placeholder{color:#fff}.topcoat-text-input--large:disabled:-ms-input-placeholder{color:#fff}.topcoat-text-input--large:invalid{border:1px solid #ec514e}.textarea{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;vertical-align:top;resize:none;outline:0}.textarea:disabled{opacity:.3;cursor:default;pointer-events:none}.textarea,.topcoat-textarea,.topcoat-textarea--large{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;vertical-align:top;resize:none;outline:0}.textarea:disabled,.topcoat-textarea:disabled,.topcoat-textarea--large:disabled{opacity:.3;cursor:default;pointer-events:none}.topcoat-textarea,.topcoat-textarea--large{padding:1rem;font-size:1rem;font-weight:400;border-radius:4px;line-height:1.313rem;border:1px solid #333434;background-color:#454646;box-shadow:inset 0 1px rgba(0,0,0,.05);color:#c6c8c8;letter-spacing:0}.topcoat-textarea:focus,.topcoat-textarea--large:focus{background-color:#595b5b;color:#fff;border:1px solid #0036ff;box-shadow:0 0 0 2px #6fb5f1}.topcoat-textarea:disabled::-webkit-input-placeholder{color:#fff}.topcoat-textarea:disabled::-moz-placeholder{color:#fff}.topcoat-textarea:disabled:-ms-input-placeholder{color:#fff}.topcoat-textarea--large{font-size:1.3rem;line-height:1.688rem}.topcoat-textarea--large:disabled{color:#fff}.topcoat-textarea--large:disabled::-webkit-input-placeholder{color:#fff}.topcoat-textarea--large:disabled::-moz-placeholder{color:#fff}.topcoat-textarea--large:disabled:-ms-input-placeholder{color:#fff}@font-face{font-family:"Source Sans";src:url(../font/SourceSansPro-Regular.otf)}@font-face{font-family:"Source Sans";src:url(../font/SourceSansPro-Light.otf);font-weight:200}@font-face{font-family:"Source Sans";src:url(../font/SourceSansPro-Semibold.otf);font-weight:600}body{margin:0;padding:0;background:#4b4d4e;color:#000;font:16px "Source Sans",helvetica,arial,sans-serif;font-weight:400}:focus{outline-color:transparent;outline-style:none}.topcoat-icon--menu-stack{background:url(../img/hamburger_light.svg) no-repeat;background-size:cover}.quarter{width:25%}.half{width:50%}.three-quarters{width:75%}.third{width:33.333%}.two-thirds{width:66.666%}.full{width:100%}.left{text-align:left}.center{text-align:center}.right{text-align:right}.reset-ui{-moz-box-sizing:border-box;box-sizing:border-box;background-clip:padding-box;position:relative;display:inline-block;vertical-align:top;padding:0;margin:0;font:inherit;color:inherit;background:transparent;border:0;cursor:default;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-overflow:ellipsis;white-space:nowrap;overflow:hidden} \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconDarkNormal.png b/openpype/hosts/aftereffects/api/extension/icons/iconDarkNormal.png new file mode 100644 index 0000000000000000000000000000000000000000..b8652a85b842b12d45e495c1d496590da0e26f01 GIT binary patch literal 18659 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconDarkRollover.png b/openpype/hosts/aftereffects/api/extension/icons/iconDarkRollover.png new file mode 100644 index 0000000000000000000000000000000000000000..49edd7ca278ed7d0047effd37242f2bed1e9be27 GIT binary patch literal 18663 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconDisabled.png b/openpype/hosts/aftereffects/api/extension/icons/iconDisabled.png new file mode 100644 index 0000000000000000000000000000000000000000..49edd7ca278ed7d0047effd37242f2bed1e9be27 GIT binary patch literal 18663 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconNormal.png b/openpype/hosts/aftereffects/api/extension/icons/iconNormal.png new file mode 100644 index 0000000000000000000000000000000000000000..199326f2eac3fee033a163b7e3fe395057746c4c GIT binary patch literal 18225 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/icons/iconRollover.png b/openpype/hosts/aftereffects/api/extension/icons/iconRollover.png new file mode 100644 index 0000000000000000000000000000000000000000..ff62645798592fa733626e43741031a57d34c61a GIT binary patch literal 18664 zcmeI33p7;g`^UFaxl4&8amFPfb7RmhY=^FROf`~BB{{nnaUYc9|GywCUD@8^BqXYW00?U@1Cv9q;w<$oi_-{PYDPmiLd#{4e{wyjqn07%Une~SP~#}@#Aq%)I9T)EPl z#bE_{v)C{@A`!+8V9}YrAOP?hQrs9Mx9<5SL(PL0_R(R7?OCo0;xJc>yjb6-Z>xmDTPfiMe~)MNv5#0zL@fg>6sz;GxY zzf`?U>^dOAdn_dmlsP7sp%(2PlYL`xY^_MxE|D@$+%kLdFnPc%_<+S(z|2}CEG1FP z4M>{_Y^^$hUz=bP7S@j&Ld_Y_pK)FOm>HsrCfQ@%GGGDY(%f}aKVy(j0h{HFFd3YdHwX5wEm&zNFeX&>#Z$!41YKr1J-c2o~purO@7XqeyV$;Ev$z1K^n zZ5i52&zCj345W28cnq+|*;u6?EPDRDv!mnLidz@7xWr8JyW;c6Y7NmMls%pr2gDVRMGmXow4EupR}*$GTHZz7KF-h@!O5dX()g+Yn12-a(bUULa<0%NK>HtgyJaOiv=7T6Ix9M>@N5;e}N>Gm2y0Q)@1A zXUc_Jl}Q-Q>0!nt01tQ!a+6zoh8kZ*~_?lj`K^tn2(;HM;dOlQFgnHRA6z(0ua6 zu!r?vOYcT@q;ynt7<4FisJ&YwnL;t!e0tceP22VUrWHz^b2>4dqF|!Fx%;Uk*B9r~ zNplTjS6iGz*1G0w3_rl&)_Mq)(0DjX`DiKkuCE^cTqsf0;Ul?r%zYCbaAQCq?dZ)sh8_Pe%$VyH^Go1mZScO`cvKTU4AvT-$UwO--5!mFTN ze#h#wUVVin{*kM(US~YrSFbCuKA&=aaeeXm;*7Z&MNaWf*{Su6iVJ@g-Yd+!qI9A2 znP_8fpD*$bxFtWfVJI?+0wV^Z_r}=otU@FZ5Xs7wFYcChtl+%g zyskC4?tMyfL2`NTxyHOcqdsQe?xqtDFeh|Uy$k{$Roo@Mbm^=$l{#?*<P&PF zXI$>mKvI!$9_OE4xN?S@HAL=LBzzzQoM1uJz!WD9YJ z#u#lZc&rx42wN<|m=P0h9=_(cPs736)NULw#WjK-@}Kvb?@KE(S1^y=d46Z->yB zcmF(iUTvr=u{y4rIXiM?=_!{P#X(fX0_27wTil?2g{UR(vH`mDD_j=3c-hth1 zDQ!~iA&QB}yX)9N>DA?fks0$d5psICqh=1#PRkCzHJ_tZ&@uv( zXMi5`8xLddV~=L1E`8Yhpx}PvLF427C&qf^%H>)Q6`qN*_kgQ-zADYxgw4lq4r-Ka zSD3XlYL#dX{_37WUB4&J*}WbKxuK<6!Hz!R>u=uQdGoS}Pmh$lWM|~|o~Eb0>zqB2 zUM-i&AEQ!~2?{ZLa^)Kp6BKql({AE*D(`09zj3VT@TJV`TSc@QdL6y{RWm$w&FrID z39k}n`^!9UxFplE>Qe9f4_1xHgooOV#Sw+XqEk2US4+T*d({t@o03kG21y@DMtyZJ zE|Z@Q``7j3}&Kl!aY@8q|+ z_s)#E86#pEvkRXV{qaPl&V=L|Tfel&X7-)muaBnmbKx>ZGVc42SGH-eErU9`ha_5t zRAq1tTla0Od_vRl%pHIy~ioF5Qs>)$j7f3|7-UG_Kz?-jIS zfOL=9(7p3zLX;=+d~n5H*IV@Ss2nqt!~*Q%j&;2htpbTHMX~FdCBtXwePNS z-sLxT&hxnHk(7tq;P-~}20t4A*!fQJ@#3W1qP0FneZi%dL+C@@%BjZHF9u74{+d5l z|0ES_@}hJgm^E~J=zYul7oh=5MyjidpL3V=oiA}N*)V9`kQua zD&wXN)Hc}+iAF}$jK;l>l_k!KJr-LPB@@+?x{QFuwwVTubbrnu*3fFSQWZx}j#lL= z&PRq0Kk0c|U7J{&p71Uq{)XSSGb3-xdX(}SE6XcYuixVtpw|zs`qX=q+r(ArKh+}Z z+_4R|Lwn5o?~Cu<9%X!rSBj4xn>izK;4{?#-UtB8A2CUu98X6F9F^sVAk$bB5W(|f z^DjgJ0B_1;lc_!+2Sx!IOn(#g{^A?zFec4J-NVok<;W(2-b~xz0MISii9`+dp<-$3 zrUXem562hq136?E&(GIC5XUo7pU}nee~&jK)nOA|I6fxo=HmmxJRMiUh^zn*W{5C^ zQ&DIP%ovM68=^7G(0VX^6xslZGC-mY;Anju3X4M_MVv#6)q`p3!-vb^PdksfNoSlClhwM)U z?JNoWxg(fN8jfnPjADo}ro$9mW9oDzVFJa6d7x14^p46Wf{_8Qg-k`srgrAg} z==E!nKo*_DB?o}!41P}hj~j7P^7|O5HXb=%mwlQ42dfZB-umyY0hwYzr+_pAxFKlB z&vaut4Nj(#Dg2#eKu0YzGRDwQ24C0szj+O&@|WsO_Gf@J0`lv$zBc?fE!#h&fBA(ut>0adcLIADKg7`jHtRlI_pHBc~dtQU(9*5&}nL z`LY5WSu~Kq-!VUH{%I8dpV5_*e~c0WU2QC2c2-0qG}g!nu8%-ZmzmaYn$Gw|2#$Yx z;Fl`$L|Nv?{LlvN?>S!PeYZ^XH`Yh-Ur6-$=aBG?q-63hqU72<*b5o`)^3FZUw3UNVz2sVYd1oMG-g}5L<1e-!!g84wa zLR=6af=z+AB&QzJ1^xNY=5qND;cDioo#H>h3!~b)Is!oOVgLw>0D!;8_}|?CumuGG zZ#Dt|E)f6}S%)^3rL`LGPu^dTMFg(o}W7tKmJbkps?G zDBAm6O3L&KH_f`SYt4epw%&%{E3F*J7aL@|7HDPdLp-!yHs)!VH>}cRW3yjzuK{mS zYD~J;;^wR1oPDmp6>LQ-%mD(q1TnCRt(-R7z201nRADFRE zN1c%E@sU=NNhQ5i<5>UJFP&;REFo>Z%1uV}Y53dFK}*s8=j+!0Ijeu~r9-{;JGnNJ z{Z2c(G$Om=Ul!T67HRUk!KdH8*W{_W^3)`*%js-eEOA|4XPb`1 zsA^SQdO}2x*)Z~fjpzy4+*|n{6zbK@^JY0`1%K9{tB2RjbbjM}rE6;gZT|xcHtpjp zy_Y(xly|L{8Bn~>bbin}*QjS-v-Cjqk;kdpbUE1zE=vZj6It)lBI*iXYQ0tF_FlTT z=?IIrv-|Xn0*+5c#deQge?K%5d|glXGeEW(G-u|RUjO8{ZeWym5fM6H^*Cm^T*w)< x<#K1<|3z9*`sRWdDe35|>VSEpCo0BdfD0n*q&z86yYUCc?W~+Eb1gRR{1=Z=&x`;7 literal 0 HcmV?d00001 diff --git a/openpype/hosts/aftereffects/api/extension/index.html b/openpype/hosts/aftereffects/api/extension/index.html new file mode 100644 index 0000000000..9e39bf1acc --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/index.html @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js b/openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js new file mode 100644 index 0000000000..4239391efd --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/js/libs/CSInterface.js @@ -0,0 +1,1193 @@ +/************************************************************************************************** +* +* ADOBE SYSTEMS INCORPORATED +* Copyright 2013 Adobe Systems Incorporated +* All Rights Reserved. +* +* NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the +* terms of the Adobe license agreement accompanying it. If you have received this file from a +* source other than Adobe, then your use, modification, or distribution of it requires the prior +* written permission of Adobe. +* +**************************************************************************************************/ + +/** CSInterface - v8.0.0 */ + +/** + * Stores constants for the window types supported by the CSXS infrastructure. + */ +function CSXSWindowType() +{ +} + +/** Constant for the CSXS window type Panel. */ +CSXSWindowType._PANEL = "Panel"; + +/** Constant for the CSXS window type Modeless. */ +CSXSWindowType._MODELESS = "Modeless"; + +/** Constant for the CSXS window type ModalDialog. */ +CSXSWindowType._MODAL_DIALOG = "ModalDialog"; + +/** EvalScript error message */ +EvalScript_ErrMessage = "EvalScript error."; + +/** + * @class Version + * Defines a version number with major, minor, micro, and special + * components. The major, minor and micro values are numeric; the special + * value can be any string. + * + * @param major The major version component, a positive integer up to nine digits long. + * @param minor The minor version component, a positive integer up to nine digits long. + * @param micro The micro version component, a positive integer up to nine digits long. + * @param special The special version component, an arbitrary string. + * + * @return A new \c Version object. + */ +function Version(major, minor, micro, special) +{ + this.major = major; + this.minor = minor; + this.micro = micro; + this.special = special; +} + +/** + * The maximum value allowed for a numeric version component. + * This reflects the maximum value allowed in PlugPlug and the manifest schema. + */ +Version.MAX_NUM = 999999999; + +/** + * @class VersionBound + * Defines a boundary for a version range, which associates a \c Version object + * with a flag for whether it is an inclusive or exclusive boundary. + * + * @param version The \c #Version object. + * @param inclusive True if this boundary is inclusive, false if it is exclusive. + * + * @return A new \c VersionBound object. + */ +function VersionBound(version, inclusive) +{ + this.version = version; + this.inclusive = inclusive; +} + +/** + * @class VersionRange + * Defines a range of versions using a lower boundary and optional upper boundary. + * + * @param lowerBound The \c #VersionBound object. + * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. + * + * @return A new \c VersionRange object. + */ +function VersionRange(lowerBound, upperBound) +{ + this.lowerBound = lowerBound; + this.upperBound = upperBound; +} + +/** + * @class Runtime + * Represents a runtime related to the CEP infrastructure. + * Extensions can declare dependencies on particular + * CEP runtime versions in the extension manifest. + * + * @param name The runtime name. + * @param version A \c #VersionRange object that defines a range of valid versions. + * + * @return A new \c Runtime object. + */ +function Runtime(name, versionRange) +{ + this.name = name; + this.versionRange = versionRange; +} + +/** +* @class Extension +* Encapsulates a CEP-based extension to an Adobe application. +* +* @param id The unique identifier of this extension. +* @param name The localizable display name of this extension. +* @param mainPath The path of the "index.html" file. +* @param basePath The base path of this extension. +* @param windowType The window type of the main window of this extension. + Valid values are defined by \c #CSXSWindowType. +* @param width The default width in pixels of the main window of this extension. +* @param height The default height in pixels of the main window of this extension. +* @param minWidth The minimum width in pixels of the main window of this extension. +* @param minHeight The minimum height in pixels of the main window of this extension. +* @param maxWidth The maximum width in pixels of the main window of this extension. +* @param maxHeight The maximum height in pixels of the main window of this extension. +* @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. +* @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. +* @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. +* @param isAutoVisible True if this extension is visible on loading. +* @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. +* +* @return A new \c Extension object. +*/ +function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, + defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) +{ + this.id = id; + this.name = name; + this.mainPath = mainPath; + this.basePath = basePath; + this.windowType = windowType; + this.width = width; + this.height = height; + this.minWidth = minWidth; + this.minHeight = minHeight; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + this.defaultExtensionDataXml = defaultExtensionDataXml; + this.specialExtensionDataXml = specialExtensionDataXml; + this.requiredRuntimeList = requiredRuntimeList; + this.isAutoVisible = isAutoVisible; + this.isPluginExtension = isPluginExtension; +} + +/** + * @class CSEvent + * A standard JavaScript event, the base class for CEP events. + * + * @param type The name of the event type. + * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". + * @param appId The unique identifier of the application that generated the event. + * @param extensionId The unique identifier of the extension that generated the event. + * + * @return A new \c CSEvent object + */ +function CSEvent(type, scope, appId, extensionId) +{ + this.type = type; + this.scope = scope; + this.appId = appId; + this.extensionId = extensionId; +} + +/** Event-specific data. */ +CSEvent.prototype.data = ""; + +/** + * @class SystemPath + * Stores operating-system-specific location constants for use in the + * \c #CSInterface.getSystemPath() method. + * @return A new \c SystemPath object. + */ +function SystemPath() +{ +} + +/** The path to user data. */ +SystemPath.USER_DATA = "userData"; + +/** The path to common files for Adobe applications. */ +SystemPath.COMMON_FILES = "commonFiles"; + +/** The path to the user's default document folder. */ +SystemPath.MY_DOCUMENTS = "myDocuments"; + +/** @deprecated. Use \c #SystemPath.Extension. */ +SystemPath.APPLICATION = "application"; + +/** The path to current extension. */ +SystemPath.EXTENSION = "extension"; + +/** The path to hosting application's executable. */ +SystemPath.HOST_APPLICATION = "hostApplication"; + +/** + * @class ColorType + * Stores color-type constants. + */ +function ColorType() +{ +} + +/** RGB color type. */ +ColorType.RGB = "rgb"; + +/** Gradient color type. */ +ColorType.GRADIENT = "gradient"; + +/** Null color type. */ +ColorType.NONE = "none"; + +/** + * @class RGBColor + * Stores an RGB color with red, green, blue, and alpha values. + * All values are in the range [0.0 to 255.0]. Invalid numeric values are + * converted to numbers within this range. + * + * @param red The red value, in the range [0.0 to 255.0]. + * @param green The green value, in the range [0.0 to 255.0]. + * @param blue The blue value, in the range [0.0 to 255.0]. + * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. + * The default, 255.0, means that the color is fully opaque. + * + * @return A new RGBColor object. + */ +function RGBColor(red, green, blue, alpha) +{ + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; +} + +/** + * @class Direction + * A point value in which the y component is 0 and the x component + * is positive or negative for a right or left direction, + * or the x component is 0 and the y component is positive or negative for + * an up or down direction. + * + * @param x The horizontal component of the point. + * @param y The vertical component of the point. + * + * @return A new \c Direction object. + */ +function Direction(x, y) +{ + this.x = x; + this.y = y; +} + +/** + * @class GradientStop + * Stores gradient stop information. + * + * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. + * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. + * + * @return GradientStop object. + */ +function GradientStop(offset, rgbColor) +{ + this.offset = offset; + this.rgbColor = rgbColor; +} + +/** + * @class GradientColor + * Stores gradient color information. + * + * @param type The gradient type, must be "linear". + * @param direction A \c #Direction object for the direction of the gradient + (up, down, right, or left). + * @param numStops The number of stops in the gradient. + * @param gradientStopList An array of \c #GradientStop objects. + * + * @return A new \c GradientColor object. + */ +function GradientColor(type, direction, numStops, arrGradientStop) +{ + this.type = type; + this.direction = direction; + this.numStops = numStops; + this.arrGradientStop = arrGradientStop; +} + +/** + * @class UIColor + * Stores color information, including the type, anti-alias level, and specific color + * values in a color object of an appropriate type. + * + * @param type The color type, 1 for "rgb" and 2 for "gradient". + The supplied color object must correspond to this type. + * @param antialiasLevel The anti-alias level constant. + * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. + * + * @return A new \c UIColor object. + */ +function UIColor(type, antialiasLevel, color) +{ + this.type = type; + this.antialiasLevel = antialiasLevel; + this.color = color; +} + +/** + * @class AppSkinInfo + * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. + * + * @param baseFontFamily The base font family of the application. + * @param baseFontSize The base font size of the application. + * @param appBarBackgroundColor The application bar background color. + * @param panelBackgroundColor The background color of the extension panel. + * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. + * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. + * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. + * + * @return AppSkinInfo object. + */ +function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) +{ + this.baseFontFamily = baseFontFamily; + this.baseFontSize = baseFontSize; + this.appBarBackgroundColor = appBarBackgroundColor; + this.panelBackgroundColor = panelBackgroundColor; + this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; + this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; + this.systemHighlightColor = systemHighlightColor; +} + +/** + * @class HostEnvironment + * Stores information about the environment in which the extension is loaded. + * + * @param appName The application's name. + * @param appVersion The application's version. + * @param appLocale The application's current license locale. + * @param appUILocale The application's current UI locale. + * @param appId The application's unique identifier. + * @param isAppOnline True if the application is currently online. + * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. + * + * @return A new \c HostEnvironment object. + */ +function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) +{ + this.appName = appName; + this.appVersion = appVersion; + this.appLocale = appLocale; + this.appUILocale = appUILocale; + this.appId = appId; + this.isAppOnline = isAppOnline; + this.appSkinInfo = appSkinInfo; +} + +/** + * @class HostCapabilities + * Stores information about the host capabilities. + * + * @param EXTENDED_PANEL_MENU True if the application supports panel menu. + * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. + * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. + * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. + * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. + * + * @return A new \c HostCapabilities object. + */ +function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) +{ + this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; + this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; + this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; + this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; + this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 +} + +/** + * @class ApiVersion + * Stores current api version. + * + * Since 4.2.0 + * + * @param major The major version + * @param minor The minor version. + * @param micro The micro version. + * + * @return ApiVersion object. + */ +function ApiVersion(major, minor, micro) +{ + this.major = major; + this.minor = minor; + this.micro = micro; +} + +/** + * @class MenuItemStatus + * Stores flyout menu item status + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function MenuItemStatus(menuItemLabel, enabled, checked) +{ + this.menuItemLabel = menuItemLabel; + this.enabled = enabled; + this.checked = checked; +} + +/** + * @class ContextMenuItemStatus + * Stores the status of the context menu item. + * + * Since 5.2.0 + * + * @param menuItemID The menu item id. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function ContextMenuItemStatus(menuItemID, enabled, checked) +{ + this.menuItemID = menuItemID; + this.enabled = enabled; + this.checked = checked; +} +//------------------------------ CSInterface ---------------------------------- + +/** + * @class CSInterface + * This is the entry point to the CEP extensibility infrastructure. + * Instantiate this object and use it to: + *
    + *
  • Access information about the host application in which an extension is running
  • + *
  • Launch an extension
  • + *
  • Register interest in event notifications, and dispatch events
  • + *
+ * + * @return A new \c CSInterface object + */ +function CSInterface() +{ +} + +/** + * User can add this event listener to handle native application theme color changes. + * Callback function gives extensions ability to fine-tune their theme color after the + * global theme color has been changed. + * The callback function should be like below: + * + * @example + * // event is a CSEvent object, but user can ignore it. + * function OnAppThemeColorChanged(event) + * { + * // Should get a latest HostEnvironment object from application. + * var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; + * // Gets the style information such as color info from the skinInfo, + * // and redraw all UI controls of your extension according to the style info. + * } + */ +CSInterface.THEME_COLOR_CHANGED_EVENT = "com.adobe.csxs.events.ThemeColorChanged"; + +/** The host environment data object. */ +CSInterface.prototype.hostEnvironment = window.__adobe_cep__ ? JSON.parse(window.__adobe_cep__.getHostEnvironment()) : null; + +/** Retrieves information about the host environment in which the + * extension is currently running. + * + * @return A \c #HostEnvironment object. + */ +CSInterface.prototype.getHostEnvironment = function() +{ + this.hostEnvironment = JSON.parse(window.__adobe_cep__.getHostEnvironment()); + return this.hostEnvironment; +}; + +/** Closes this extension. */ +CSInterface.prototype.closeExtension = function() +{ + window.__adobe_cep__.closeExtension(); +}; + +/** + * Retrieves a path for which a constant is defined in the system. + * + * @param pathType The path-type constant defined in \c #SystemPath , + * + * @return The platform-specific system path string. + */ +CSInterface.prototype.getSystemPath = function(pathType) +{ + var path = decodeURI(window.__adobe_cep__.getSystemPath(pathType)); + var OSVersion = this.getOSInformation(); + if (OSVersion.indexOf("Windows") >= 0) + { + path = path.replace("file:///", ""); + } + else if (OSVersion.indexOf("Mac") >= 0) + { + path = path.replace("file://", ""); + } + return path; +}; + +/** + * Evaluates a JavaScript script, which can use the JavaScript DOM + * of the host application. + * + * @param script The JavaScript script. + * @param callback Optional. A callback function that receives the result of execution. + * If execution fails, the callback function receives the error message \c EvalScript_ErrMessage. + */ +CSInterface.prototype.evalScript = function(script, callback) +{ + if(callback === null || callback === undefined) + { + callback = function(result){}; + } + window.__adobe_cep__.evalScript(script, callback); +}; + +/** + * Retrieves the unique identifier of the application. + * in which the extension is currently running. + * + * @return The unique ID string. + */ +CSInterface.prototype.getApplicationID = function() +{ + var appId = this.hostEnvironment.appId; + return appId; +}; + +/** + * Retrieves host capability information for the application + * in which the extension is currently running. + * + * @return A \c #HostCapabilities object. + */ +CSInterface.prototype.getHostCapabilities = function() +{ + var hostCapabilities = JSON.parse(window.__adobe_cep__.getHostCapabilities() ); + return hostCapabilities; +}; + +/** + * Triggers a CEP event programmatically. Yoy can use it to dispatch + * an event of a predefined type, or of a type you have defined. + * + * @param event A \c CSEvent object. + */ +CSInterface.prototype.dispatchEvent = function(event) +{ + if (typeof event.data == "object") + { + event.data = JSON.stringify(event.data); + } + + window.__adobe_cep__.dispatchEvent(event); +}; + +/** + * Registers an interest in a CEP event of a particular type, and + * assigns an event handler. + * The event infrastructure notifies your extension when events of this type occur, + * passing the event object to the registered handler function. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.addEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.addEventListener(type, listener, obj); +}; + +/** + * Removes a registered event listener. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method that was registered. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.removeEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.removeEventListener(type, listener, obj); +}; + +/** + * Loads and launches another extension, or activates the extension if it is already loaded. + * + * @param extensionId The extension's unique identifier. + * @param startupParams Not currently used, pass "". + * + * @example + * To launch the extension "help" with ID "HLP" from this extension, call: + * requestOpenExtension("HLP", ""); + * + */ +CSInterface.prototype.requestOpenExtension = function(extensionId, params) +{ + window.__adobe_cep__.requestOpenExtension(extensionId, params); +}; + +/** + * Retrieves the list of extensions currently loaded in the current host application. + * The extension list is initialized once, and remains the same during the lifetime + * of the CEP session. + * + * @param extensionIds Optional, an array of unique identifiers for extensions of interest. + * If omitted, retrieves data for all extensions. + * + * @return Zero or more \c #Extension objects. + */ +CSInterface.prototype.getExtensions = function(extensionIds) +{ + var extensionIdsStr = JSON.stringify(extensionIds); + var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr); + + var extensions = JSON.parse(extensionsStr); + return extensions; +}; + +/** + * Retrieves network-related preferences. + * + * @return A JavaScript object containing network preferences. + */ +CSInterface.prototype.getNetworkPreferences = function() +{ + var result = window.__adobe_cep__.getNetworkPreferences(); + var networkPre = JSON.parse(result); + + return networkPre; +}; + +/** + * Initializes the resource bundle for this extension with property values + * for the current application and locale. + * To support multiple locales, you must define a property file for each locale, + * containing keyed display-string values for that locale. + * See localization documentation for Extension Builder and related products. + * + * Keys can be in the + * form key.value="localized string", for use in HTML text elements. + * For example, in this input element, the localized \c key.value string is displayed + * instead of the empty \c value string: + * + * + * + * @return An object containing the resource bundle information. + */ +CSInterface.prototype.initResourceBundle = function() +{ + var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle()); + var resElms = document.querySelectorAll('[data-locale]'); + for (var n = 0; n < resElms.length; n++) + { + var resEl = resElms[n]; + // Get the resource key from the element. + var resKey = resEl.getAttribute('data-locale'); + if (resKey) + { + // Get all the resources that start with the key. + for (var key in resourceBundle) + { + if (key.indexOf(resKey) === 0) + { + var resValue = resourceBundle[key]; + if (key.length == resKey.length) + { + resEl.innerHTML = resValue; + } + else if ('.' == key.charAt(resKey.length)) + { + var attrKey = key.substring(resKey.length + 1); + resEl[attrKey] = resValue; + } + } + } + } + } + return resourceBundle; +}; + +/** + * Writes installation information to a file. + * + * @return The file path. + */ +CSInterface.prototype.dumpInstallationInfo = function() +{ + return window.__adobe_cep__.dumpInstallationInfo(); +}; + +/** + * Retrieves version information for the current Operating System, + * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values. + * + * @return A string containing the OS version, or "unknown Operation System". + * If user customizes the User Agent by setting CEF command parameter "--user-agent", only + * "Mac OS X" or "Windows" will be returned. + */ +CSInterface.prototype.getOSInformation = function() +{ + var userAgent = navigator.userAgent; + + if ((navigator.platform == "Win32") || (navigator.platform == "Windows")) + { + var winVersion = "Windows"; + var winBit = ""; + if (userAgent.indexOf("Windows") > -1) + { + if (userAgent.indexOf("Windows NT 5.0") > -1) + { + winVersion = "Windows 2000"; + } + else if (userAgent.indexOf("Windows NT 5.1") > -1) + { + winVersion = "Windows XP"; + } + else if (userAgent.indexOf("Windows NT 5.2") > -1) + { + winVersion = "Windows Server 2003"; + } + else if (userAgent.indexOf("Windows NT 6.0") > -1) + { + winVersion = "Windows Vista"; + } + else if (userAgent.indexOf("Windows NT 6.1") > -1) + { + winVersion = "Windows 7"; + } + else if (userAgent.indexOf("Windows NT 6.2") > -1) + { + winVersion = "Windows 8"; + } + else if (userAgent.indexOf("Windows NT 6.3") > -1) + { + winVersion = "Windows 8.1"; + } + else if (userAgent.indexOf("Windows NT 10") > -1) + { + winVersion = "Windows 10"; + } + + if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1) + { + winBit = " 64-bit"; + } + else + { + winBit = " 32-bit"; + } + } + + return winVersion + winBit; + } + else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh")) + { + var result = "Mac OS X"; + + if (userAgent.indexOf("Mac OS X") > -1) + { + result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")")); + result = result.replace(/_/g, "."); + } + + return result; + } + + return "Unknown Operation System"; +}; + +/** + * Opens a page in the default system browser. + * + * Since 4.2.0 + * + * @param url The URL of the page/file to open, or the email address. + * Must use HTTP/HTTPS/file/mailto protocol. For example: + * "http://www.adobe.com" + * "https://github.com" + * "file:///C:/log.txt" + * "mailto:test@adobe.com" + * + * @return One of these error codes:\n + *
    \n + *
  • NO_ERROR - 0
  • \n + *
  • ERR_UNKNOWN - 1
  • \n + *
  • ERR_INVALID_PARAMS - 2
  • \n + *
  • ERR_INVALID_URL - 201
  • \n + *
\n + */ +CSInterface.prototype.openURLInDefaultBrowser = function(url) +{ + return cep.util.openURLInDefaultBrowser(url); +}; + +/** + * Retrieves extension ID. + * + * Since 4.2.0 + * + * @return extension ID. + */ +CSInterface.prototype.getExtensionID = function() +{ + return window.__adobe_cep__.getExtensionId(); +}; + +/** + * Retrieves the scale factor of screen. + * On Windows platform, the value of scale factor might be different from operating system's scale factor, + * since host application may use its self-defined scale factor. + * + * Since 4.2.0 + * + * @return One of the following float number. + *
    \n + *
  • -1.0 when error occurs
  • \n + *
  • 1.0 means normal screen
  • \n + *
  • >1.0 means HiDPI screen
  • \n + *
\n + */ +CSInterface.prototype.getScaleFactor = function() +{ + return window.__adobe_cep__.getScaleFactor(); +}; + +/** + * Set a handler to detect any changes of scale factor. This only works on Mac. + * + * Since 4.2.0 + * + * @param handler The function to be called when scale factor is changed. + * + */ +CSInterface.prototype.setScaleFactorChangedHandler = function(handler) +{ + window.__adobe_cep__.setScaleFactorChangedHandler(handler); +}; + +/** + * Retrieves current API version. + * + * Since 4.2.0 + * + * @return ApiVersion object. + * + */ +CSInterface.prototype.getCurrentApiVersion = function() +{ + var apiVersion = JSON.parse(window.__adobe_cep__.getCurrentApiVersion()); + return apiVersion; +}; + +/** + * Set panel flyout menu by an XML. + * + * Since 5.2.0 + * + * Register a callback function for "com.adobe.csxs.events.flyoutMenuClicked" to get notified when a + * menu item is clicked. + * The "data" attribute of event is an object which contains "menuId" and "menuName" attributes. + * + * Register callback functions for "com.adobe.csxs.events.flyoutMenuOpened" and "com.adobe.csxs.events.flyoutMenuClosed" + * respectively to get notified when flyout menu is opened or closed. + * + * @param menu A XML string which describes menu structure. + * An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setPanelFlyoutMenu = function(menu) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeSync("setPanelFlyoutMenu", menu); +}; + +/** + * Updates a menu item in the extension window's flyout menu, by setting the enabled + * and selection status. + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + * + * @return false when the host application does not support this functionality (HostCapabilities.EXTENDED_PANEL_MENU is false). + * Fails silently if menu label is invalid. + * + * @see HostCapabilities.EXTENDED_PANEL_MENU + */ +CSInterface.prototype.updatePanelMenuItem = function(menuItemLabel, enabled, checked) +{ + var ret = false; + if (this.getHostCapabilities().EXTENDED_PANEL_MENU) + { + var itemStatus = new MenuItemStatus(menuItemLabel, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updatePanelMenuItem", JSON.stringify(itemStatus)); + } + return ret; +}; + + +/** + * Set context menu by XML string. + * + * Since 5.2.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item name is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + https://developer.chrome.com/extensions/contextMenus + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + * + * @param menu A XML string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setContextMenu = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenu", menu, callback); +}; + +/** + * Set context menu by JSON string. + * + * Since 6.0.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item label is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + https://developer.chrome.com/extensions/contextMenus + * + * @param menu A JSON string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu JSON: + * + * { + * "menu": [ + * { + * "id": "menuItemId1", + * "label": "testExample1", + * "enabled": true, + * "checkable": true, + * "checked": false, + * "icon": "./image/small_16X16.png" + * }, + * { + * "id": "menuItemId2", + * "label": "testExample2", + * "menu": [ + * { + * "id": "menuItemId2-1", + * "label": "testExample2-1", + * "menu": [ + * { + * "id": "menuItemId2-1-1", + * "label": "testExample2-1-1", + * "enabled": false, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "id": "menuItemId2-2", + * "label": "testExample2-2", + * "enabled": true, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "label": "---" + * }, + * { + * "id": "menuItemId3", + * "label": "testExample3", + * "enabled": false, + * "checkable": true, + * "checked": false + * } + * ] + * } + * + */ +CSInterface.prototype.setContextMenuByJSON = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenuByJSON", menu, callback); +}; + +/** + * Updates a context menu item by setting the enabled and selection status. + * + * Since 5.2.0 + * + * @param menuItemID The menu item ID. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + */ +CSInterface.prototype.updateContextMenuItem = function(menuItemID, enabled, checked) +{ + var itemStatus = new ContextMenuItemStatus(menuItemID, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updateContextMenuItem", JSON.stringify(itemStatus)); +}; + +/** + * Get the visibility status of an extension window. + * + * Since 6.0.0 + * + * @return true if the extension window is visible; false if the extension window is hidden. + */ +CSInterface.prototype.isWindowVisible = function() +{ + return window.__adobe_cep__.invokeSync("isWindowVisible", ""); +}; + +/** + * Resize extension's content to the specified dimensions. + * 1. Works with modal and modeless extensions in all Adobe products. + * 2. Extension's manifest min/max size constraints apply and take precedence. + * 3. For panel extensions + * 3.1 This works in all Adobe products except: + * * Premiere Pro + * * Prelude + * * After Effects + * 3.2 When the panel is in certain states (especially when being docked), + * it will not change to the desired dimensions even when the + * specified size satisfies min/max constraints. + * + * Since 6.0.0 + * + * @param width The new width + * @param height The new height + */ +CSInterface.prototype.resizeContent = function(width, height) +{ + window.__adobe_cep__.resizeContent(width, height); +}; + +/** + * Register the invalid certificate callback for an extension. + * This callback will be triggered when the extension tries to access the web site that contains the invalid certificate on the main frame. + * But if the extension does not call this function and tries to access the web site containing the invalid certificate, a default error page will be shown. + * + * Since 6.1.0 + * + * @param callback the callback function + */ +CSInterface.prototype.registerInvalidCertificateCallback = function(callback) +{ + return window.__adobe_cep__.registerInvalidCertificateCallback(callback); +}; + +/** + * Register an interest in some key events to prevent them from being sent to the host application. + * + * This function works with modeless extensions and panel extensions. + * Generally all the key events will be sent to the host application for these two extensions if the current focused element + * is not text input or dropdown, + * If you want to intercept some key events and want them to be handled in the extension, please call this function + * in advance to prevent them being sent to the host application. + * + * Since 6.1.0 + * + * @param keyEventsInterest A JSON string describing those key events you are interested in. A null object or + an empty string will lead to removing the interest + * + * This JSON string should be an array, each object has following keys: + * + * keyCode: [Required] represents an OS system dependent virtual key code identifying + * the unmodified value of the pressed key. + * ctrlKey: [optional] a Boolean that indicates if the control key was pressed (true) or not (false) when the event occurred. + * altKey: [optional] a Boolean that indicates if the alt key was pressed (true) or not (false) when the event occurred. + * shiftKey: [optional] a Boolean that indicates if the shift key was pressed (true) or not (false) when the event occurred. + * metaKey: [optional] (Mac Only) a Boolean that indicates if the Meta key was pressed (true) or not (false) when the event occurred. + * On Macintosh keyboards, this is the command key. To detect Windows key on Windows, please use keyCode instead. + * An example JSON string: + * + * [ + * { + * "keyCode": 48 + * }, + * { + * "keyCode": 123, + * "ctrlKey": true + * }, + * { + * "keyCode": 123, + * "ctrlKey": true, + * "metaKey": true + * } + * ] + * + */ +CSInterface.prototype.registerKeyEventsInterest = function(keyEventsInterest) +{ + return window.__adobe_cep__.registerKeyEventsInterest(keyEventsInterest); +}; + +/** + * Set the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @param title The window title. + */ +CSInterface.prototype.setWindowTitle = function(title) +{ + window.__adobe_cep__.invokeSync("setWindowTitle", title); +}; + +/** + * Get the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @return The window title. + */ +CSInterface.prototype.getWindowTitle = function() +{ + return window.__adobe_cep__.invokeSync("getWindowTitle", ""); +}; diff --git a/openpype/hosts/aftereffects/api/extension/js/libs/jquery-2.0.2.min.js b/openpype/hosts/aftereffects/api/extension/js/libs/jquery-2.0.2.min.js new file mode 100644 index 0000000000..73e5218d21 --- /dev/null +++ b/openpype/hosts/aftereffects/api/extension/js/libs/jquery-2.0.2.min.js @@ -0,0 +1,6 @@ +/*! jQuery v2.0.2 | (c) 2005, 2013 jQuery Foundation, Inc. | jquery.org/license +//@ sourceMappingURL=jquery-2.0.2.min.map +*/ +(function(e,undefined){var t,n,r=typeof undefined,i=e.location,o=e.document,s=o.documentElement,a=e.jQuery,u=e.$,l={},c=[],p="2.0.2",f=c.concat,h=c.push,d=c.slice,g=c.indexOf,m=l.toString,y=l.hasOwnProperty,v=p.trim,x=function(e,n){return new x.fn.init(e,n,t)},b=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,w=/\S+/g,T=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,k=/^-ms-/,N=/-([\da-z])/gi,E=function(e,t){return t.toUpperCase()},S=function(){o.removeEventListener("DOMContentLoaded",S,!1),e.removeEventListener("load",S,!1),x.ready()};x.fn=x.prototype={jquery:p,constructor:x,init:function(e,t,n){var r,i;if(!e)return this;if("string"==typeof e){if(r="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:T.exec(e),!r||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof x?t[0]:t,x.merge(this,x.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:o,!0)),C.test(r[1])&&x.isPlainObject(t))for(r in t)x.isFunction(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return i=o.getElementById(r[2]),i&&i.parentNode&&(this.length=1,this[0]=i),this.context=o,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):x.isFunction(e)?n.ready(e):(e.selector!==undefined&&(this.selector=e.selector,this.context=e.context),x.makeArray(e,this))},selector:"",length:0,toArray:function(){return d.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=x.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return x.each(this,e,t)},ready:function(e){return x.ready.promise().done(e),this},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(x.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:h,sort:[].sort,splice:[].splice},x.fn.init.prototype=x.fn,x.extend=x.fn.extend=function(){var e,t,n,r,i,o,s=arguments[0]||{},a=1,u=arguments.length,l=!1;for("boolean"==typeof s&&(l=s,s=arguments[1]||{},a=2),"object"==typeof s||x.isFunction(s)||(s={}),u===a&&(s=this,--a);u>a;a++)if(null!=(e=arguments[a]))for(t in e)n=s[t],r=e[t],s!==r&&(l&&r&&(x.isPlainObject(r)||(i=x.isArray(r)))?(i?(i=!1,o=n&&x.isArray(n)?n:[]):o=n&&x.isPlainObject(n)?n:{},s[t]=x.extend(l,o,r)):r!==undefined&&(s[t]=r));return s},x.extend({expando:"jQuery"+(p+Math.random()).replace(/\D/g,""),noConflict:function(t){return e.$===x&&(e.$=u),t&&e.jQuery===x&&(e.jQuery=a),x},isReady:!1,readyWait:1,holdReady:function(e){e?x.readyWait++:x.ready(!0)},ready:function(e){(e===!0?--x.readyWait:x.isReady)||(x.isReady=!0,e!==!0&&--x.readyWait>0||(n.resolveWith(o,[x]),x.fn.trigger&&x(o).trigger("ready").off("ready")))},isFunction:function(e){return"function"===x.type(e)},isArray:Array.isArray,isWindow:function(e){return null!=e&&e===e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[m.call(e)]||"object":typeof e},isPlainObject:function(e){if("object"!==x.type(e)||e.nodeType||x.isWindow(e))return!1;try{if(e.constructor&&!y.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(t){return!1}return!0},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||o;var r=C.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=x.buildFragment([e],t,i),i&&x(i).remove(),x.merge([],r.childNodes))},parseJSON:JSON.parse,parseXML:function(e){var t,n;if(!e||"string"!=typeof e)return null;try{n=new DOMParser,t=n.parseFromString(e,"text/xml")}catch(r){t=undefined}return(!t||t.getElementsByTagName("parsererror").length)&&x.error("Invalid XML: "+e),t},noop:function(){},globalEval:function(e){var t,n=eval;e=x.trim(e),e&&(1===e.indexOf("use strict")?(t=o.createElement("script"),t.text=e,o.head.appendChild(t).parentNode.removeChild(t)):n(e))},camelCase:function(e){return e.replace(k,"ms-").replace(N,E)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,n){var r,i=0,o=e.length,s=j(e);if(n){if(s){for(;o>i;i++)if(r=t.apply(e[i],n),r===!1)break}else for(i in e)if(r=t.apply(e[i],n),r===!1)break}else if(s){for(;o>i;i++)if(r=t.call(e[i],i,e[i]),r===!1)break}else for(i in e)if(r=t.call(e[i],i,e[i]),r===!1)break;return e},trim:function(e){return null==e?"":v.call(e)},makeArray:function(e,t){var n=t||[];return null!=e&&(j(Object(e))?x.merge(n,"string"==typeof e?[e]:e):h.call(n,e)),n},inArray:function(e,t,n){return null==t?-1:g.call(t,e,n)},merge:function(e,t){var n=t.length,r=e.length,i=0;if("number"==typeof n)for(;n>i;i++)e[r++]=t[i];else while(t[i]!==undefined)e[r++]=t[i++];return e.length=r,e},grep:function(e,t,n){var r,i=[],o=0,s=e.length;for(n=!!n;s>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,n){var r,i=0,o=e.length,s=j(e),a=[];if(s)for(;o>i;i++)r=t(e[i],i,n),null!=r&&(a[a.length]=r);else for(i in e)r=t(e[i],i,n),null!=r&&(a[a.length]=r);return f.apply([],a)},guid:1,proxy:function(e,t){var n,r,i;return"string"==typeof t&&(n=e[t],t=e,e=n),x.isFunction(e)?(r=d.call(arguments,2),i=function(){return e.apply(t||this,r.concat(d.call(arguments)))},i.guid=e.guid=e.guid||x.guid++,i):undefined},access:function(e,t,n,r,i,o,s){var a=0,u=e.length,l=null==n;if("object"===x.type(n)){i=!0;for(a in n)x.access(e,t,a,n[a],!0,o,s)}else if(r!==undefined&&(i=!0,x.isFunction(r)||(s=!0),l&&(s?(t.call(e,r),t=null):(l=t,t=function(e,t,n){return l.call(x(e),n)})),t))for(;u>a;a++)t(e[a],n,s?r:r.call(e[a],a,t(e[a],n)));return i?e:l?t.call(e):u?t(e[0],n):o},now:Date.now,swap:function(e,t,n,r){var i,o,s={};for(o in t)s[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=s[o];return i}}),x.ready.promise=function(t){return n||(n=x.Deferred(),"complete"===o.readyState?setTimeout(x.ready):(o.addEventListener("DOMContentLoaded",S,!1),e.addEventListener("load",S,!1))),n.promise(t)},x.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){l["[object "+t+"]"]=t.toLowerCase()});function j(e){var t=e.length,n=x.type(e);return x.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}t=x(o),function(e,undefined){var t,n,r,i,o,s,a,u,l,c,p,f,h,d,g,m,y,v="sizzle"+-new Date,b=e.document,w=0,T=0,C=at(),k=at(),N=at(),E=!1,S=function(){return 0},j=typeof undefined,D=1<<31,A={}.hasOwnProperty,L=[],H=L.pop,q=L.push,O=L.push,F=L.slice,P=L.indexOf||function(e){var t=0,n=this.length;for(;n>t;t++)if(this[t]===e)return t;return-1},R="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",W="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",$=W.replace("w","w#"),B="\\["+M+"*("+W+")"+M+"*(?:([*^$|!~]?=)"+M+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+$+")|)|)"+M+"*\\]",I=":("+W+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+B.replace(3,8)+")*)|.*)\\)|)",z=RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),_=RegExp("^"+M+"*,"+M+"*"),X=RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=RegExp(M+"*[+~]"),Y=RegExp("="+M+"*([^\\]'\"]*)"+M+"*\\]","g"),V=RegExp(I),G=RegExp("^"+$+"$"),J={ID:RegExp("^#("+W+")"),CLASS:RegExp("^\\.("+W+")"),TAG:RegExp("^("+W.replace("w","w*")+")"),ATTR:RegExp("^"+B),PSEUDO:RegExp("^"+I),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:RegExp("^(?:"+R+")$","i"),needsContext:RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Q=/^[^{]+\{\s*\[native \w/,K=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,Z=/^(?:input|select|textarea|button)$/i,et=/^h\d$/i,tt=/'|\\/g,nt=RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),rt=function(e,t,n){var r="0x"+t-65536;return r!==r||n?t:0>r?String.fromCharCode(r+65536):String.fromCharCode(55296|r>>10,56320|1023&r)};try{O.apply(L=F.call(b.childNodes),b.childNodes),L[b.childNodes.length].nodeType}catch(it){O={apply:L.length?function(e,t){q.apply(e,F.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function ot(e,t,r,i){var o,s,a,u,l,f,g,m,x,w;if((t?t.ownerDocument||t:b)!==p&&c(t),t=t||p,r=r||[],!e||"string"!=typeof e)return r;if(1!==(u=t.nodeType)&&9!==u)return[];if(h&&!i){if(o=K.exec(e))if(a=o[1]){if(9===u){if(s=t.getElementById(a),!s||!s.parentNode)return r;if(s.id===a)return r.push(s),r}else if(t.ownerDocument&&(s=t.ownerDocument.getElementById(a))&&y(t,s)&&s.id===a)return r.push(s),r}else{if(o[2])return O.apply(r,t.getElementsByTagName(e)),r;if((a=o[3])&&n.getElementsByClassName&&t.getElementsByClassName)return O.apply(r,t.getElementsByClassName(a)),r}if(n.qsa&&(!d||!d.test(e))){if(m=g=v,x=t,w=9===u&&e,1===u&&"object"!==t.nodeName.toLowerCase()){f=vt(e),(g=t.getAttribute("id"))?m=g.replace(tt,"\\$&"):t.setAttribute("id",m),m="[id='"+m+"'] ",l=f.length;while(l--)f[l]=m+xt(f[l]);x=U.test(e)&&t.parentNode||t,w=f.join(",")}if(w)try{return O.apply(r,x.querySelectorAll(w)),r}catch(T){}finally{g||t.removeAttribute("id")}}}return St(e.replace(z,"$1"),t,r,i)}function st(e){return Q.test(e+"")}function at(){var e=[];function t(n,r){return e.push(n+=" ")>i.cacheLength&&delete t[e.shift()],t[n]=r}return t}function ut(e){return e[v]=!0,e}function lt(e){var t=p.createElement("div");try{return!!e(t)}catch(n){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function ct(e,t,n){e=e.split("|");var r,o=e.length,s=n?null:t;while(o--)(r=i.attrHandle[e[o]])&&r!==t||(i.attrHandle[e[o]]=s)}function pt(e,t){var n=e.getAttributeNode(t);return n&&n.specified?n.value:e[t]===!0?t.toLowerCase():null}function ft(e,t){return e.getAttribute(t,"type"===t.toLowerCase()?1:2)}function ht(e){return"input"===e.nodeName.toLowerCase()?e.defaultValue:undefined}function dt(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&(~t.sourceIndex||D)-(~e.sourceIndex||D);if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function gt(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function mt(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function yt(e){return ut(function(t){return t=+t,ut(function(n,r){var i,o=e([],n.length,t),s=o.length;while(s--)n[i=o[s]]&&(n[i]=!(r[i]=n[i]))})})}s=ot.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},n=ot.support={},c=ot.setDocument=function(e){var t=e?e.ownerDocument||e:b,r=t.parentWindow;return t!==p&&9===t.nodeType&&t.documentElement?(p=t,f=t.documentElement,h=!s(t),r&&r.frameElement&&r.attachEvent("onbeforeunload",function(){c()}),n.attributes=lt(function(e){return e.innerHTML="",ct("type|href|height|width",ft,"#"===e.firstChild.getAttribute("href")),ct(R,pt,null==e.getAttribute("disabled")),e.className="i",!e.getAttribute("className")}),n.input=lt(function(e){return e.innerHTML="",e.firstChild.setAttribute("value",""),""===e.firstChild.getAttribute("value")}),ct("value",ht,n.attributes&&n.input),n.getElementsByTagName=lt(function(e){return e.appendChild(t.createComment("")),!e.getElementsByTagName("*").length}),n.getElementsByClassName=lt(function(e){return e.innerHTML="
",e.firstChild.className="i",2===e.getElementsByClassName("i").length}),n.getById=lt(function(e){return f.appendChild(e).id=v,!t.getElementsByName||!t.getElementsByName(v).length}),n.getById?(i.find.ID=function(e,t){if(typeof t.getElementById!==j&&h){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},i.filter.ID=function(e){var t=e.replace(nt,rt);return function(e){return e.getAttribute("id")===t}}):(delete i.find.ID,i.filter.ID=function(e){var t=e.replace(nt,rt);return function(e){var n=typeof e.getAttributeNode!==j&&e.getAttributeNode("id");return n&&n.value===t}}),i.find.TAG=n.getElementsByTagName?function(e,t){return typeof t.getElementsByTagName!==j?t.getElementsByTagName(e):undefined}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},i.find.CLASS=n.getElementsByClassName&&function(e,t){return typeof t.getElementsByClassName!==j&&h?t.getElementsByClassName(e):undefined},g=[],d=[],(n.qsa=st(t.querySelectorAll))&&(lt(function(e){e.innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll(":checked").length||d.push(":checked")}),lt(function(e){var n=t.createElement("input");n.setAttribute("type","hidden"),e.appendChild(n).setAttribute("t",""),e.querySelectorAll("[t^='']").length&&d.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll(":enabled").length||d.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),d.push(",.*:")})),(n.matchesSelector=st(m=f.webkitMatchesSelector||f.mozMatchesSelector||f.oMatchesSelector||f.msMatchesSelector))&<(function(e){n.disconnectedMatch=m.call(e,"div"),m.call(e,"[s!='']:x"),g.push("!=",I)}),d=d.length&&RegExp(d.join("|")),g=g.length&&RegExp(g.join("|")),y=st(f.contains)||f.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},n.sortDetached=lt(function(e){return 1&e.compareDocumentPosition(t.createElement("div"))}),S=f.compareDocumentPosition?function(e,r){if(e===r)return E=!0,0;var i=r.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(r);return i?1&i||!n.sortDetached&&r.compareDocumentPosition(e)===i?e===t||y(b,e)?-1:r===t||y(b,r)?1:l?P.call(l,e)-P.call(l,r):0:4&i?-1:1:e.compareDocumentPosition?-1:1}:function(e,n){var r,i=0,o=e.parentNode,s=n.parentNode,a=[e],u=[n];if(e===n)return E=!0,0;if(!o||!s)return e===t?-1:n===t?1:o?-1:s?1:l?P.call(l,e)-P.call(l,n):0;if(o===s)return dt(e,n);r=e;while(r=r.parentNode)a.unshift(r);r=n;while(r=r.parentNode)u.unshift(r);while(a[i]===u[i])i++;return i?dt(a[i],u[i]):a[i]===b?-1:u[i]===b?1:0},t):p},ot.matches=function(e,t){return ot(e,null,null,t)},ot.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&c(e),t=t.replace(Y,"='$1']"),!(!n.matchesSelector||!h||g&&g.test(t)||d&&d.test(t)))try{var r=m.call(e,t);if(r||n.disconnectedMatch||e.document&&11!==e.document.nodeType)return r}catch(i){}return ot(t,p,null,[e]).length>0},ot.contains=function(e,t){return(e.ownerDocument||e)!==p&&c(e),y(e,t)},ot.attr=function(e,t){(e.ownerDocument||e)!==p&&c(e);var r=i.attrHandle[t.toLowerCase()],o=r&&A.call(i.attrHandle,t.toLowerCase())?r(e,t,!h):undefined;return o===undefined?n.attributes||!h?e.getAttribute(t):(o=e.getAttributeNode(t))&&o.specified?o.value:null:o},ot.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},ot.uniqueSort=function(e){var t,r=[],i=0,o=0;if(E=!n.detectDuplicates,l=!n.sortStable&&e.slice(0),e.sort(S),E){while(t=e[o++])t===e[o]&&(i=r.push(o));while(i--)e.splice(r[i],1)}return e},o=ot.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=o(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=o(t);return n},i=ot.selectors={cacheLength:50,createPseudo:ut,match:J,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(nt,rt),e[3]=(e[4]||e[5]||"").replace(nt,rt),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||ot.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&ot.error(e[0]),e},PSEUDO:function(e){var t,n=!e[5]&&e[2];return J.CHILD.test(e[0])?null:(e[3]&&e[4]!==undefined?e[2]=e[4]:n&&V.test(n)&&(t=vt(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(nt,rt).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=C[e+" "];return t||(t=RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&C(e,function(e){return t.test("string"==typeof e.className&&e.className||typeof e.getAttribute!==j&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=ot.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.slice(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),s="last"!==e.slice(-4),a="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,p,f,h,d,g=o!==s?"nextSibling":"previousSibling",m=t.parentNode,y=a&&t.nodeName.toLowerCase(),x=!u&&!a;if(m){if(o){while(g){p=t;while(p=p[g])if(a?p.nodeName.toLowerCase()===y:1===p.nodeType)return!1;d=g="only"===e&&!d&&"nextSibling"}return!0}if(d=[s?m.firstChild:m.lastChild],s&&x){c=m[v]||(m[v]={}),l=c[e]||[],h=l[0]===w&&l[1],f=l[0]===w&&l[2],p=h&&m.childNodes[h];while(p=++h&&p&&p[g]||(f=h=0)||d.pop())if(1===p.nodeType&&++f&&p===t){c[e]=[w,h,f];break}}else if(x&&(l=(t[v]||(t[v]={}))[e])&&l[0]===w)f=l[1];else while(p=++h&&p&&p[g]||(f=h=0)||d.pop())if((a?p.nodeName.toLowerCase()===y:1===p.nodeType)&&++f&&(x&&((p[v]||(p[v]={}))[e]=[w,f]),p===t))break;return f-=i,f===r||0===f%r&&f/r>=0}}},PSEUDO:function(e,t){var n,r=i.pseudos[e]||i.setFilters[e.toLowerCase()]||ot.error("unsupported pseudo: "+e);return r[v]?r(t):r.length>1?(n=[e,e,"",t],i.setFilters.hasOwnProperty(e.toLowerCase())?ut(function(e,n){var i,o=r(e,t),s=o.length;while(s--)i=P.call(e,o[s]),e[i]=!(n[i]=o[s])}):function(e){return r(e,0,n)}):r}},pseudos:{not:ut(function(e){var t=[],n=[],r=a(e.replace(z,"$1"));return r[v]?ut(function(e,t,n,i){var o,s=r(e,null,i,[]),a=e.length;while(a--)(o=s[a])&&(e[a]=!(t[a]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:ut(function(e){return function(t){return ot(e,t).length>0}}),contains:ut(function(e){return function(t){return(t.textContent||t.innerText||o(t)).indexOf(e)>-1}}),lang:ut(function(e){return G.test(e||"")||ot.error("unsupported lang: "+e),e=e.replace(nt,rt).toLowerCase(),function(t){var n;do if(n=h?t.lang:t.getAttribute("xml:lang")||t.getAttribute("lang"))return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===f},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!i.pseudos.empty(e)},header:function(e){return et.test(e.nodeName)},input:function(e){return Z.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:yt(function(){return[0]}),last:yt(function(e,t){return[t-1]}),eq:yt(function(e,t,n){return[0>n?n+t:n]}),even:yt(function(e,t){var n=0;for(;t>n;n+=2)e.push(n);return e}),odd:yt(function(e,t){var n=1;for(;t>n;n+=2)e.push(n);return e}),lt:yt(function(e,t,n){var r=0>n?n+t:n;for(;--r>=0;)e.push(r);return e}),gt:yt(function(e,t,n){var r=0>n?n+t:n;for(;t>++r;)e.push(r);return e})}};for(t in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})i.pseudos[t]=gt(t);for(t in{submit:!0,reset:!0})i.pseudos[t]=mt(t);function vt(e,t){var n,r,o,s,a,u,l,c=k[e+" "];if(c)return t?0:c.slice(0);a=e,u=[],l=i.preFilter;while(a){(!n||(r=_.exec(a)))&&(r&&(a=a.slice(r[0].length)||a),u.push(o=[])),n=!1,(r=X.exec(a))&&(n=r.shift(),o.push({value:n,type:r[0].replace(z," ")}),a=a.slice(n.length));for(s in i.filter)!(r=J[s].exec(a))||l[s]&&!(r=l[s](r))||(n=r.shift(),o.push({value:n,type:s,matches:r}),a=a.slice(n.length));if(!n)break}return t?a.length:a?ot.error(e):k(e,u).slice(0)}function xt(e){var t=0,n=e.length,r="";for(;n>t;t++)r+=e[t].value;return r}function bt(e,t,n){var i=t.dir,o=n&&"parentNode"===i,s=T++;return t.first?function(t,n,r){while(t=t[i])if(1===t.nodeType||o)return e(t,n,r)}:function(t,n,a){var u,l,c,p=w+" "+s;if(a){while(t=t[i])if((1===t.nodeType||o)&&e(t,n,a))return!0}else while(t=t[i])if(1===t.nodeType||o)if(c=t[v]||(t[v]={}),(l=c[i])&&l[0]===p){if((u=l[1])===!0||u===r)return u===!0}else if(l=c[i]=[p],l[1]=e(t,n,a)||r,l[1]===!0)return!0}}function wt(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function Tt(e,t,n,r,i){var o,s=[],a=0,u=e.length,l=null!=t;for(;u>a;a++)(o=e[a])&&(!n||n(o,r,i))&&(s.push(o),l&&t.push(a));return s}function Ct(e,t,n,r,i,o){return r&&!r[v]&&(r=Ct(r)),i&&!i[v]&&(i=Ct(i,o)),ut(function(o,s,a,u){var l,c,p,f=[],h=[],d=s.length,g=o||Et(t||"*",a.nodeType?[a]:a,[]),m=!e||!o&&t?g:Tt(g,f,e,a,u),y=n?i||(o?e:d||r)?[]:s:m;if(n&&n(m,y,a,u),r){l=Tt(y,h),r(l,[],a,u),c=l.length;while(c--)(p=l[c])&&(y[h[c]]=!(m[h[c]]=p))}if(o){if(i||e){if(i){l=[],c=y.length;while(c--)(p=y[c])&&l.push(m[c]=p);i(null,y=[],l,u)}c=y.length;while(c--)(p=y[c])&&(l=i?P.call(o,p):f[c])>-1&&(o[l]=!(s[l]=p))}}else y=Tt(y===s?y.splice(d,y.length):y),i?i(null,s,y,u):O.apply(s,y)})}function kt(e){var t,n,r,o=e.length,s=i.relative[e[0].type],a=s||i.relative[" "],l=s?1:0,c=bt(function(e){return e===t},a,!0),p=bt(function(e){return P.call(t,e)>-1},a,!0),f=[function(e,n,r){return!s&&(r||n!==u)||((t=n).nodeType?c(e,n,r):p(e,n,r))}];for(;o>l;l++)if(n=i.relative[e[l].type])f=[bt(wt(f),n)];else{if(n=i.filter[e[l].type].apply(null,e[l].matches),n[v]){for(r=++l;o>r;r++)if(i.relative[e[r].type])break;return Ct(l>1&&wt(f),l>1&&xt(e.slice(0,l-1).concat({value:" "===e[l-2].type?"*":""})).replace(z,"$1"),n,r>l&&kt(e.slice(l,r)),o>r&&kt(e=e.slice(r)),o>r&&xt(e))}f.push(n)}return wt(f)}function Nt(e,t){var n=0,o=t.length>0,s=e.length>0,a=function(a,l,c,f,h){var d,g,m,y=[],v=0,x="0",b=a&&[],T=null!=h,C=u,k=a||s&&i.find.TAG("*",h&&l.parentNode||l),N=w+=null==C?1:Math.random()||.1;for(T&&(u=l!==p&&l,r=n);null!=(d=k[x]);x++){if(s&&d){g=0;while(m=e[g++])if(m(d,l,c)){f.push(d);break}T&&(w=N,r=++n)}o&&((d=!m&&d)&&v--,a&&b.push(d))}if(v+=x,o&&x!==v){g=0;while(m=t[g++])m(b,y,l,c);if(a){if(v>0)while(x--)b[x]||y[x]||(y[x]=H.call(f));y=Tt(y)}O.apply(f,y),T&&!a&&y.length>0&&v+t.length>1&&ot.uniqueSort(f)}return T&&(w=N,u=C),b};return o?ut(a):a}a=ot.compile=function(e,t){var n,r=[],i=[],o=N[e+" "];if(!o){t||(t=vt(e)),n=t.length;while(n--)o=kt(t[n]),o[v]?r.push(o):i.push(o);o=N(e,Nt(i,r))}return o};function Et(e,t,n){var r=0,i=t.length;for(;i>r;r++)ot(e,t[r],n);return n}function St(e,t,r,o){var s,u,l,c,p,f=vt(e);if(!o&&1===f.length){if(u=f[0]=f[0].slice(0),u.length>2&&"ID"===(l=u[0]).type&&n.getById&&9===t.nodeType&&h&&i.relative[u[1].type]){if(t=(i.find.ID(l.matches[0].replace(nt,rt),t)||[])[0],!t)return r;e=e.slice(u.shift().value.length)}s=J.needsContext.test(e)?0:u.length;while(s--){if(l=u[s],i.relative[c=l.type])break;if((p=i.find[c])&&(o=p(l.matches[0].replace(nt,rt),U.test(u[0].type)&&t.parentNode||t))){if(u.splice(s,1),e=o.length&&xt(u),!e)return O.apply(r,o),r;break}}}return a(e,f)(o,t,!h,r,U.test(e)),r}i.pseudos.nth=i.pseudos.eq;function jt(){}jt.prototype=i.filters=i.pseudos,i.setFilters=new jt,n.sortStable=v.split("").sort(S).join("")===v,c(),[0,0].sort(S),n.detectDuplicates=E,x.find=ot,x.expr=ot.selectors,x.expr[":"]=x.expr.pseudos,x.unique=ot.uniqueSort,x.text=ot.getText,x.isXMLDoc=ot.isXML,x.contains=ot.contains}(e);var D={};function A(e){var t=D[e]={};return x.each(e.match(w)||[],function(e,n){t[n]=!0}),t}x.Callbacks=function(e){e="string"==typeof e?D[e]||A(e):x.extend({},e);var t,n,r,i,o,s,a=[],u=!e.once&&[],l=function(p){for(t=e.memory&&p,n=!0,s=i||0,i=0,o=a.length,r=!0;a&&o>s;s++)if(a[s].apply(p[0],p[1])===!1&&e.stopOnFalse){t=!1;break}r=!1,a&&(u?u.length&&l(u.shift()):t?a=[]:c.disable())},c={add:function(){if(a){var n=a.length;(function s(t){x.each(t,function(t,n){var r=x.type(n);"function"===r?e.unique&&c.has(n)||a.push(n):n&&n.length&&"string"!==r&&s(n)})})(arguments),r?o=a.length:t&&(i=n,l(t))}return this},remove:function(){return a&&x.each(arguments,function(e,t){var n;while((n=x.inArray(t,a,n))>-1)a.splice(n,1),r&&(o>=n&&o--,s>=n&&s--)}),this},has:function(e){return e?x.inArray(e,a)>-1:!(!a||!a.length)},empty:function(){return a=[],o=0,this},disable:function(){return a=u=t=undefined,this},disabled:function(){return!a},lock:function(){return u=undefined,t||c.disable(),this},locked:function(){return!u},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],!a||n&&!u||(r?u.push(t):l(t)),this},fire:function(){return c.fireWith(this,arguments),this},fired:function(){return!!n}};return c},x.extend({Deferred:function(e){var t=[["resolve","done",x.Callbacks("once memory"),"resolved"],["reject","fail",x.Callbacks("once memory"),"rejected"],["notify","progress",x.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return x.Deferred(function(n){x.each(t,function(t,o){var s=o[0],a=x.isFunction(e[t])&&e[t];i[o[1]](function(){var e=a&&a.apply(this,arguments);e&&x.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[s+"With"](this===r?n.promise():this,a?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?x.extend(e,r):r}},i={};return r.pipe=r.then,x.each(t,function(e,o){var s=o[2],a=o[3];r[o[1]]=s.add,a&&s.add(function(){n=a},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=s.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=d.call(arguments),r=n.length,i=1!==r||e&&x.isFunction(e.promise)?r:0,o=1===i?e:x.Deferred(),s=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?d.call(arguments):r,n===a?o.notifyWith(t,n):--i||o.resolveWith(t,n)}},a,u,l;if(r>1)for(a=Array(r),u=Array(r),l=Array(r);r>t;t++)n[t]&&x.isFunction(n[t].promise)?n[t].promise().done(s(t,l,n)).fail(o.reject).progress(s(t,u,a)):--i;return i||o.resolveWith(l,n),o.promise()}}),x.support=function(t){var n=o.createElement("input"),r=o.createDocumentFragment(),i=o.createElement("div"),s=o.createElement("select"),a=s.appendChild(o.createElement("option"));return n.type?(n.type="checkbox",t.checkOn=""!==n.value,t.optSelected=a.selected,t.reliableMarginRight=!0,t.boxSizingReliable=!0,t.pixelPosition=!1,n.checked=!0,t.noCloneChecked=n.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!a.disabled,n=o.createElement("input"),n.value="t",n.type="radio",t.radioValue="t"===n.value,n.setAttribute("checked","t"),n.setAttribute("name","t"),r.appendChild(n),t.checkClone=r.cloneNode(!0).cloneNode(!0).lastChild.checked,t.focusinBubbles="onfocusin"in e,i.style.backgroundClip="content-box",i.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===i.style.backgroundClip,x(function(){var n,r,s="padding:0;margin:0;border:0;display:block;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box",a=o.getElementsByTagName("body")[0];a&&(n=o.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",a.appendChild(n).appendChild(i),i.innerHTML="",i.style.cssText="-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%",x.swap(a,null!=a.style.zoom?{zoom:1}:{},function(){t.boxSizing=4===i.offsetWidth}),e.getComputedStyle&&(t.pixelPosition="1%"!==(e.getComputedStyle(i,null)||{}).top,t.boxSizingReliable="4px"===(e.getComputedStyle(i,null)||{width:"4px"}).width,r=i.appendChild(o.createElement("div")),r.style.cssText=i.style.cssText=s,r.style.marginRight=r.style.width="0",i.style.width="1px",t.reliableMarginRight=!parseFloat((e.getComputedStyle(r,null)||{}).marginRight)),a.removeChild(n))}),t):t}({});var L,H,q=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,O=/([A-Z])/g;function F(){Object.defineProperty(this.cache={},0,{get:function(){return{}}}),this.expando=x.expando+Math.random()}F.uid=1,F.accepts=function(e){return e.nodeType?1===e.nodeType||9===e.nodeType:!0},F.prototype={key:function(e){if(!F.accepts(e))return 0;var t={},n=e[this.expando];if(!n){n=F.uid++;try{t[this.expando]={value:n},Object.defineProperties(e,t)}catch(r){t[this.expando]=n,x.extend(e,t)}}return this.cache[n]||(this.cache[n]={}),n},set:function(e,t,n){var r,i=this.key(e),o=this.cache[i];if("string"==typeof t)o[t]=n;else if(x.isEmptyObject(o))x.extend(this.cache[i],t);else for(r in t)o[r]=t[r];return o},get:function(e,t){var n=this.cache[this.key(e)];return t===undefined?n:n[t]},access:function(e,t,n){return t===undefined||t&&"string"==typeof t&&n===undefined?this.get(e,t):(this.set(e,t,n),n!==undefined?n:t)},remove:function(e,t){var n,r,i,o=this.key(e),s=this.cache[o];if(t===undefined)this.cache[o]={};else{x.isArray(t)?r=t.concat(t.map(x.camelCase)):(i=x.camelCase(t),t in s?r=[t,i]:(r=i,r=r in s?[r]:r.match(w)||[])),n=r.length;while(n--)delete s[r[n]]}},hasData:function(e){return!x.isEmptyObject(this.cache[e[this.expando]]||{})},discard:function(e){e[this.expando]&&delete this.cache[e[this.expando]]}},L=new F,H=new F,x.extend({acceptData:F.accepts,hasData:function(e){return L.hasData(e)||H.hasData(e)},data:function(e,t,n){return L.access(e,t,n)},removeData:function(e,t){L.remove(e,t)},_data:function(e,t,n){return H.access(e,t,n)},_removeData:function(e,t){H.remove(e,t)}}),x.fn.extend({data:function(e,t){var n,r,i=this[0],o=0,s=null;if(e===undefined){if(this.length&&(s=L.get(i),1===i.nodeType&&!H.get(i,"hasDataAttrs"))){for(n=i.attributes;n.length>o;o++)r=n[o].name,0===r.indexOf("data-")&&(r=x.camelCase(r.slice(5)),P(i,r,s[r]));H.set(i,"hasDataAttrs",!0)}return s}return"object"==typeof e?this.each(function(){L.set(this,e)}):x.access(this,function(t){var n,r=x.camelCase(e);if(i&&t===undefined){if(n=L.get(i,e),n!==undefined)return n;if(n=L.get(i,r),n!==undefined)return n;if(n=P(i,r,undefined),n!==undefined)return n}else this.each(function(){var n=L.get(this,r);L.set(this,r,t),-1!==e.indexOf("-")&&n!==undefined&&L.set(this,e,t)})},null,t,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){L.remove(this,e)})}});function P(e,t,n){var r;if(n===undefined&&1===e.nodeType)if(r="data-"+t.replace(O,"-$1").toLowerCase(),n=e.getAttribute(r),"string"==typeof n){try{n="true"===n?!0:"false"===n?!1:"null"===n?null:+n+""===n?+n:q.test(n)?JSON.parse(n):n}catch(i){}L.set(e,t,n)}else n=undefined;return n}x.extend({queue:function(e,t,n){var r;return e?(t=(t||"fx")+"queue",r=H.get(e,t),n&&(!r||x.isArray(n)?r=H.access(e,t,x.makeArray(n)):r.push(n)),r||[]):undefined},dequeue:function(e,t){t=t||"fx";var n=x.queue(e,t),r=n.length,i=n.shift(),o=x._queueHooks(e,t),s=function(){x.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,s,o)),!r&&o&&o.empty.fire() +},_queueHooks:function(e,t){var n=t+"queueHooks";return H.get(e,n)||H.access(e,n,{empty:x.Callbacks("once memory").add(function(){H.remove(e,[t+"queue",n])})})}}),x.fn.extend({queue:function(e,t){var n=2;return"string"!=typeof e&&(t=e,e="fx",n--),n>arguments.length?x.queue(this[0],e):t===undefined?this:this.each(function(){var n=x.queue(this,e,t);x._queueHooks(this,e),"fx"===e&&"inprogress"!==n[0]&&x.dequeue(this,e)})},dequeue:function(e){return this.each(function(){x.dequeue(this,e)})},delay:function(e,t){return e=x.fx?x.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,t){var n,r=1,i=x.Deferred(),o=this,s=this.length,a=function(){--r||i.resolveWith(o,[o])};"string"!=typeof e&&(t=e,e=undefined),e=e||"fx";while(s--)n=H.get(o[s],e+"queueHooks"),n&&n.empty&&(r++,n.empty.add(a));return a(),i.promise(t)}});var R,M,W=/[\t\r\n\f]/g,$=/\r/g,B=/^(?:input|select|textarea|button)$/i;x.fn.extend({attr:function(e,t){return x.access(this,x.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){x.removeAttr(this,e)})},prop:function(e,t){return x.access(this,x.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[x.propFix[e]||e]})},addClass:function(e){var t,n,r,i,o,s=0,a=this.length,u="string"==typeof e&&e;if(x.isFunction(e))return this.each(function(t){x(this).addClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];a>s;s++)if(n=this[s],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(W," "):" ")){o=0;while(i=t[o++])0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=x.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,s=0,a=this.length,u=0===arguments.length||"string"==typeof e&&e;if(x.isFunction(e))return this.each(function(t){x(this).removeClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];a>s;s++)if(n=this[s],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(W," "):"")){o=0;while(i=t[o++])while(r.indexOf(" "+i+" ")>=0)r=r.replace(" "+i+" "," ");n.className=e?x.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e,i="boolean"==typeof t;return x.isFunction(e)?this.each(function(n){x(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n){var o,s=0,a=x(this),u=t,l=e.match(w)||[];while(o=l[s++])u=i?u:!a.hasClass(o),a[u?"addClass":"removeClass"](o)}else(n===r||"boolean"===n)&&(this.className&&H.set(this,"__className__",this.className),this.className=this.className||e===!1?"":H.get(this,"__className__")||"")})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(W," ").indexOf(t)>=0)return!0;return!1},val:function(e){var t,n,r,i=this[0];{if(arguments.length)return r=x.isFunction(e),this.each(function(n){var i;1===this.nodeType&&(i=r?e.call(this,n,x(this).val()):e,null==i?i="":"number"==typeof i?i+="":x.isArray(i)&&(i=x.map(i,function(e){return null==e?"":e+""})),t=x.valHooks[this.type]||x.valHooks[this.nodeName.toLowerCase()],t&&"set"in t&&t.set(this,i,"value")!==undefined||(this.value=i))});if(i)return t=x.valHooks[i.type]||x.valHooks[i.nodeName.toLowerCase()],t&&"get"in t&&(n=t.get(i,"value"))!==undefined?n:(n=i.value,"string"==typeof n?n.replace($,""):null==n?"":n)}}}),x.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,s=o?null:[],a=o?i+1:r.length,u=0>i?a:o?i:0;for(;a>u;u++)if(n=r[u],!(!n.selected&&u!==i||(x.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&x.nodeName(n.parentNode,"optgroup"))){if(t=x(n).val(),o)return t;s.push(t)}return s},set:function(e,t){var n,r,i=e.options,o=x.makeArray(t),s=i.length;while(s--)r=i[s],(r.selected=x.inArray(x(r).val(),o)>=0)&&(n=!0);return n||(e.selectedIndex=-1),o}}},attr:function(e,t,n){var i,o,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return typeof e.getAttribute===r?x.prop(e,t,n):(1===s&&x.isXMLDoc(e)||(t=t.toLowerCase(),i=x.attrHooks[t]||(x.expr.match.bool.test(t)?M:R)),n===undefined?i&&"get"in i&&null!==(o=i.get(e,t))?o:(o=x.find.attr(e,t),null==o?undefined:o):null!==n?i&&"set"in i&&(o=i.set(e,n,t))!==undefined?o:(e.setAttribute(t,n+""),n):(x.removeAttr(e,t),undefined))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(w);if(o&&1===e.nodeType)while(n=o[i++])r=x.propFix[n]||n,x.expr.match.bool.test(n)&&(e[r]=!1),e.removeAttribute(n)},attrHooks:{type:{set:function(e,t){if(!x.support.radioValue&&"radio"===t&&x.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{"for":"htmlFor","class":"className"},prop:function(e,t,n){var r,i,o,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return o=1!==s||!x.isXMLDoc(e),o&&(t=x.propFix[t]||t,i=x.propHooks[t]),n!==undefined?i&&"set"in i&&(r=i.set(e,n,t))!==undefined?r:e[t]=n:i&&"get"in i&&null!==(r=i.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){return e.hasAttribute("tabindex")||B.test(e.nodeName)||e.href?e.tabIndex:-1}}}}),M={set:function(e,t,n){return t===!1?x.removeAttr(e,n):e.setAttribute(n,n),n}},x.each(x.expr.match.bool.source.match(/\w+/g),function(e,t){var n=x.expr.attrHandle[t]||x.find.attr;x.expr.attrHandle[t]=function(e,t,r){var i=x.expr.attrHandle[t],o=r?undefined:(x.expr.attrHandle[t]=undefined)!=n(e,t,r)?t.toLowerCase():null;return x.expr.attrHandle[t]=i,o}}),x.support.optSelected||(x.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null}}),x.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){x.propFix[this.toLowerCase()]=this}),x.each(["radio","checkbox"],function(){x.valHooks[this]={set:function(e,t){return x.isArray(t)?e.checked=x.inArray(x(e).val(),t)>=0:undefined}},x.support.checkOn||(x.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})});var I=/^key/,z=/^(?:mouse|contextmenu)|click/,_=/^(?:focusinfocus|focusoutblur)$/,X=/^([^.]*)(?:\.(.+)|)$/;function U(){return!0}function Y(){return!1}function V(){try{return o.activeElement}catch(e){}}x.event={global:{},add:function(e,t,n,i,o){var s,a,u,l,c,p,f,h,d,g,m,y=H.get(e);if(y){n.handler&&(s=n,n=s.handler,o=s.selector),n.guid||(n.guid=x.guid++),(l=y.events)||(l=y.events={}),(a=y.handle)||(a=y.handle=function(e){return typeof x===r||e&&x.event.triggered===e.type?undefined:x.event.dispatch.apply(a.elem,arguments)},a.elem=e),t=(t||"").match(w)||[""],c=t.length;while(c--)u=X.exec(t[c])||[],d=m=u[1],g=(u[2]||"").split(".").sort(),d&&(f=x.event.special[d]||{},d=(o?f.delegateType:f.bindType)||d,f=x.event.special[d]||{},p=x.extend({type:d,origType:m,data:i,handler:n,guid:n.guid,selector:o,needsContext:o&&x.expr.match.needsContext.test(o),namespace:g.join(".")},s),(h=l[d])||(h=l[d]=[],h.delegateCount=0,f.setup&&f.setup.call(e,i,g,a)!==!1||e.addEventListener&&e.addEventListener(d,a,!1)),f.add&&(f.add.call(e,p),p.handler.guid||(p.handler.guid=n.guid)),o?h.splice(h.delegateCount++,0,p):h.push(p),x.event.global[d]=!0);e=null}},remove:function(e,t,n,r,i){var o,s,a,u,l,c,p,f,h,d,g,m=H.hasData(e)&&H.get(e);if(m&&(u=m.events)){t=(t||"").match(w)||[""],l=t.length;while(l--)if(a=X.exec(t[l])||[],h=g=a[1],d=(a[2]||"").split(".").sort(),h){p=x.event.special[h]||{},h=(r?p.delegateType:p.bindType)||h,f=u[h]||[],a=a[2]&&RegExp("(^|\\.)"+d.join("\\.(?:.*\\.|)")+"(\\.|$)"),s=o=f.length;while(o--)c=f[o],!i&&g!==c.origType||n&&n.guid!==c.guid||a&&!a.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(f.splice(o,1),c.selector&&f.delegateCount--,p.remove&&p.remove.call(e,c));s&&!f.length&&(p.teardown&&p.teardown.call(e,d,m.handle)!==!1||x.removeEvent(e,h,m.handle),delete u[h])}else for(h in u)x.event.remove(e,h+t[l],n,r,!0);x.isEmptyObject(u)&&(delete m.handle,H.remove(e,"events"))}},trigger:function(t,n,r,i){var s,a,u,l,c,p,f,h=[r||o],d=y.call(t,"type")?t.type:t,g=y.call(t,"namespace")?t.namespace.split("."):[];if(a=u=r=r||o,3!==r.nodeType&&8!==r.nodeType&&!_.test(d+x.event.triggered)&&(d.indexOf(".")>=0&&(g=d.split("."),d=g.shift(),g.sort()),c=0>d.indexOf(":")&&"on"+d,t=t[x.expando]?t:new x.Event(d,"object"==typeof t&&t),t.isTrigger=i?2:3,t.namespace=g.join("."),t.namespace_re=t.namespace?RegExp("(^|\\.)"+g.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=undefined,t.target||(t.target=r),n=null==n?[t]:x.makeArray(n,[t]),f=x.event.special[d]||{},i||!f.trigger||f.trigger.apply(r,n)!==!1)){if(!i&&!f.noBubble&&!x.isWindow(r)){for(l=f.delegateType||d,_.test(l+d)||(a=a.parentNode);a;a=a.parentNode)h.push(a),u=a;u===(r.ownerDocument||o)&&h.push(u.defaultView||u.parentWindow||e)}s=0;while((a=h[s++])&&!t.isPropagationStopped())t.type=s>1?l:f.bindType||d,p=(H.get(a,"events")||{})[t.type]&&H.get(a,"handle"),p&&p.apply(a,n),p=c&&a[c],p&&x.acceptData(a)&&p.apply&&p.apply(a,n)===!1&&t.preventDefault();return t.type=d,i||t.isDefaultPrevented()||f._default&&f._default.apply(h.pop(),n)!==!1||!x.acceptData(r)||c&&x.isFunction(r[d])&&!x.isWindow(r)&&(u=r[c],u&&(r[c]=null),x.event.triggered=d,r[d](),x.event.triggered=undefined,u&&(r[c]=u)),t.result}},dispatch:function(e){e=x.event.fix(e);var t,n,r,i,o,s=[],a=d.call(arguments),u=(H.get(this,"events")||{})[e.type]||[],l=x.event.special[e.type]||{};if(a[0]=e,e.delegateTarget=this,!l.preDispatch||l.preDispatch.call(this,e)!==!1){s=x.event.handlers.call(this,e,u),t=0;while((i=s[t++])&&!e.isPropagationStopped()){e.currentTarget=i.elem,n=0;while((o=i.handlers[n++])&&!e.isImmediatePropagationStopped())(!e.namespace_re||e.namespace_re.test(o.namespace))&&(e.handleObj=o,e.data=o.data,r=((x.event.special[o.origType]||{}).handle||o.handler).apply(i.elem,a),r!==undefined&&(e.result=r)===!1&&(e.preventDefault(),e.stopPropagation()))}return l.postDispatch&&l.postDispatch.call(this,e),e.result}},handlers:function(e,t){var n,r,i,o,s=[],a=t.delegateCount,u=e.target;if(a&&u.nodeType&&(!e.button||"click"!==e.type))for(;u!==this;u=u.parentNode||this)if(u.disabled!==!0||"click"!==e.type){for(r=[],n=0;a>n;n++)o=t[n],i=o.selector+" ",r[i]===undefined&&(r[i]=o.needsContext?x(i,this).index(u)>=0:x.find(i,this,null,[u]).length),r[i]&&r.push(o);r.length&&s.push({elem:u,handlers:r})}return t.length>a&&s.push({elem:this,handlers:t.slice(a)}),s},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,t){var n,r,i,s=t.button;return null==e.pageX&&null!=t.clientX&&(n=e.target.ownerDocument||o,r=n.documentElement,i=n.body,e.pageX=t.clientX+(r&&r.scrollLeft||i&&i.scrollLeft||0)-(r&&r.clientLeft||i&&i.clientLeft||0),e.pageY=t.clientY+(r&&r.scrollTop||i&&i.scrollTop||0)-(r&&r.clientTop||i&&i.clientTop||0)),e.which||s===undefined||(e.which=1&s?1:2&s?3:4&s?2:0),e}},fix:function(e){if(e[x.expando])return e;var t,n,r,i=e.type,s=e,a=this.fixHooks[i];a||(this.fixHooks[i]=a=z.test(i)?this.mouseHooks:I.test(i)?this.keyHooks:{}),r=a.props?this.props.concat(a.props):this.props,e=new x.Event(s),t=r.length;while(t--)n=r[t],e[n]=s[n];return e.target||(e.target=o),3===e.target.nodeType&&(e.target=e.target.parentNode),a.filter?a.filter(e,s):e},special:{load:{noBubble:!0},focus:{trigger:function(){return this!==V()&&this.focus?(this.focus(),!1):undefined},delegateType:"focusin"},blur:{trigger:function(){return this===V()&&this.blur?(this.blur(),!1):undefined},delegateType:"focusout"},click:{trigger:function(){return"checkbox"===this.type&&this.click&&x.nodeName(this,"input")?(this.click(),!1):undefined},_default:function(e){return x.nodeName(e.target,"a")}},beforeunload:{postDispatch:function(e){e.result!==undefined&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=x.extend(new x.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?x.event.trigger(i,null,t):x.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},x.removeEvent=function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)},x.Event=function(e,t){return this instanceof x.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.getPreventDefault&&e.getPreventDefault()?U:Y):this.type=e,t&&x.extend(this,t),this.timeStamp=e&&e.timeStamp||x.now(),this[x.expando]=!0,undefined):new x.Event(e,t)},x.Event.prototype={isDefaultPrevented:Y,isPropagationStopped:Y,isImmediatePropagationStopped:Y,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=U,e&&e.preventDefault&&e.preventDefault()},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=U,e&&e.stopPropagation&&e.stopPropagation()},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=U,this.stopPropagation()}},x.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){x.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj;return(!i||i!==r&&!x.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),x.support.focusinBubbles||x.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){x.event.simulate(t,e.target,x.event.fix(e),!0)};x.event.special[t]={setup:function(){0===n++&&o.addEventListener(e,r,!0)},teardown:function(){0===--n&&o.removeEventListener(e,r,!0)}}}),x.fn.extend({on:function(e,t,n,r,i){var o,s;if("object"==typeof e){"string"!=typeof t&&(n=n||t,t=undefined);for(s in e)this.on(s,t,n,e[s],i);return this}if(null==n&&null==r?(r=t,n=t=undefined):null==r&&("string"==typeof t?(r=n,n=undefined):(r=n,n=t,t=undefined)),r===!1)r=Y;else if(!r)return this;return 1===i&&(o=r,r=function(e){return x().off(e),o.apply(this,arguments)},r.guid=o.guid||(o.guid=x.guid++)),this.each(function(){x.event.add(this,e,r,n,t)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,t,n){var r,i;if(e&&e.preventDefault&&e.handleObj)return r=e.handleObj,x(e.delegateTarget).off(r.namespace?r.origType+"."+r.namespace:r.origType,r.selector,r.handler),this;if("object"==typeof e){for(i in e)this.off(i,t,e[i]);return this}return(t===!1||"function"==typeof t)&&(n=t,t=undefined),n===!1&&(n=Y),this.each(function(){x.event.remove(this,e,n,t)})},trigger:function(e,t){return this.each(function(){x.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];return n?x.event.trigger(e,t,n,!0):undefined}});var G=/^.[^:#\[\.,]*$/,J=/^(?:parents|prev(?:Until|All))/,Q=x.expr.match.needsContext,K={children:!0,contents:!0,next:!0,prev:!0};x.fn.extend({find:function(e){var t,n=[],r=this,i=r.length;if("string"!=typeof e)return this.pushStack(x(e).filter(function(){for(t=0;i>t;t++)if(x.contains(r[t],this))return!0}));for(t=0;i>t;t++)x.find(e,r[t],n);return n=this.pushStack(i>1?x.unique(n):n),n.selector=this.selector?this.selector+" "+e:e,n},has:function(e){var t=x(e,this),n=t.length;return this.filter(function(){var e=0;for(;n>e;e++)if(x.contains(this,t[e]))return!0})},not:function(e){return this.pushStack(et(this,e||[],!0))},filter:function(e){return this.pushStack(et(this,e||[],!1))},is:function(e){return!!et(this,"string"==typeof e&&Q.test(e)?x(e):e||[],!1).length},closest:function(e,t){var n,r=0,i=this.length,o=[],s=Q.test(e)||"string"!=typeof e?x(e,t||this.context):0;for(;i>r;r++)for(n=this[r];n&&n!==t;n=n.parentNode)if(11>n.nodeType&&(s?s.index(n)>-1:1===n.nodeType&&x.find.matchesSelector(n,e))){n=o.push(n);break}return this.pushStack(o.length>1?x.unique(o):o)},index:function(e){return e?"string"==typeof e?g.call(x(e),this[0]):g.call(this,e.jquery?e[0]:e):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?x(e,t):x.makeArray(e&&e.nodeType?[e]:e),r=x.merge(this.get(),n);return this.pushStack(x.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}});function Z(e,t){while((e=e[t])&&1!==e.nodeType);return e}x.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return x.dir(e,"parentNode")},parentsUntil:function(e,t,n){return x.dir(e,"parentNode",n)},next:function(e){return Z(e,"nextSibling")},prev:function(e){return Z(e,"previousSibling")},nextAll:function(e){return x.dir(e,"nextSibling")},prevAll:function(e){return x.dir(e,"previousSibling")},nextUntil:function(e,t,n){return x.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return x.dir(e,"previousSibling",n)},siblings:function(e){return x.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return x.sibling(e.firstChild)},contents:function(e){return e.contentDocument||x.merge([],e.childNodes)}},function(e,t){x.fn[e]=function(n,r){var i=x.map(this,t,n);return"Until"!==e.slice(-5)&&(r=n),r&&"string"==typeof r&&(i=x.filter(r,i)),this.length>1&&(K[e]||x.unique(i),J.test(e)&&i.reverse()),this.pushStack(i)}}),x.extend({filter:function(e,t,n){var r=t[0];return n&&(e=":not("+e+")"),1===t.length&&1===r.nodeType?x.find.matchesSelector(r,e)?[r]:[]:x.find.matches(e,x.grep(t,function(e){return 1===e.nodeType}))},dir:function(e,t,n){var r=[],i=n!==undefined;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&x(e).is(n))break;r.push(e)}return r},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});function et(e,t,n){if(x.isFunction(t))return x.grep(e,function(e,r){return!!t.call(e,r,e)!==n});if(t.nodeType)return x.grep(e,function(e){return e===t!==n});if("string"==typeof t){if(G.test(t))return x.filter(t,e,n);t=x.filter(t,e)}return x.grep(e,function(e){return g.call(t,e)>=0!==n})}var tt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,nt=/<([\w:]+)/,rt=/<|&#?\w+;/,it=/<(?:script|style|link)/i,ot=/^(?:checkbox|radio)$/i,st=/checked\s*(?:[^=]|=\s*.checked.)/i,at=/^$|\/(?:java|ecma)script/i,ut=/^true\/(.*)/,lt=/^\s*\s*$/g,ct={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ct.optgroup=ct.option,ct.tbody=ct.tfoot=ct.colgroup=ct.caption=ct.thead,ct.th=ct.td,x.fn.extend({text:function(e){return x.access(this,function(e){return e===undefined?x.text(this):this.empty().append((this[0]&&this[0].ownerDocument||o).createTextNode(e))},null,e,arguments.length)},append:function(){return this.domManip(arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=pt(this,e);t.appendChild(e)}})},prepend:function(){return this.domManip(arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=pt(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return this.domManip(arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){var n,r=e?x.filter(e,this):this,i=0;for(;null!=(n=r[i]);i++)t||1!==n.nodeType||x.cleanData(mt(n)),n.parentNode&&(t&&x.contains(n.ownerDocument,n)&&dt(mt(n,"script")),n.parentNode.removeChild(n));return this},empty:function(){var e,t=0;for(;null!=(e=this[t]);t++)1===e.nodeType&&(x.cleanData(mt(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return x.clone(this,e,t)})},html:function(e){return x.access(this,function(e){var t=this[0]||{},n=0,r=this.length;if(e===undefined&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!it.test(e)&&!ct[(nt.exec(e)||["",""])[1].toLowerCase()]){e=e.replace(tt,"<$1>");try{for(;r>n;n++)t=this[n]||{},1===t.nodeType&&(x.cleanData(mt(t,!1)),t.innerHTML=e);t=0}catch(i){}}t&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(){var e=x.map(this,function(e){return[e.nextSibling,e.parentNode]}),t=0;return this.domManip(arguments,function(n){var r=e[t++],i=e[t++];i&&(r&&r.parentNode!==i&&(r=this.nextSibling),x(this).remove(),i.insertBefore(n,r))},!0),t?this:this.remove()},detach:function(e){return this.remove(e,!0)},domManip:function(e,t,n){e=f.apply([],e);var r,i,o,s,a,u,l=0,c=this.length,p=this,h=c-1,d=e[0],g=x.isFunction(d);if(g||!(1>=c||"string"!=typeof d||x.support.checkClone)&&st.test(d))return this.each(function(r){var i=p.eq(r);g&&(e[0]=d.call(this,r,i.html())),i.domManip(e,t,n)});if(c&&(r=x.buildFragment(e,this[0].ownerDocument,!1,!n&&this),i=r.firstChild,1===r.childNodes.length&&(r=i),i)){for(o=x.map(mt(r,"script"),ft),s=o.length;c>l;l++)a=r,l!==h&&(a=x.clone(a,!0,!0),s&&x.merge(o,mt(a,"script"))),t.call(this[l],a,l);if(s)for(u=o[o.length-1].ownerDocument,x.map(o,ht),l=0;s>l;l++)a=o[l],at.test(a.type||"")&&!H.access(a,"globalEval")&&x.contains(u,a)&&(a.src?x._evalUrl(a.src):x.globalEval(a.textContent.replace(lt,"")))}return this}}),x.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){x.fn[e]=function(e){var n,r=[],i=x(e),o=i.length-1,s=0;for(;o>=s;s++)n=s===o?this:this.clone(!0),x(i[s])[t](n),h.apply(r,n.get());return this.pushStack(r)}}),x.extend({clone:function(e,t,n){var r,i,o,s,a=e.cloneNode(!0),u=x.contains(e.ownerDocument,e);if(!(x.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||x.isXMLDoc(e)))for(s=mt(a),o=mt(e),r=0,i=o.length;i>r;r++)yt(o[r],s[r]);if(t)if(n)for(o=o||mt(e),s=s||mt(a),r=0,i=o.length;i>r;r++)gt(o[r],s[r]);else gt(e,a);return s=mt(a,"script"),s.length>0&&dt(s,!u&&mt(e,"script")),a},buildFragment:function(e,t,n,r){var i,o,s,a,u,l,c=0,p=e.length,f=t.createDocumentFragment(),h=[];for(;p>c;c++)if(i=e[c],i||0===i)if("object"===x.type(i))x.merge(h,i.nodeType?[i]:i);else if(rt.test(i)){o=o||f.appendChild(t.createElement("div")),s=(nt.exec(i)||["",""])[1].toLowerCase(),a=ct[s]||ct._default,o.innerHTML=a[1]+i.replace(tt,"<$1>")+a[2],l=a[0];while(l--)o=o.firstChild;x.merge(h,o.childNodes),o=f.firstChild,o.textContent=""}else h.push(t.createTextNode(i));f.textContent="",c=0;while(i=h[c++])if((!r||-1===x.inArray(i,r))&&(u=x.contains(i.ownerDocument,i),o=mt(f.appendChild(i),"script"),u&&dt(o),n)){l=0;while(i=o[l++])at.test(i.type||"")&&n.push(i)}return f},cleanData:function(e){var t,n,r,i,o,s,a=x.event.special,u=0;for(;(n=e[u])!==undefined;u++){if(F.accepts(n)&&(o=n[H.expando],o&&(t=H.cache[o]))){if(r=Object.keys(t.events||{}),r.length)for(s=0;(i=r[s])!==undefined;s++)a[i]?x.event.remove(n,i):x.removeEvent(n,i,t.handle);H.cache[o]&&delete H.cache[o]}delete L.cache[n[L.expando]]}},_evalUrl:function(e){return x.ajax({url:e,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0})}});function pt(e,t){return x.nodeName(e,"table")&&x.nodeName(1===t.nodeType?t:t.firstChild,"tr")?e.getElementsByTagName("tbody")[0]||e.appendChild(e.ownerDocument.createElement("tbody")):e}function ft(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function ht(e){var t=ut.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function dt(e,t){var n=e.length,r=0;for(;n>r;r++)H.set(e[r],"globalEval",!t||H.get(t[r],"globalEval"))}function gt(e,t){var n,r,i,o,s,a,u,l;if(1===t.nodeType){if(H.hasData(e)&&(o=H.access(e),s=H.set(t,o),l=o.events)){delete s.handle,s.events={};for(i in l)for(n=0,r=l[i].length;r>n;n++)x.event.add(t,i,l[i][n])}L.hasData(e)&&(a=L.access(e),u=x.extend({},a),L.set(t,u))}}function mt(e,t){var n=e.getElementsByTagName?e.getElementsByTagName(t||"*"):e.querySelectorAll?e.querySelectorAll(t||"*"):[];return t===undefined||t&&x.nodeName(e,t)?x.merge([e],n):n}function yt(e,t){var n=t.nodeName.toLowerCase();"input"===n&&ot.test(e.type)?t.checked=e.checked:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}x.fn.extend({wrapAll:function(e){var t;return x.isFunction(e)?this.each(function(t){x(this).wrapAll(e.call(this,t))}):(this[0]&&(t=x(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstElementChild)e=e.firstElementChild;return e}).append(this)),this)},wrapInner:function(e){return x.isFunction(e)?this.each(function(t){x(this).wrapInner(e.call(this,t))}):this.each(function(){var t=x(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=x.isFunction(e);return this.each(function(n){x(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){x.nodeName(this,"body")||x(this).replaceWith(this.childNodes)}).end()}});var vt,xt,bt=/^(none|table(?!-c[ea]).+)/,wt=/^margin/,Tt=RegExp("^("+b+")(.*)$","i"),Ct=RegExp("^("+b+")(?!px)[a-z%]+$","i"),kt=RegExp("^([+-])=("+b+")","i"),Nt={BODY:"block"},Et={position:"absolute",visibility:"hidden",display:"block"},St={letterSpacing:0,fontWeight:400},jt=["Top","Right","Bottom","Left"],Dt=["Webkit","O","Moz","ms"];function At(e,t){if(t in e)return t;var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=Dt.length;while(i--)if(t=Dt[i]+n,t in e)return t;return r}function Lt(e,t){return e=t||e,"none"===x.css(e,"display")||!x.contains(e.ownerDocument,e)}function Ht(t){return e.getComputedStyle(t,null)}function qt(e,t){var n,r,i,o=[],s=0,a=e.length;for(;a>s;s++)r=e[s],r.style&&(o[s]=H.get(r,"olddisplay"),n=r.style.display,t?(o[s]||"none"!==n||(r.style.display=""),""===r.style.display&&Lt(r)&&(o[s]=H.access(r,"olddisplay",Rt(r.nodeName)))):o[s]||(i=Lt(r),(n&&"none"!==n||!i)&&H.set(r,"olddisplay",i?n:x.css(r,"display"))));for(s=0;a>s;s++)r=e[s],r.style&&(t&&"none"!==r.style.display&&""!==r.style.display||(r.style.display=t?o[s]||"":"none"));return e}x.fn.extend({css:function(e,t){return x.access(this,function(e,t,n){var r,i,o={},s=0;if(x.isArray(t)){for(r=Ht(e),i=t.length;i>s;s++)o[t[s]]=x.css(e,t[s],!1,r);return o}return n!==undefined?x.style(e,t,n):x.css(e,t)},e,t,arguments.length>1)},show:function(){return qt(this,!0)},hide:function(){return qt(this)},toggle:function(e){var t="boolean"==typeof e;return this.each(function(){(t?e:Lt(this))?x(this).show():x(this).hide()})}}),x.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=vt(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":"cssFloat"},style:function(e,t,n,r){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var i,o,s,a=x.camelCase(t),u=e.style;return t=x.cssProps[a]||(x.cssProps[a]=At(u,a)),s=x.cssHooks[t]||x.cssHooks[a],n===undefined?s&&"get"in s&&(i=s.get(e,!1,r))!==undefined?i:u[t]:(o=typeof n,"string"===o&&(i=kt.exec(n))&&(n=(i[1]+1)*i[2]+parseFloat(x.css(e,t)),o="number"),null==n||"number"===o&&isNaN(n)||("number"!==o||x.cssNumber[a]||(n+="px"),x.support.clearCloneStyle||""!==n||0!==t.indexOf("background")||(u[t]="inherit"),s&&"set"in s&&(n=s.set(e,n,r))===undefined||(u[t]=n)),undefined)}},css:function(e,t,n,r){var i,o,s,a=x.camelCase(t);return t=x.cssProps[a]||(x.cssProps[a]=At(e.style,a)),s=x.cssHooks[t]||x.cssHooks[a],s&&"get"in s&&(i=s.get(e,!0,n)),i===undefined&&(i=vt(e,t,r)),"normal"===i&&t in St&&(i=St[t]),""===n||n?(o=parseFloat(i),n===!0||x.isNumeric(o)?o||0:i):i}}),vt=function(e,t,n){var r,i,o,s=n||Ht(e),a=s?s.getPropertyValue(t)||s[t]:undefined,u=e.style;return s&&(""!==a||x.contains(e.ownerDocument,e)||(a=x.style(e,t)),Ct.test(a)&&wt.test(t)&&(r=u.width,i=u.minWidth,o=u.maxWidth,u.minWidth=u.maxWidth=u.width=a,a=s.width,u.width=r,u.minWidth=i,u.maxWidth=o)),a};function Ot(e,t,n){var r=Tt.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function Ft(e,t,n,r,i){var o=n===(r?"border":"content")?4:"width"===t?1:0,s=0;for(;4>o;o+=2)"margin"===n&&(s+=x.css(e,n+jt[o],!0,i)),r?("content"===n&&(s-=x.css(e,"padding"+jt[o],!0,i)),"margin"!==n&&(s-=x.css(e,"border"+jt[o]+"Width",!0,i))):(s+=x.css(e,"padding"+jt[o],!0,i),"padding"!==n&&(s+=x.css(e,"border"+jt[o]+"Width",!0,i)));return s}function Pt(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=Ht(e),s=x.support.boxSizing&&"border-box"===x.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=vt(e,t,o),(0>i||null==i)&&(i=e.style[t]),Ct.test(i))return i;r=s&&(x.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+Ft(e,t,n||(s?"border":"content"),r,o)+"px"}function Rt(e){var t=o,n=Nt[e];return n||(n=Mt(e,t),"none"!==n&&n||(xt=(xt||x("