diff --git a/.gitignore b/.gitignore index 4b2eb5453a..101c1e6224 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,31 @@ __pycache__/ *.py[cod] *$py.class +# Mac Stuff +########### +# General +.DS_Store +.AppleDouble +.LSOverride +# Icon must end with two \r +Icon +# Thumbnails +._* +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + # Documentation ############### /docs/build diff --git a/pype/api.py b/pype/api.py index 5775bb3ce4..44a31f2626 100644 --- a/pype/api.py +++ b/pype/api.py @@ -12,6 +12,8 @@ from pypeapp.lib.mongo import ( get_default_components ) +from . import resources + from .plugin import ( Extractor, @@ -54,6 +56,8 @@ __all__ = [ "compose_url", "get_default_components", + # Resources + "resources", # plugin classes "Extractor", # ordering diff --git a/pype/hooks/celaction/prelaunch.py b/pype/hooks/celaction/prelaunch.py index df9da6cbbf..e1e86cc919 100644 --- a/pype/hooks/celaction/prelaunch.py +++ b/pype/hooks/celaction/prelaunch.py @@ -106,7 +106,7 @@ class CelactionPrelaunchHook(PypeHook): f"--project {project}", f"--asset {asset}", f"--task {task}", - "--currentFile \"*SCENE*\"", + "--currentFile \\\"\"*SCENE*\"\\\"", "--chunk *CHUNK*", "--frameStart *START*", "--frameEnd *END*", diff --git a/pype/hooks/premiere/prelaunch.py b/pype/hooks/premiere/prelaunch.py index 118493e9a7..c0a65c0bf2 100644 --- a/pype/hooks/premiere/prelaunch.py +++ b/pype/hooks/premiere/prelaunch.py @@ -1,5 +1,6 @@ import os import traceback +import winreg from avalon import api, io, lib from pype.lib import PypeHook from pype.api import Logger, Anatomy @@ -14,6 +15,12 @@ class PremierePrelaunch(PypeHook): shell script. """ project_code = None + reg_string_value = [{ + "path": r"Software\Adobe\CSXS.9", + "name": "PlayerDebugMode", + "type": winreg.REG_SZ, + "value": "1" + }] def __init__(self, logger=None): if not logger: @@ -55,6 +62,10 @@ class PremierePrelaunch(PypeHook): # adding project code to env env["AVALON_PROJECT_CODE"] = self.project_code + # add keys to registry + self.modify_registry() + + # start avalon try: __import__("pype.hosts.premiere") __import__("pyblish") @@ -69,6 +80,24 @@ class PremierePrelaunch(PypeHook): return True + def modify_registry(self): + # adding key to registry + for key in self.reg_string_value: + winreg.CreateKey(winreg.HKEY_CURRENT_USER, key["path"]) + rg_key = winreg.OpenKey( + key=winreg.HKEY_CURRENT_USER, + sub_key=key["path"], + reserved=0, + access=winreg.KEY_ALL_ACCESS) + + winreg.SetValueEx( + rg_key, + key["name"], + 0, + key["type"], + key["value"] + ) + def get_anatomy_filled(self): root_path = api.registered_root() project_name = self._S["AVALON_PROJECT"] diff --git a/pype/hosts/blender/__init__.py b/pype/hosts/blender/__init__.py index a6d3cd82ef..dafeca5107 100644 --- a/pype/hosts/blender/__init__.py +++ b/pype/hosts/blender/__init__.py @@ -5,6 +5,8 @@ import traceback from avalon import api as avalon from pyblish import api as pyblish +import bpy + from pype import PLUGINS_DIR PUBLISH_PATH = os.path.join(PLUGINS_DIR, "blender", "publish") @@ -25,6 +27,9 @@ def install(): avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH)) avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH)) + avalon.on("new", on_new) + avalon.on("open", on_open) + def uninstall(): """Uninstall Blender configuration for Avalon.""" @@ -32,3 +37,24 @@ def uninstall(): pyblish.deregister_plugin_path(str(PUBLISH_PATH)) avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH)) avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH)) + + +def set_start_end_frames(): + from avalon import io + + asset_name = io.Session["AVALON_ASSET"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) + + bpy.context.scene.frame_start = asset_doc["data"]["frameStart"] + bpy.context.scene.frame_end = asset_doc["data"]["frameEnd"] + + +def on_new(arg1, arg2): + set_start_end_frames() + + +def on_open(arg1, arg2): + set_start_end_frames() diff --git a/pype/hosts/blender/plugin.py b/pype/hosts/blender/plugin.py index 77fce90d65..07080a86c4 100644 --- a/pype/hosts/blender/plugin.py +++ b/pype/hosts/blender/plugin.py @@ -14,12 +14,42 @@ def asset_name( asset: str, subset: str, namespace: Optional[str] = None ) -> str: """Return a consistent name for an asset.""" - name = f"{asset}_{subset}" + name = f"{asset}" if namespace: - name = f"{namespace}:{name}" + name = f"{name}_{namespace}" + name = f"{name}_{subset}" return name +def get_unique_number( + asset: str, subset: str +) -> str: + """Return a unique number based on the asset name.""" + avalon_containers = [ + c for c in bpy.data.collections + if c.name == 'AVALON_CONTAINERS' + ] + loaded_assets = [] + for c in avalon_containers: + loaded_assets.extend(c.children) + collections_names = [ + c.name for c in loaded_assets + ] + count = 1 + name = f"{asset}_{count:0>2}_{subset}_CON" + while name in collections_names: + count += 1 + name = f"{asset}_{count:0>2}_{subset}_CON" + return f"{count:0>2}" + + +def prepare_data(data, container_name): + name = data.name + local_data = data.make_local() + local_data.name = f"{name}:{container_name}" + return local_data + + def create_blender_context(active: Optional[bpy.types.Object] = None, selected: Optional[bpy.types.Object] = None,): """Create a new Blender context. If an object is passed as @@ -47,6 +77,25 @@ def create_blender_context(active: Optional[bpy.types.Object] = None, raise Exception("Could not create a custom Blender context.") +def get_parent_collection(collection): + """Get the parent of the input collection""" + check_list = [bpy.context.scene.collection] + + for c in check_list: + if collection.name in c.children.keys(): + return c + check_list.extend(c.children) + + return None + + +def get_local_collection_with_name(name): + for collection in bpy.data.collections: + if collection.name == name and collection.library is None: + return collection + return None + + class AssetLoader(api.Loader): """A basic AssetLoader for Blender diff --git a/pype/hosts/celaction/cli.py b/pype/hosts/celaction/cli.py index 8cf2bcc791..42f7a1a385 100644 --- a/pype/hosts/celaction/cli.py +++ b/pype/hosts/celaction/cli.py @@ -46,9 +46,6 @@ def cli(): parser.add_argument("--resolutionHeight", help=("Height of resolution")) - # parser.add_argument("--programDir", - # help=("Directory with celaction program installation")) - celaction.kwargs = parser.parse_args(sys.argv[1:]).__dict__ @@ -78,7 +75,7 @@ def _prepare_publish_environments(): env["AVALON_WORKDIR"] = os.getenv("AVALON_WORKDIR") env["AVALON_HIERARCHY"] = hierarchy env["AVALON_PROJECTCODE"] = project_doc["data"].get("code", "") - env["AVALON_APP"] = publish_host + env["AVALON_APP"] = f"hosts.{publish_host}" env["AVALON_APP_NAME"] = "celaction_local" env["PYBLISH_HOSTS"] = publish_host diff --git a/pype/hosts/harmony/__init__.py b/pype/hosts/harmony/__init__.py index 3d49c60563..3cae695852 100644 --- a/pype/hosts/harmony/__init__.py +++ b/pype/hosts/harmony/__init__.py @@ -1,8 +1,9 @@ import os import sys -from avalon import api, harmony +from avalon import api, io, harmony from avalon.vendor import Qt +import avalon.tools.sceneinventory import pyblish.api from pype import lib @@ -92,6 +93,61 @@ def ensure_scene_settings(): set_scene_settings(valid_settings) +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) + + # Colour nodes. + func = """function func(args){ + for( var i =0; i <= args[0].length - 1; ++i) + { + var red_color = new ColorRGBA(255, 0, 0, 255); + node.setColor(args[0][i], red_color); + } + } + func + """ + outdated_nodes = [] + for container in outdated_containers: + if container["loader"] == "ImageSequenceLoader": + outdated_nodes.append( + harmony.find_node_by_name(container["name"], "READ") + ) + harmony.send({"function": func, "args": [outdated_nodes]}) + + # Warn about outdated containers. + print("Starting new QApplication..") + app = Qt.QtWidgets.QApplication(sys.argv) + + message_box = Qt.QtWidgets.QMessageBox() + message_box.setIcon(Qt.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(): + ensure_scene_settings() + check_inventory() + + def export_template(backdrops, nodes, filepath): func = """function func(args) { @@ -161,7 +217,7 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) - api.on("application.launched", ensure_scene_settings) + api.on("application.launched", application_launch) def on_pyblish_instance_toggled(instance, old_value, new_value): diff --git a/pype/hosts/maya/customize.py b/pype/hosts/maya/customize.py index 8bd7052d9e..ee3ad4f239 100644 --- a/pype/hosts/maya/customize.py +++ b/pype/hosts/maya/customize.py @@ -69,17 +69,38 @@ def override_component_mask_commands(): def override_toolbox_ui(): """Add custom buttons in Toolbox as replacement for Maya web help icon.""" + inventory = None + loader = None + launch_workfiles_app = None + mayalookassigner = None + try: + import avalon.tools.sceneinventory as inventory + except Exception: + log.warning("Could not import SceneInventory tool") - import pype - res = os.path.join(os.path.dirname(os.path.dirname(pype.__file__)), - "res") - icons = os.path.join(res, "icons") + try: + import avalon.tools.loader as loader + except Exception: + log.warning("Could not import Loader tool") - import avalon.tools.sceneinventory as inventory - import avalon.tools.loader as loader - from avalon.maya.pipeline import launch_workfiles_app - import mayalookassigner + try: + from avalon.maya.pipeline import launch_workfiles_app + except Exception: + log.warning("Could not import Workfiles tool") + try: + import mayalookassigner + except Exception: + log.warning("Could not import Maya Look assigner tool") + + from pype.api import resources + + icons = resources.get_resource("icons") + + if not any(( + mayalookassigner, launch_workfiles_app, loader, inventory + )): + return # Ensure the maya web icon on toolbox exists web_button = "ToolBox|MainToolboxLayout|mayaWebButton" @@ -99,65 +120,65 @@ def override_toolbox_ui(): # Create our controls background_color = (0.267, 0.267, 0.267) controls = [] + if mayalookassigner: + controls.append( + mc.iconTextButton( + "pype_toolbox_lookmanager", + annotation="Look Manager", + label="Look Manager", + image=os.path.join(icons, "lookmanager.png"), + command=lambda: mayalookassigner.show(), + bgc=background_color, + width=icon_size, + height=icon_size, + parent=parent + ) + ) - control = mc.iconTextButton( - "pype_toolbox_lookmanager", - annotation="Look Manager", - label="Look Manager", - image=os.path.join(icons, "lookmanager.png"), - command=lambda: mayalookassigner.show(), - bgc=background_color, - width=icon_size, - height=icon_size, - parent=parent) - controls.append(control) + if launch_workfiles_app: + controls.append( + mc.iconTextButton( + "pype_toolbox_workfiles", + annotation="Work Files", + label="Work Files", + image=os.path.join(icons, "workfiles.png"), + command=lambda: launch_workfiles_app(), + bgc=background_color, + width=icon_size, + height=icon_size, + parent=parent + ) + ) - control = mc.iconTextButton( - "pype_toolbox_workfiles", - annotation="Work Files", - label="Work Files", - image=os.path.join(icons, "workfiles.png"), - command=lambda: launch_workfiles_app(), - bgc=background_color, - width=icon_size, - height=icon_size, - parent=parent) - controls.append(control) + if loader: + controls.append( + mc.iconTextButton( + "pype_toolbox_loader", + annotation="Loader", + label="Loader", + image=os.path.join(icons, "loader.png"), + command=lambda: loader.show(use_context=True), + bgc=background_color, + width=icon_size, + height=icon_size, + parent=parent + ) + ) - control = mc.iconTextButton( - "pype_toolbox_loader", - annotation="Loader", - label="Loader", - image=os.path.join(icons, "loader.png"), - command=lambda: loader.show(use_context=True), - bgc=background_color, - width=icon_size, - height=icon_size, - parent=parent) - controls.append(control) - - control = mc.iconTextButton( - "pype_toolbox_manager", - annotation="Inventory", - label="Inventory", - image=os.path.join(icons, "inventory.png"), - command=lambda: inventory.show(), - bgc=background_color, - width=icon_size, - height=icon_size, - parent=parent) - controls.append(control) - - # control = mc.iconTextButton( - # "pype_toolbox", - # annotation="Kredenc", - # label="Kredenc", - # image=os.path.join(icons, "kredenc_logo.png"), - # bgc=background_color, - # width=icon_size, - # height=icon_size, - # parent=parent) - # controls.append(control) + if inventory: + controls.append( + mc.iconTextButton( + "pype_toolbox_manager", + annotation="Inventory", + label="Inventory", + image=os.path.join(icons, "inventory.png"), + command=lambda: inventory.show(), + bgc=background_color, + width=icon_size, + height=icon_size, + parent=parent + ) + ) # Add the buttons on the bottom and stack # them above each other with side padding diff --git a/pype/hosts/nuke/utils.py b/pype/hosts/nuke/utils.py index aa5bc1077e..72c7b7bc14 100644 --- a/pype/hosts/nuke/utils.py +++ b/pype/hosts/nuke/utils.py @@ -1,6 +1,7 @@ import os import nuke from avalon.nuke import lib as anlib +from pype.api import resources def set_context_favorites(favorites={}): @@ -9,9 +10,7 @@ def set_context_favorites(favorites={}): Argumets: favorites (dict): couples of {name:path} """ - dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - icon_path = os.path.join(dir, 'res', 'icons', 'folder-favorite3.png') - + icon_path = resources.get_resource("icons", "folder-favorite3.png") for name, path in favorites.items(): nuke.addFavoriteDir( name, diff --git a/pype/hosts/nukestudio/workio.py b/pype/hosts/nukestudio/workio.py index eee6654a4c..2cf898aa33 100644 --- a/pype/hosts/nukestudio/workio.py +++ b/pype/hosts/nukestudio/workio.py @@ -6,8 +6,9 @@ from pype.api import Logger log = Logger().get_logger(__name__, "nukestudio") + def file_extensions(): - return [".hrox"] + return api.HOST_WORKFILE_EXTENSIONS["nukestudio"] def has_unsaved_changes(): diff --git a/pype/hosts/photoshop/__init__.py b/pype/hosts/photoshop/__init__.py index 01ed757a8d..564e9c8a05 100644 --- a/pype/hosts/photoshop/__init__.py +++ b/pype/hosts/photoshop/__init__.py @@ -1,9 +1,48 @@ import os +import sys -from avalon import api +from avalon import api, io +from avalon.vendor import Qt +from pype import lib import pyblish.api +def check_inventory(): + if not lib.any_outdated(): + return + + host = 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..") + app = Qt.QtWidgets.QApplication(sys.argv) + + message_box = Qt.QtWidgets.QMessageBox() + message_box.setIcon(Qt.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...") @@ -27,6 +66,8 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) + api.on("application.launched", application_launch) + def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle layer visibility on instance toggles.""" diff --git a/pype/hosts/premiere/extensions/com.pype/jsx/pype.jsx b/pype/hosts/premiere/extensions/com.pype/jsx/pype.jsx index 684cef5e5a..3cd4502653 100644 --- a/pype/hosts/premiere/extensions/com.pype/jsx/pype.jsx +++ b/pype/hosts/premiere/extensions/com.pype/jsx/pype.jsx @@ -534,7 +534,9 @@ $.pype = { if (instances === null) { return null; } - if (audioOnly === true) { + + // make only audio representations + if (audioOnly === 'true') { $.pype.log('? looping if audio True'); for (var i = 0; i < instances.length; i++) { var subsetToRepresentations = instances[i].subsetToRepresentations; diff --git a/pype/modules/clockify/__init__.py b/pype/modules/clockify/__init__.py index aab0d048de..8e11d2f5f4 100644 --- a/pype/modules/clockify/__init__.py +++ b/pype/modules/clockify/__init__.py @@ -1,14 +1,7 @@ -from .clockify_api import ClockifyAPI -from .widget_settings import ClockifySettings -from .widget_message import MessageWidget from .clockify import ClockifyModule -__all__ = [ - "ClockifyAPI", - "ClockifySettings", - "ClockifyModule", - "MessageWidget" -] - +CLASS_DEFINIION = ClockifyModule + + def tray_init(tray_widget, main_widget): return ClockifyModule(main_widget, tray_widget) diff --git a/pype/modules/clockify/clockify.py b/pype/modules/clockify/clockify.py index 2ab22702c1..fea15a1bea 100644 --- a/pype/modules/clockify/clockify.py +++ b/pype/modules/clockify/clockify.py @@ -3,17 +3,25 @@ import threading from pype.api import Logger from avalon import style from Qt import QtWidgets -from . import ClockifySettings, ClockifyAPI, MessageWidget +from .widgets import ClockifySettings, MessageWidget +from .clockify_api import ClockifyAPI +from .constants import CLOCKIFY_FTRACK_USER_PATH class ClockifyModule: + workspace_name = None def __init__(self, main_parent=None, parent=None): + if not self.workspace_name: + raise Exception("Clockify Workspace is not set in config.") + + os.environ["CLOCKIFY_WORKSPACE"] = self.workspace_name + self.log = Logger().get_logger(self.__class__.__name__, "PypeTray") self.main_parent = main_parent self.parent = parent - self.clockapi = ClockifyAPI() + self.clockapi = ClockifyAPI(master_parent=self) self.message_widget = None self.widget_settings = ClockifySettings(main_parent, self) self.widget_settings_required = None @@ -24,8 +32,6 @@ class ClockifyModule: self.bool_api_key_set = False self.bool_workspace_set = False self.bool_timer_run = False - - self.clockapi.set_master(self) self.bool_api_key_set = self.clockapi.set_api() def tray_start(self): @@ -43,14 +49,12 @@ class ClockifyModule: def process_modules(self, modules): if 'FtrackModule' in modules: - actions_path = os.path.sep.join([ - os.path.dirname(__file__), - 'ftrack_actions' - ]) current = os.environ.get('FTRACK_ACTIONS_PATH', '') if current: current += os.pathsep - os.environ['FTRACK_ACTIONS_PATH'] = current + actions_path + os.environ['FTRACK_ACTIONS_PATH'] = ( + current + CLOCKIFY_FTRACK_USER_PATH + ) if 'AvalonApps' in modules: from launcher import lib @@ -188,9 +192,10 @@ class ClockifyModule: ).format(project_name)) msg = ( - "Project \"{}\" is not in Clockify Workspace \"{}\"." + "Project \"{}\" is not" + " in Clockify Workspace \"{}\"." "

Please inform your Project Manager." - ).format(project_name, str(self.clockapi.workspace)) + ).format(project_name, str(self.clockapi.workspace_name)) self.message_widget = MessageWidget( self.main_parent, msg, "Clockify - Info Message" diff --git a/pype/modules/clockify/clockify_api.py b/pype/modules/clockify/clockify_api.py index f012efc002..d88b2ef8df 100644 --- a/pype/modules/clockify/clockify_api.py +++ b/pype/modules/clockify/clockify_api.py @@ -1,35 +1,39 @@ import os import re +import time import requests import json import datetime -import appdirs +from .constants import ( + CLOCKIFY_ENDPOINT, ADMIN_PERMISSION_NAMES, CREDENTIALS_JSON_PATH +) -class Singleton(type): - _instances = {} +def time_check(obj): + if obj.request_counter < 10: + obj.request_counter += 1 + return - def __call__(cls, *args, **kwargs): - if cls not in cls._instances: - cls._instances[cls] = super( - Singleton, cls - ).__call__(*args, **kwargs) - return cls._instances[cls] + wait_time = 1 - (time.time() - obj.request_time) + if wait_time > 0: + time.sleep(wait_time) + + obj.request_time = time.time() + obj.request_counter = 0 -class ClockifyAPI(metaclass=Singleton): - endpoint = "https://api.clockify.me/api/" - headers = {"X-Api-Key": None} - app_dir = os.path.normpath(appdirs.user_data_dir('pype-app', 'pype')) - file_name = 'clockify.json' - fpath = os.path.join(app_dir, file_name) - admin_permission_names = ['WORKSPACE_OWN', 'WORKSPACE_ADMIN'] - master_parent = None - workspace = None - workspace_id = None - - def set_master(self, master_parent): +class ClockifyAPI: + def __init__(self, api_key=None, master_parent=None): + self.workspace_name = None + self.workspace_id = None self.master_parent = master_parent + self.api_key = api_key + self.request_counter = 0 + self.request_time = time.time() + + @property + def headers(self): + return {"X-Api-Key": self.api_key} def verify_api(self): for key, value in self.headers.items(): @@ -42,7 +46,7 @@ class ClockifyAPI(metaclass=Singleton): api_key = self.get_api_key() if api_key is not None and self.validate_api_key(api_key) is True: - self.headers["X-Api-Key"] = api_key + self.api_key = api_key self.set_workspace() if self.master_parent: self.master_parent.signed_in() @@ -52,8 +56,9 @@ class ClockifyAPI(metaclass=Singleton): def validate_api_key(self, api_key): test_headers = {'X-Api-Key': api_key} action_url = 'workspaces/' + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=test_headers ) if response.status_code != 200: @@ -69,25 +74,27 @@ class ClockifyAPI(metaclass=Singleton): action_url = "/workspaces/{}/users/{}/permissions".format( workspace_id, user_id ) + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) user_permissions = response.json() for perm in user_permissions: - if perm['name'] in self.admin_permission_names: + if perm['name'] in ADMIN_PERMISSION_NAMES: return True return False def get_user_id(self): action_url = 'v1/user/' + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) # this regex is neccessary: UNICODE strings are crashing # during json serialization - id_regex ='\"{1}id\"{1}\:{1}\"{1}\w+\"{1}' + id_regex = '\"{1}id\"{1}\:{1}\"{1}\w+\"{1}' result = re.findall(id_regex, str(response.content)) if len(result) != 1: # replace with log and better message? @@ -98,9 +105,9 @@ class ClockifyAPI(metaclass=Singleton): def set_workspace(self, name=None): if name is None: name = os.environ.get('CLOCKIFY_WORKSPACE', None) - self.workspace = name + self.workspace_name = name self.workspace_id = None - if self.workspace is None: + if self.workspace_name is None: return try: result = self.validate_workspace() @@ -115,7 +122,7 @@ class ClockifyAPI(metaclass=Singleton): def validate_workspace(self, name=None): if name is None: - name = self.workspace + name = self.workspace_name all_workspaces = self.get_workspaces() if name in all_workspaces: return all_workspaces[name] @@ -124,25 +131,26 @@ class ClockifyAPI(metaclass=Singleton): def get_api_key(self): api_key = None try: - file = open(self.fpath, 'r') + file = open(CREDENTIALS_JSON_PATH, 'r') api_key = json.load(file).get('api_key', None) if api_key == '': api_key = None except Exception: - file = open(self.fpath, 'w') + file = open(CREDENTIALS_JSON_PATH, 'w') file.close() return api_key def save_api_key(self, api_key): data = {'api_key': api_key} - file = open(self.fpath, 'w') + file = open(CREDENTIALS_JSON_PATH, 'w') file.write(json.dumps(data)) file.close() def get_workspaces(self): action_url = 'workspaces/' + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return { @@ -153,8 +161,9 @@ class ClockifyAPI(metaclass=Singleton): if workspace_id is None: workspace_id = self.workspace_id action_url = 'workspaces/{}/projects/'.format(workspace_id) + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) @@ -168,8 +177,9 @@ class ClockifyAPI(metaclass=Singleton): action_url = 'workspaces/{}/projects/{}/'.format( workspace_id, project_id ) + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) @@ -179,8 +189,9 @@ class ClockifyAPI(metaclass=Singleton): if workspace_id is None: workspace_id = self.workspace_id action_url = 'workspaces/{}/tags/'.format(workspace_id) + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) @@ -194,8 +205,9 @@ class ClockifyAPI(metaclass=Singleton): action_url = 'workspaces/{}/projects/{}/tasks/'.format( workspace_id, project_id ) + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) @@ -276,8 +288,9 @@ class ClockifyAPI(metaclass=Singleton): "taskId": task_id, "tagIds": tag_ids } + time_check(self) response = requests.post( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) @@ -293,8 +306,9 @@ class ClockifyAPI(metaclass=Singleton): action_url = 'workspaces/{}/timeEntries/inProgress'.format( workspace_id ) + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) try: @@ -323,8 +337,9 @@ class ClockifyAPI(metaclass=Singleton): "tagIds": current["tagIds"], "end": self.get_current_time() } + time_check(self) response = requests.put( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) @@ -336,8 +351,9 @@ class ClockifyAPI(metaclass=Singleton): if workspace_id is None: workspace_id = self.workspace_id action_url = 'workspaces/{}/timeEntries/'.format(workspace_id) + time_check(self) response = requests.get( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return response.json()[:quantity] @@ -348,8 +364,9 @@ class ClockifyAPI(metaclass=Singleton): action_url = 'workspaces/{}/timeEntries/{}'.format( workspace_id, tid ) + time_check(self) response = requests.delete( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers ) return response.json() @@ -363,14 +380,15 @@ class ClockifyAPI(metaclass=Singleton): "clientId": "", "isPublic": "false", "estimate": { - # "estimate": "3600", + "estimate": 0, "type": "AUTO" }, "color": "#f44336", "billable": "true" } + time_check(self) response = requests.post( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) @@ -379,8 +397,9 @@ class ClockifyAPI(metaclass=Singleton): def add_workspace(self, name): action_url = 'workspaces/' body = {"name": name} + time_check(self) response = requests.post( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) @@ -398,8 +417,9 @@ class ClockifyAPI(metaclass=Singleton): "name": name, "projectId": project_id } + time_check(self) response = requests.post( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) @@ -412,8 +432,9 @@ class ClockifyAPI(metaclass=Singleton): body = { "name": name } + time_check(self) response = requests.post( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, json=body ) @@ -427,8 +448,9 @@ class ClockifyAPI(metaclass=Singleton): action_url = '/workspaces/{}/projects/{}'.format( workspace_id, project_id ) + time_check(self) response = requests.delete( - self.endpoint + action_url, + CLOCKIFY_ENDPOINT + action_url, headers=self.headers, ) return response.json() diff --git a/pype/modules/clockify/constants.py b/pype/modules/clockify/constants.py new file mode 100644 index 0000000000..38ad4b64cf --- /dev/null +++ b/pype/modules/clockify/constants.py @@ -0,0 +1,17 @@ +import os +import appdirs + + +CLOCKIFY_FTRACK_SERVER_PATH = os.path.join( + os.path.dirname(__file__), "ftrack", "server" +) +CLOCKIFY_FTRACK_USER_PATH = os.path.join( + os.path.dirname(__file__), "ftrack", "user" +) +CREDENTIALS_JSON_PATH = os.path.normpath(os.path.join( + appdirs.user_data_dir("pype-app", "pype"), + "clockify.json" +)) + +ADMIN_PERMISSION_NAMES = ["WORKSPACE_OWN", "WORKSPACE_ADMIN"] +CLOCKIFY_ENDPOINT = "https://api.clockify.me/api/" diff --git a/pype/modules/clockify/ftrack/server/action_clockify_sync_server.py b/pype/modules/clockify/ftrack/server/action_clockify_sync_server.py new file mode 100644 index 0000000000..ae911f6258 --- /dev/null +++ b/pype/modules/clockify/ftrack/server/action_clockify_sync_server.py @@ -0,0 +1,166 @@ +import os +import json +from pype.modules.ftrack.lib import BaseAction +from pype.modules.clockify.clockify_api import ClockifyAPI + + +class SyncClocifyServer(BaseAction): + '''Synchronise project names and task types.''' + + identifier = "clockify.sync.server" + label = "Sync To Clockify (server)" + description = "Synchronise data to Clockify workspace" + + discover_role_list = ["Pypeclub", "Administrator", "project Manager"] + + def __init__(self, *args, **kwargs): + super(SyncClocifyServer, self).__init__(*args, **kwargs) + + workspace_name = os.environ.get("CLOCKIFY_WORKSPACE") + api_key = os.environ.get("CLOCKIFY_API_KEY") + self.clockapi = ClockifyAPI(api_key) + self.clockapi.set_workspace(workspace_name) + if api_key is None: + modified_key = "None" + else: + str_len = int(len(api_key) / 2) + start_replace = int(len(api_key) / 4) + modified_key = "" + for idx in range(len(api_key)): + if idx >= start_replace and idx < start_replace + str_len: + replacement = "X" + else: + replacement = api_key[idx] + modified_key += replacement + + self.log.info( + "Clockify info. Workspace: \"{}\" API key: \"{}\"".format( + str(workspace_name), str(modified_key) + ) + ) + + def discover(self, session, entities, event): + if ( + len(entities) != 1 + or entities[0].entity_type.lower() != "project" + ): + return False + + # Get user and check his roles + user_id = event.get("source", {}).get("user", {}).get("id") + if not user_id: + return False + + user = session.query("User where id is \"{}\"".format(user_id)).first() + if not user: + return False + + for role in user["user_security_roles"]: + if role["security_role"]["name"] in self.discover_role_list: + return True + return False + + def register(self): + self.session.event_hub.subscribe( + "topic=ftrack.action.discover", + self._discover, + priority=self.priority + ) + + launch_subscription = ( + "topic=ftrack.action.launch and data.actionIdentifier={}" + ).format(self.identifier) + self.session.event_hub.subscribe(launch_subscription, self._launch) + + def launch(self, session, entities, event): + if self.clockapi.workspace_id is None: + return { + "success": False, + "message": "Clockify Workspace or API key are not set!" + } + + if self.clockapi.validate_workspace_perm() is False: + return { + "success": False, + "message": "Missing permissions for this action!" + } + + # JOB SETTINGS + user_id = event["source"]["user"]["id"] + user = session.query("User where id is " + user_id).one() + + job = session.create("Job", { + "user": user, + "status": "running", + "data": json.dumps({"description": "Sync Ftrack to Clockify"}) + }) + session.commit() + + project_entity = entities[0] + if project_entity.entity_type.lower() != "project": + project_entity = self.get_project_from_entity(project_entity) + + project_name = project_entity["full_name"] + self.log.info( + "Synchronization of project \"{}\" to clockify begins.".format( + project_name + ) + ) + task_types = ( + project_entity["project_schema"]["_task_type_schema"]["types"] + ) + task_type_names = [ + task_type["name"] for task_type in task_types + ] + try: + clockify_projects = self.clockapi.get_projects() + if project_name not in clockify_projects: + response = self.clockapi.add_project(project_name) + if "id" not in response: + self.log.warning( + "Project \"{}\" can't be created. Response: {}".format( + project_name, response + ) + ) + return { + "success": False, + "message": ( + "Can't create clockify project \"{}\"." + " Unexpected error." + ).format(project_name) + } + + clockify_workspace_tags = self.clockapi.get_tags() + for task_type_name in task_type_names: + if task_type_name in clockify_workspace_tags: + self.log.debug( + "Task \"{}\" already exist".format(task_type_name) + ) + continue + + response = self.clockapi.add_tag(task_type_name) + if "id" not in response: + self.log.warning( + "Task \"{}\" can't be created. Response: {}".format( + task_type_name, response + ) + ) + + job["status"] = "done" + + except Exception: + self.log.warning( + "Synchronization to clockify failed.", + exc_info=True + ) + + finally: + if job["status"] != "done": + job["status"] = "failed" + session.commit() + + return True + + +def register(session, **kw): + SyncClocifyServer(session).register() diff --git a/pype/modules/clockify/ftrack/user/action_clockify_sync_local.py b/pype/modules/clockify/ftrack/user/action_clockify_sync_local.py new file mode 100644 index 0000000000..e74bf3dbb3 --- /dev/null +++ b/pype/modules/clockify/ftrack/user/action_clockify_sync_local.py @@ -0,0 +1,122 @@ +import json +from pype.modules.ftrack.lib import BaseAction, statics_icon +from pype.modules.clockify.clockify_api import ClockifyAPI + + +class SyncClocifyLocal(BaseAction): + '''Synchronise project names and task types.''' + + #: Action identifier. + identifier = 'clockify.sync.local' + #: Action label. + label = 'Sync To Clockify (local)' + #: Action description. + description = 'Synchronise data to Clockify workspace' + #: roles that are allowed to register this action + role_list = ["Pypeclub", "Administrator", "project Manager"] + #: icon + icon = statics_icon("app_icons", "clockify-white.png") + + #: CLockifyApi + clockapi = ClockifyAPI() + + def discover(self, session, entities, event): + if ( + len(entities) == 1 + and entities[0].entity_type.lower() == "project" + ): + return True + return False + + def launch(self, session, entities, event): + self.clockapi.set_api() + if self.clockapi.workspace_id is None: + return { + "success": False, + "message": "Clockify Workspace or API key are not set!" + } + + if self.clockapi.validate_workspace_perm() is False: + return { + "success": False, + "message": "Missing permissions for this action!" + } + + # JOB SETTINGS + userId = event['source']['user']['id'] + user = session.query('User where id is ' + userId).one() + + job = session.create('Job', { + 'user': user, + 'status': 'running', + 'data': json.dumps({ + 'description': 'Sync Ftrack to Clockify' + }) + }) + session.commit() + + project_entity = entities[0] + if project_entity.entity_type.lower() != "project": + project_entity = self.get_project_from_entity(project_entity) + + project_name = project_entity["full_name"] + self.log.info( + "Synchronization of project \"{}\" to clockify begins.".format( + project_name + ) + ) + task_types = ( + project_entity["project_schema"]["_task_type_schema"]["types"] + ) + task_type_names = [ + task_type["name"] for task_type in task_types + ] + try: + clockify_projects = self.clockapi.get_projects() + if project_name not in clockify_projects: + response = self.clockapi.add_project(project_name) + if "id" not in response: + self.log.warning( + "Project \"{}\" can't be created. Response: {}".format( + project_name, response + ) + ) + return { + "success": False, + "message": ( + "Can't create clockify project \"{}\"." + " Unexpected error." + ).format(project_name) + } + + clockify_workspace_tags = self.clockapi.get_tags() + for task_type_name in task_type_names: + if task_type_name in clockify_workspace_tags: + self.log.debug( + "Task \"{}\" already exist".format(task_type_name) + ) + continue + + response = self.clockapi.add_tag(task_type_name) + if "id" not in response: + self.log.warning( + "Task \"{}\" can't be created. Response: {}".format( + task_type_name, response + ) + ) + + job["status"] = "done" + + except Exception: + pass + + finally: + if job["status"] != "done": + job["status"] = "failed" + session.commit() + + return True + + +def register(session, **kw): + SyncClocifyLocal(session).register() diff --git a/pype/modules/clockify/ftrack_actions/action_clockify_sync.py b/pype/modules/clockify/ftrack_actions/action_clockify_sync.py deleted file mode 100644 index a041e6ada6..0000000000 --- a/pype/modules/clockify/ftrack_actions/action_clockify_sync.py +++ /dev/null @@ -1,155 +0,0 @@ -import os -import sys -import argparse -import logging -import json -import ftrack_api -from pype.modules.ftrack import BaseAction, MissingPermision -from pype.modules.clockify import ClockifyAPI - - -class SyncClocify(BaseAction): - '''Synchronise project names and task types.''' - - #: Action identifier. - identifier = 'clockify.sync' - #: Action label. - label = 'Sync To Clockify' - #: Action description. - description = 'Synchronise data to Clockify workspace' - #: roles that are allowed to register this action - role_list = ["Pypeclub", "Administrator", "project Manager"] - #: icon - icon = '{}/app_icons/clockify-white.png'.format( - os.environ.get('PYPE_STATICS_SERVER', '') - ) - #: CLockifyApi - clockapi = ClockifyAPI() - - def preregister(self): - if self.clockapi.workspace_id is None: - return "Clockify Workspace or API key are not set!" - - if self.clockapi.validate_workspace_perm() is False: - raise MissingPermision('Clockify') - - return True - - def discover(self, session, entities, event): - ''' Validation ''' - if len(entities) != 1: - return False - - if entities[0].entity_type.lower() != "project": - return False - return True - - def launch(self, session, entities, event): - # JOB SETTINGS - userId = event['source']['user']['id'] - user = session.query('User where id is ' + userId).one() - - job = session.create('Job', { - 'user': user, - 'status': 'running', - 'data': json.dumps({ - 'description': 'Sync Ftrack to Clockify' - }) - }) - session.commit() - try: - entity = entities[0] - - if entity.entity_type.lower() == 'project': - project = entity - else: - project = entity['project'] - project_name = project['full_name'] - - task_types = [] - for task_type in project['project_schema']['_task_type_schema'][ - 'types' - ]: - task_types.append(task_type['name']) - - clockify_projects = self.clockapi.get_projects() - - if project_name not in clockify_projects: - response = self.clockapi.add_project(project_name) - if 'id' not in response: - self.log.error('Project {} can\'t be created'.format( - project_name - )) - return { - 'success': False, - 'message': 'Can\'t create project, unexpected error' - } - project_id = response['id'] - else: - project_id = clockify_projects[project_name] - - clockify_workspace_tags = self.clockapi.get_tags() - for task_type in task_types: - if task_type not in clockify_workspace_tags: - response = self.clockapi.add_tag(task_type) - if 'id' not in response: - self.log.error('Task {} can\'t be created'.format( - task_type - )) - continue - except Exception: - job['status'] = 'failed' - session.commit() - return False - - job['status'] = 'done' - session.commit() - return True - - -def register(session, **kw): - '''Register plugin. Called when used as an plugin.''' - - if not isinstance(session, ftrack_api.session.Session): - return - - SyncClocify(session).register() - - -def main(arguments=None): - '''Set up logging and register action.''' - if arguments is None: - arguments = [] - - parser = argparse.ArgumentParser() - # Allow setting of logging level from arguments. - loggingLevels = {} - for level in ( - logging.NOTSET, logging.DEBUG, logging.INFO, logging.WARNING, - logging.ERROR, logging.CRITICAL - ): - loggingLevels[logging.getLevelName(level).lower()] = level - - parser.add_argument( - '-v', '--verbosity', - help='Set the logging output verbosity.', - choices=loggingLevels.keys(), - default='info' - ) - namespace = parser.parse_args(arguments) - - # Set up basic logging - logging.basicConfig(level=loggingLevels[namespace.verbosity]) - - session = ftrack_api.Session() - register(session) - - # Wait for events - logging.info( - 'Registered actions and listening for events. Use Ctrl-C to abort.' - ) - session.event_hub.wait() - - -if __name__ == '__main__': - raise SystemExit(main(sys.argv[1:])) diff --git a/pype/modules/clockify/launcher_actions/ClockifyStart.py b/pype/modules/clockify/launcher_actions/ClockifyStart.py index d5e9164977..f97360662f 100644 --- a/pype/modules/clockify/launcher_actions/ClockifyStart.py +++ b/pype/modules/clockify/launcher_actions/ClockifyStart.py @@ -1,6 +1,6 @@ from avalon import api, io from pype.api import Logger -from pype.modules.clockify import ClockifyAPI +from pype.modules.clockify.clockify_api import ClockifyAPI log = Logger().get_logger(__name__, "clockify_start") diff --git a/pype/modules/clockify/launcher_actions/ClockifySync.py b/pype/modules/clockify/launcher_actions/ClockifySync.py index 0f20d1dce1..a77c038076 100644 --- a/pype/modules/clockify/launcher_actions/ClockifySync.py +++ b/pype/modules/clockify/launcher_actions/ClockifySync.py @@ -1,5 +1,5 @@ from avalon import api, io -from pype.modules.clockify import ClockifyAPI +from pype.modules.clockify.clockify_api import ClockifyAPI from pype.api import Logger log = Logger().get_logger(__name__, "clockify_sync") diff --git a/pype/modules/clockify/widget_message.py b/pype/modules/clockify/widget_message.py deleted file mode 100644 index f919c3f819..0000000000 --- a/pype/modules/clockify/widget_message.py +++ /dev/null @@ -1,91 +0,0 @@ -from Qt import QtCore, QtGui, QtWidgets -from avalon import style - - -class MessageWidget(QtWidgets.QWidget): - - SIZE_W = 300 - SIZE_H = 130 - - closed = QtCore.Signal() - - def __init__(self, parent=None, messages=[], title="Message"): - - super(MessageWidget, self).__init__() - - self._parent = parent - - # Icon - if parent and hasattr(parent, 'icon'): - self.setWindowIcon(parent.icon) - else: - from pypeapp.resources import get_resource - self.setWindowIcon(QtGui.QIcon(get_resource('icon.png'))) - - self.setWindowFlags( - QtCore.Qt.WindowCloseButtonHint | - QtCore.Qt.WindowMinimizeButtonHint - ) - - # Font - self.font = QtGui.QFont() - self.font.setFamily("DejaVu Sans Condensed") - self.font.setPointSize(9) - self.font.setBold(True) - self.font.setWeight(50) - self.font.setKerning(True) - - # Size setting - self.resize(self.SIZE_W, self.SIZE_H) - self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) - self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100)) - - # Style - self.setStyleSheet(style.load_stylesheet()) - - self.setLayout(self._ui_layout(messages)) - self.setWindowTitle(title) - - def _ui_layout(self, messages): - if not messages: - messages = ["*Misssing messages (This is a bug)*", ] - - elif not isinstance(messages, (tuple, list)): - messages = [messages, ] - - main_layout = QtWidgets.QVBoxLayout(self) - - labels = [] - for message in messages: - label = QtWidgets.QLabel(message) - label.setFont(self.font) - label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) - label.setTextFormat(QtCore.Qt.RichText) - label.setWordWrap(True) - - labels.append(label) - main_layout.addWidget(label) - - btn_close = QtWidgets.QPushButton("Close") - btn_close.setToolTip('Close this window') - btn_close.clicked.connect(self.on_close_clicked) - - btn_group = QtWidgets.QHBoxLayout() - btn_group.addStretch(1) - btn_group.addWidget(btn_close) - - main_layout.addLayout(btn_group) - - self.labels = labels - self.btn_group = btn_group - self.btn_close = btn_close - self.main_layout = main_layout - - return main_layout - - def on_close_clicked(self): - self.close() - - def close(self, *args, **kwargs): - self.closed.emit() - super(MessageWidget, self).close(*args, **kwargs) diff --git a/pype/modules/clockify/widget_settings.py b/pype/modules/clockify/widgets.py similarity index 65% rename from pype/modules/clockify/widget_settings.py rename to pype/modules/clockify/widgets.py index 956bdb1916..dc57a48ecb 100644 --- a/pype/modules/clockify/widget_settings.py +++ b/pype/modules/clockify/widgets.py @@ -1,6 +1,95 @@ -import os from Qt import QtCore, QtGui, QtWidgets from avalon import style +from pype.api import resources + + +class MessageWidget(QtWidgets.QWidget): + + SIZE_W = 300 + SIZE_H = 130 + + closed = QtCore.Signal() + + def __init__(self, parent=None, messages=[], title="Message"): + + super(MessageWidget, self).__init__() + + self._parent = parent + + # Icon + if parent and hasattr(parent, 'icon'): + self.setWindowIcon(parent.icon) + else: + icon = QtGui.QIcon(resources.pype_icon_filepath()) + self.setWindowIcon(icon) + + self.setWindowFlags( + QtCore.Qt.WindowCloseButtonHint | + QtCore.Qt.WindowMinimizeButtonHint + ) + + # Font + self.font = QtGui.QFont() + self.font.setFamily("DejaVu Sans Condensed") + self.font.setPointSize(9) + self.font.setBold(True) + self.font.setWeight(50) + self.font.setKerning(True) + + # Size setting + self.resize(self.SIZE_W, self.SIZE_H) + self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) + self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100)) + + # Style + self.setStyleSheet(style.load_stylesheet()) + + self.setLayout(self._ui_layout(messages)) + self.setWindowTitle(title) + + def _ui_layout(self, messages): + if not messages: + messages = ["*Misssing messages (This is a bug)*", ] + + elif not isinstance(messages, (tuple, list)): + messages = [messages, ] + + main_layout = QtWidgets.QVBoxLayout(self) + + labels = [] + for message in messages: + label = QtWidgets.QLabel(message) + label.setFont(self.font) + label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) + label.setTextFormat(QtCore.Qt.RichText) + label.setWordWrap(True) + + labels.append(label) + main_layout.addWidget(label) + + btn_close = QtWidgets.QPushButton("Close") + btn_close.setToolTip('Close this window') + btn_close.clicked.connect(self.on_close_clicked) + + btn_group = QtWidgets.QHBoxLayout() + btn_group.addStretch(1) + btn_group.addWidget(btn_close) + + main_layout.addLayout(btn_group) + + self.labels = labels + self.btn_group = btn_group + self.btn_close = btn_close + self.main_layout = main_layout + + return main_layout + + def on_close_clicked(self): + self.close() + + def close(self, *args, **kwargs): + self.closed.emit() + super(MessageWidget, self).close(*args, **kwargs) class ClockifySettings(QtWidgets.QWidget): @@ -26,10 +115,7 @@ class ClockifySettings(QtWidgets.QWidget): elif hasattr(parent, 'parent') and hasattr(parent.parent, 'icon'): self.setWindowIcon(self.parent.parent.icon) else: - pype_setup = os.getenv('PYPE_SETUP_PATH') - items = [pype_setup, "app", "resources", "icon.png"] - fname = os.path.sep.join(items) - icon = QtGui.QIcon(fname) + icon = QtGui.QIcon(resources.pype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/pype/modules/ftrack/actions/action_delete_asset.py b/pype/modules/ftrack/actions/action_delete_asset.py index 1074efee3b..27394770e1 100644 --- a/pype/modules/ftrack/actions/action_delete_asset.py +++ b/pype/modules/ftrack/actions/action_delete_asset.py @@ -497,9 +497,8 @@ class DeleteAssetSubset(BaseAction): for entity in entities: ftrack_id = entity["id"] ftrack_id_name_map[ftrack_id] = entity["name"] - if ftrack_id in ftrack_ids_to_delete: - continue - not_deleted_entities_id.append(ftrack_id) + if ftrack_id not in ftrack_ids_to_delete: + not_deleted_entities_id.append(ftrack_id) mongo_proc_txt = "MongoProcessing: " ftrack_proc_txt = "Ftrack processing: " @@ -534,25 +533,20 @@ class DeleteAssetSubset(BaseAction): ftrack_proc_txt, ", ".join(ftrack_ids_to_delete) )) - joined_ids_to_delete = ", ".join( - ["\"{}\"".format(id) for id in ftrack_ids_to_delete] + ftrack_ents_to_delete = ( + self._filter_entities_to_delete(ftrack_ids_to_delete, session) ) - ftrack_ents_to_delete = self.session.query( - "select id, link from TypedContext where id in ({})".format( - joined_ids_to_delete - ) - ).all() for entity in ftrack_ents_to_delete: - self.session.delete(entity) + session.delete(entity) try: - self.session.commit() + session.commit() except Exception: ent_path = "/".join( [ent["name"] for ent in entity["link"]] ) msg = "Failed to delete entity" report_messages[msg].append(ent_path) - self.session.rollback() + session.rollback() self.log.warning( "{} <{}>".format(msg, ent_path), exc_info=True @@ -568,7 +562,7 @@ class DeleteAssetSubset(BaseAction): for name in asset_names_to_delete ]) # Find assets of selected entities with names of checked subsets - assets = self.session.query(( + assets = session.query(( "select id from Asset where" " context_id in ({}) and name in ({})" ).format(joined_not_deleted, joined_asset_names)).all() @@ -578,20 +572,54 @@ class DeleteAssetSubset(BaseAction): ", ".join([asset["id"] for asset in assets]) )) for asset in assets: - self.session.delete(asset) + session.delete(asset) try: - self.session.commit() + session.commit() except Exception: - self.session.rollback() + session.rollback() msg = "Failed to delete asset" report_messages[msg].append(asset["id"]) self.log.warning( - "{} <{}>".format(asset["id"]), + "Asset: {} <{}>".format(asset["name"], asset["id"]), exc_info=True ) return self.report_handle(report_messages, project_name, event) + def _filter_entities_to_delete(self, ftrack_ids_to_delete, session): + """Filter children entities to avoid CircularDependencyError.""" + joined_ids_to_delete = ", ".join( + ["\"{}\"".format(id) for id in ftrack_ids_to_delete] + ) + to_delete_entities = session.query( + "select id, link from TypedContext where id in ({})".format( + joined_ids_to_delete + ) + ).all() + filtered = to_delete_entities[:] + while True: + changed = False + _filtered = filtered[:] + for entity in filtered: + entity_id = entity["id"] + + for _entity in tuple(_filtered): + if entity_id == _entity["id"]: + continue + + for _link in _entity["link"]: + if entity_id == _link["id"] and _entity in _filtered: + _filtered.remove(_entity) + changed = True + break + + filtered = _filtered + + if not changed: + break + + return filtered + def report_handle(self, report_messages, project_name, event): if not report_messages: return { diff --git a/pype/modules/ftrack/actions/action_djvview.py b/pype/modules/ftrack/actions/action_djvview.py index 9708503ad1..6f667c0604 100644 --- a/pype/modules/ftrack/actions/action_djvview.py +++ b/pype/modules/ftrack/actions/action_djvview.py @@ -1,13 +1,7 @@ import os -import sys -import logging import subprocess from operator import itemgetter -import ftrack_api from pype.modules.ftrack.lib import BaseAction, statics_icon -from pype.api import Logger, config - -log = Logger().get_logger(__name__) class DJVViewAction(BaseAction): @@ -19,20 +13,18 @@ class DJVViewAction(BaseAction): type = 'Application' + allowed_types = [ + "cin", "dpx", "avi", "dv", "gif", "flv", "mkv", "mov", "mpg", "mpeg", + "mp4", "m4v", "mxf", "iff", "z", "ifl", "jpeg", "jpg", "jfif", "lut", + "1dl", "exr", "pic", "png", "ppm", "pnm", "pgm", "pbm", "rla", "rpf", + "sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img" + ] + def __init__(self, session, plugins_presets): '''Expects a ftrack_api.Session instance''' super().__init__(session, plugins_presets) - self.djv_path = None - self.config_data = config.get_presets()['djv_view']['config'] - self.set_djv_path() - - if self.djv_path is None: - return - - self.allowed_types = self.config_data.get( - 'file_ext', ["img", "mov", "exr"] - ) + self.djv_path = self.find_djv_path() def preregister(self): if self.djv_path is None: @@ -53,11 +45,10 @@ class DJVViewAction(BaseAction): return True return False - def set_djv_path(self): - for path in self.config_data.get("djv_paths", []): + def find_djv_path(self): + for path in (os.environ.get("DJV_PATH") or "").split(os.pathsep): if os.path.exists(path): - self.djv_path = path - break + return path def interface(self, session, entities, event): if event['data'].get('values', {}): @@ -221,43 +212,3 @@ def register(session, plugins_presets={}): """Register hooks.""" DJVViewAction(session, plugins_presets).register() - - -def main(arguments=None): - '''Set up logging and register action.''' - if arguments is None: - arguments = [] - - import argparse - parser = argparse.ArgumentParser() - # Allow setting of logging level from arguments. - loggingLevels = {} - for level in ( - logging.NOTSET, logging.DEBUG, logging.INFO, logging.WARNING, - logging.ERROR, logging.CRITICAL - ): - loggingLevels[logging.getLevelName(level).lower()] = level - - parser.add_argument( - '-v', '--verbosity', - help='Set the logging output verbosity.', - choices=loggingLevels.keys(), - default='info' - ) - namespace = parser.parse_args(arguments) - - # Set up basic logging - logging.basicConfig(level=loggingLevels[namespace.verbosity]) - - session = ftrack_api.Session() - register(session) - - # Wait for events - logging.info( - 'Registered actions and listening for events. Use Ctrl-C to abort.' - ) - session.event_hub.wait() - - -if __name__ == '__main__': - raise SystemExit(main(sys.argv[1:])) diff --git a/pype/modules/ftrack/events/action_sync_to_avalon.py b/pype/modules/ftrack/events/action_sync_to_avalon.py index a06b825d6a..4e119228c3 100644 --- a/pype/modules/ftrack/events/action_sync_to_avalon.py +++ b/pype/modules/ftrack/events/action_sync_to_avalon.py @@ -1,10 +1,8 @@ -import os import time import traceback from pype.modules.ftrack import BaseAction from pype.modules.ftrack.lib.avalon_sync import SyncEntitiesFactory -from pype.api import config class SyncToAvalonServer(BaseAction): @@ -38,17 +36,6 @@ class SyncToAvalonServer(BaseAction): variant = "- Sync To Avalon (Server)" #: Action description. description = "Send data from Ftrack to Avalon" - #: Action icon. - icon = "{}/ftrack/action_icons/PypeAdmin.svg".format( - os.environ.get( - "PYPE_STATICS_SERVER", - "http://localhost:{}".format( - config.get_presets().get("services", {}).get( - "rest_api", {} - ).get("default_port", 8021) - ) - ) - ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/pype/modules/ftrack/events/event_version_to_task_statuses.py b/pype/modules/ftrack/events/event_version_to_task_statuses.py index 3ff986f9c6..fdb48cbc37 100644 --- a/pype/modules/ftrack/events/event_version_to_task_statuses.py +++ b/pype/modules/ftrack/events/event_version_to_task_statuses.py @@ -84,6 +84,9 @@ class VersionToTaskStatus(BaseEvent): if not task: continue + if version["asset"]["type"]["short"].lower() == "scene": + continue + project_schema = task["project"]["project_schema"] # Get all available statuses for Task statuses = project_schema.get_statuses("Task", task["type_id"]) diff --git a/pype/modules/ftrack/ftrack_server/event_server_cli.py b/pype/modules/ftrack/ftrack_server/event_server_cli.py index 73c7abfc5d..bf51c37290 100644 --- a/pype/modules/ftrack/ftrack_server/event_server_cli.py +++ b/pype/modules/ftrack/ftrack_server/event_server_cli.py @@ -522,6 +522,21 @@ def main(argv): help="Load creadentials from apps dir", action="store_true" ) + parser.add_argument( + "-clockifyapikey", type=str, + help=( + "Enter API key for Clockify actions." + " (default from environment: $CLOCKIFY_API_KEY)" + ) + ) + parser.add_argument( + "-clockifyworkspace", type=str, + help=( + "Enter workspace for Clockify." + " (default from module presets or " + "environment: $CLOCKIFY_WORKSPACE)" + ) + ) ftrack_url = os.environ.get('FTRACK_SERVER') username = os.environ.get('FTRACK_API_USER') api_key = os.environ.get('FTRACK_API_KEY') @@ -546,6 +561,12 @@ def main(argv): if kwargs.ftrackapikey: api_key = kwargs.ftrackapikey + if kwargs.clockifyworkspace: + os.environ["CLOCKIFY_WORKSPACE"] = kwargs.clockifyworkspace + + if kwargs.clockifyapikey: + os.environ["CLOCKIFY_API_KEY"] = kwargs.clockifyapikey + legacy = kwargs.legacy # Check url regex and accessibility ftrack_url = check_ftrack_url(ftrack_url) diff --git a/pype/modules/ftrack/ftrack_server/lib.py b/pype/modules/ftrack/ftrack_server/lib.py index 8377187ebe..acf31ab437 100644 --- a/pype/modules/ftrack/ftrack_server/lib.py +++ b/pype/modules/ftrack/ftrack_server/lib.py @@ -26,7 +26,7 @@ from pype.api import ( compose_url ) -from pype.modules.ftrack.lib.custom_db_connector import DbConnector +from pype.modules.ftrack.lib.custom_db_connector import CustomDbConnector TOPIC_STATUS_SERVER = "pype.event.server.status" @@ -44,15 +44,8 @@ def get_ftrack_event_mongo_info(): mongo_url = os.environ.get("FTRACK_EVENTS_MONGO_URL") if mongo_url is not None: components = decompose_url(mongo_url) - _used_ftrack_url = True else: components = get_default_components() - _used_ftrack_url = False - - if not _used_ftrack_url or components["database"] is None: - components["database"] = database_name - - components.pop("collection", None) uri = compose_url(**components) @@ -166,10 +159,10 @@ class ProcessEventHub(SocketBaseEventHub): pypelog = Logger().get_logger("Session Processor") def __init__(self, *args, **kwargs): - self.dbcon = DbConnector( + self.dbcon = CustomDbConnector( self.uri, - self.port, self.database, + self.port, self.table_name ) super(ProcessEventHub, self).__init__(*args, **kwargs) diff --git a/pype/modules/ftrack/ftrack_server/socket_thread.py b/pype/modules/ftrack/ftrack_server/socket_thread.py index dda4c7db35..e66e8bc775 100644 --- a/pype/modules/ftrack/ftrack_server/socket_thread.py +++ b/pype/modules/ftrack/ftrack_server/socket_thread.py @@ -11,7 +11,7 @@ from pype.api import Logger class SocketThread(threading.Thread): """Thread that checks suprocess of storer of processor of events""" - MAX_TIMEOUT = 35 + MAX_TIMEOUT = int(os.environ.get("PYPE_FTRACK_SOCKET_TIMEOUT", 45)) def __init__(self, name, port, filepath, additional_args=[]): super(SocketThread, self).__init__() diff --git a/pype/modules/ftrack/ftrack_server/sub_event_processor.py b/pype/modules/ftrack/ftrack_server/sub_event_processor.py index d7bb7a53b3..4a3241dd4f 100644 --- a/pype/modules/ftrack/ftrack_server/sub_event_processor.py +++ b/pype/modules/ftrack/ftrack_server/sub_event_processor.py @@ -9,7 +9,7 @@ from pype.modules.ftrack.ftrack_server.lib import ( SocketSession, ProcessEventHub, TOPIC_STATUS_SERVER ) import ftrack_api -from pype.api import Logger +from pype.api import Logger, config log = Logger().get_logger("Event processor") @@ -55,6 +55,42 @@ def register(session): ) +def clockify_module_registration(): + module_name = "Clockify" + + menu_items = config.get_presets()["tray"]["menu_items"] + if not menu_items["item_usage"][module_name]: + return + + api_key = os.environ.get("CLOCKIFY_API_KEY") + if not api_key: + log.warning("Clockify API key is not set.") + return + + workspace_name = os.environ.get("CLOCKIFY_WORKSPACE") + if not workspace_name: + workspace_name = ( + menu_items + .get("attributes", {}) + .get(module_name, {}) + .get("workspace_name", {}) + ) + + if not workspace_name: + log.warning("Clockify Workspace is not set.") + return + + os.environ["CLOCKIFY_WORKSPACE"] = workspace_name + + from pype.modules.clockify.constants import CLOCKIFY_FTRACK_SERVER_PATH + + current = os.environ.get("FTRACK_EVENTS_PATH") or "" + if current: + current += os.pathsep + os.environ["FTRACK_EVENTS_PATH"] = current + CLOCKIFY_FTRACK_SERVER_PATH + return True + + def main(args): port = int(args[-1]) # Create a TCP/IP socket @@ -66,6 +102,11 @@ def main(args): sock.connect(server_address) sock.sendall(b"CreatedProcess") + try: + clockify_module_registration() + except Exception: + log.info("Clockify registration failed.", exc_info=True) + try: session = SocketSession( auto_connect_event_hub=True, sock=sock, Eventhub=ProcessEventHub diff --git a/pype/modules/ftrack/ftrack_server/sub_event_storer.py b/pype/modules/ftrack/ftrack_server/sub_event_storer.py index 61b9aaf2c8..1635f6cea3 100644 --- a/pype/modules/ftrack/ftrack_server/sub_event_storer.py +++ b/pype/modules/ftrack/ftrack_server/sub_event_storer.py @@ -12,7 +12,7 @@ from pype.modules.ftrack.ftrack_server.lib import ( get_ftrack_event_mongo_info, TOPIC_STATUS_SERVER, TOPIC_STATUS_SERVER_RESULT ) -from pype.modules.ftrack.lib.custom_db_connector import DbConnector +from pype.modules.ftrack.lib.custom_db_connector import CustomDbConnector from pype.api import Logger log = Logger().get_logger("Event storer") @@ -24,7 +24,7 @@ class SessionFactory: uri, port, database, table_name = get_ftrack_event_mongo_info() -dbcon = DbConnector(uri, port, database, table_name) +dbcon = CustomDbConnector(uri, database, port, table_name) # ignore_topics = ["ftrack.meta.connected"] ignore_topics = [] diff --git a/pype/modules/ftrack/lib/custom_db_connector.py b/pype/modules/ftrack/lib/custom_db_connector.py index a734b3f80a..d498d041dc 100644 --- a/pype/modules/ftrack/lib/custom_db_connector.py +++ b/pype/modules/ftrack/lib/custom_db_connector.py @@ -9,6 +9,7 @@ import time import logging import functools import atexit +import os # Third-party dependencies import pymongo @@ -40,7 +41,7 @@ def auto_reconnect(func): def check_active_table(func): - """Check if DbConnector has active table before db method is called""" + """Check if CustomDbConnector has active collection.""" @functools.wraps(func) def decorated(obj, *args, **kwargs): if not obj.active_table: @@ -49,23 +50,12 @@ def check_active_table(func): return decorated -def check_active_table(func): - """Handling auto reconnect in 3 retry times""" - @functools.wraps(func) - def decorated(obj, *args, **kwargs): - if not obj.active_table: - raise NotActiveTable("Active table is not set. (This is bug)") - return func(obj, *args, **kwargs) - - return decorated - - -class DbConnector: +class CustomDbConnector: log = logging.getLogger(__name__) - timeout = 1000 + timeout = int(os.environ["AVALON_TIMEOUT"]) def __init__( - self, uri, port=None, database_name=None, table_name=None + self, uri, database_name, port=None, table_name=None ): self._mongo_client = None self._sentry_client = None @@ -78,9 +68,6 @@ class DbConnector: if port is None: port = components.get("port") - if database_name is None: - database_name = components.get("database") - if database_name is None: raise ValueError( "Database is not defined for connection. {}".format(uri) @@ -99,7 +86,7 @@ class DbConnector: # not all methods of PyMongo database are implemented with this it is # possible to use them too try: - return super(DbConnector, self).__getattribute__(attr) + return super(CustomDbConnector, self).__getattribute__(attr) except AttributeError: if self.active_table is None: raise NotActiveTable() diff --git a/pype/modules/ftrack/lib/ftrack_app_handler.py b/pype/modules/ftrack/lib/ftrack_app_handler.py index 00bd13fd73..22fd6eeaab 100644 --- a/pype/modules/ftrack/lib/ftrack_app_handler.py +++ b/pype/modules/ftrack/lib/ftrack_app_handler.py @@ -4,9 +4,13 @@ import copy import platform import avalon.lib import acre +import getpass from pype import lib as pypelib from pype.api import config, Anatomy from .ftrack_action_handler import BaseAction +from avalon.api import ( + last_workfile, HOST_WORKFILE_EXTENSIONS, should_start_last_workfile +) class AppAction(BaseAction): @@ -82,7 +86,7 @@ class AppAction(BaseAction): if ( len(entities) != 1 - or entities[0].entity_type.lower() != 'task' + or entities[0].entity_type.lower() != "task" ): return False @@ -90,21 +94,31 @@ class AppAction(BaseAction): if entity["parent"].entity_type.lower() == "project": return False - ft_project = self.get_project_from_entity(entity) - database = pypelib.get_avalon_database() - project_name = ft_project["full_name"] - avalon_project = database[project_name].find_one({ - "type": "project" - }) + avalon_project_apps = event["data"].get("avalon_project_apps", None) + avalon_project_doc = event["data"].get("avalon_project_doc", None) + if avalon_project_apps is None: + if avalon_project_doc is None: + ft_project = self.get_project_from_entity(entity) + database = pypelib.get_avalon_database() + project_name = ft_project["full_name"] + avalon_project_doc = database[project_name].find_one({ + "type": "project" + }) or False + event["data"]["avalon_project_doc"] = avalon_project_doc - if not avalon_project: + if not avalon_project_doc: + return False + + project_apps_config = avalon_project_doc["config"].get("apps", []) + avalon_project_apps = [ + app["name"] for app in project_apps_config + ] or False + event["data"]["avalon_project_apps"] = avalon_project_apps + + if not avalon_project_apps: return False - project_apps = avalon_project["config"].get("apps", []) - apps = [app["name"] for app in project_apps] - if self.identifier in apps: - return True - return False + return self.identifier in avalon_project_apps def _launch(self, event): entities = self._translate_event(event) @@ -140,6 +154,9 @@ class AppAction(BaseAction): """ entity = entities[0] + + task_name = entity["name"] + project_name = entity["project"]["full_name"] database = pypelib.get_avalon_database() @@ -152,18 +169,19 @@ class AppAction(BaseAction): hierarchy = "" asset_doc_parents = asset_document["data"].get("parents") - if len(asset_doc_parents) > 0: + if asset_doc_parents: hierarchy = os.path.join(*asset_doc_parents) application = avalon.lib.get_application(self.identifier) + host_name = application["application_dir"] data = { "project": { "name": entity["project"]["full_name"], "code": entity["project"]["name"] }, - "task": entity["name"], + "task": task_name, "asset": asset_name, - "app": application["application_dir"], + "app": host_name, "hierarchy": hierarchy } @@ -187,17 +205,48 @@ class AppAction(BaseAction): except FileExistsError: pass + last_workfile_path = None + extensions = HOST_WORKFILE_EXTENSIONS.get(host_name) + if extensions: + # Find last workfile + file_template = anatomy.templates["work"]["file"] + data.update({ + "version": 1, + "user": getpass.getuser(), + "ext": extensions[0] + }) + + last_workfile_path = last_workfile( + workdir, file_template, data, extensions, True + ) + # set environments for Avalon prep_env = copy.deepcopy(os.environ) prep_env.update({ "AVALON_PROJECT": project_name, "AVALON_ASSET": asset_name, - "AVALON_TASK": entity["name"], - "AVALON_APP": self.identifier.split("_")[0], + "AVALON_TASK": task_name, + "AVALON_APP": host_name, "AVALON_APP_NAME": self.identifier, "AVALON_HIERARCHY": hierarchy, "AVALON_WORKDIR": workdir }) + + start_last_workfile = should_start_last_workfile( + project_name, host_name, task_name + ) + # Store boolean as "0"(False) or "1"(True) + prep_env["AVALON_OPEN_LAST_WORKFILE"] = ( + str(int(bool(start_last_workfile))) + ) + + if ( + start_last_workfile + and last_workfile_path + and os.path.exists(last_workfile_path) + ): + prep_env["AVALON_LAST_WORKFILE"] = last_workfile_path + prep_env.update(anatomy.roots_obj.root_environments()) # collect all parents from the task @@ -213,7 +262,6 @@ class AppAction(BaseAction): tools_env = acre.get_tools(tools_attr) env = acre.compute(tools_env) env = acre.merge(env, current_env=dict(prep_env)) - env = acre.append(dict(prep_env), env) # Get path to execute st_temp_path = os.environ["PYPE_CONFIG"] diff --git a/pype/modules/ftrack/tray/login_dialog.py b/pype/modules/ftrack/tray/login_dialog.py index 3b8a366209..e0614513a3 100644 --- a/pype/modules/ftrack/tray/login_dialog.py +++ b/pype/modules/ftrack/tray/login_dialog.py @@ -3,6 +3,7 @@ import requests from avalon import style from pype.modules.ftrack import credentials from . import login_tools +from pype.api import resources from Qt import QtCore, QtGui, QtWidgets @@ -29,10 +30,7 @@ class Login_Dialog_ui(QtWidgets.QWidget): elif hasattr(parent, 'parent') and hasattr(parent.parent, 'icon'): self.setWindowIcon(self.parent.parent.icon) else: - pype_setup = os.getenv('PYPE_SETUP_PATH') - items = [pype_setup, "app", "resources", "icon.png"] - fname = os.path.sep.join(items) - icon = QtGui.QIcon(fname) + icon = QtGui.QIcon(resources.pype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/pype/modules/ftrack/tray/login_tools.py b/pype/modules/ftrack/tray/login_tools.py index b259f2d2ed..02982294f2 100644 --- a/pype/modules/ftrack/tray/login_tools.py +++ b/pype/modules/ftrack/tray/login_tools.py @@ -1,16 +1,16 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from urllib import parse -import os import webbrowser import functools -import pype -import inspect from Qt import QtCore +from pype.api import resources class LoginServerHandler(BaseHTTPRequestHandler): '''Login server handler.''' + message_filepath = resources.get_resource("ftrack", "sign_in_message.html") + def __init__(self, login_callback, *args, **kw): '''Initialise handler.''' self.login_callback = login_callback @@ -28,23 +28,21 @@ class LoginServerHandler(BaseHTTPRequestHandler): login_credentials = parse.parse_qs(query) api_user = login_credentials['api_user'][0] api_key = login_credentials['api_key'][0] - # get path to resources - path_items = os.path.dirname( - inspect.getfile(pype) - ).split(os.path.sep) - del path_items[-1] - path_items.extend(['res', 'ftrack', 'sign_in_message.html']) - message_filepath = os.path.sep.join(path_items) - message_file = open(message_filepath, 'r') - sign_in_message = message_file.read() - message_file.close() + + with open(self.message_filepath, "r") as message_file: + sign_in_message = message_file.read() + # formatting html code for python - replacement = [('{', '{{'), ('}', '}}'), ('{{}}', '{}')] - for r in (replacement): - sign_in_message = sign_in_message.replace(*r) + replacements = ( + ("{", "{{"), + ("}", "}}"), + ("{{}}", "{}") + ) + for replacement in (replacements): + sign_in_message = sign_in_message.replace(*replacement) message = sign_in_message.format(api_user) else: - message = '

Failed to sign in

' + message = "

Failed to sign in

" self.send_response(200) self.end_headers() @@ -74,7 +72,6 @@ class LoginServerThread(QtCore.QThread): def run(self): '''Listen for events.''' - # self._server = BaseHTTPServer.HTTPServer( self._server = HTTPServer( ('localhost', 0), functools.partial( diff --git a/pype/modules/idle_manager/idle_manager.py b/pype/modules/idle_manager/idle_manager.py index cfcdfef78f..3a9f9154a9 100644 --- a/pype/modules/idle_manager/idle_manager.py +++ b/pype/modules/idle_manager/idle_manager.py @@ -1,26 +1,25 @@ import time import collections -from Qt import QtCore +import threading from pynput import mouse, keyboard from pype.api import Logger -class IdleManager(QtCore.QThread): +class IdleManager(threading.Thread): """ Measure user's idle time in seconds. Idle time resets on keyboard/mouse input. Is able to emit signals at specific time idle. """ - time_signals = collections.defaultdict(list) + time_callbacks = collections.defaultdict(list) idle_time = 0 - signal_reset_timer = QtCore.Signal() def __init__(self): super(IdleManager, self).__init__() self.log = Logger().get_logger(self.__class__.__name__) - self.signal_reset_timer.connect(self._reset_time) self.qaction = None self.failed_icon = None self._is_running = False + self.threads = [] def set_qaction(self, qaction, failed_icon): self.qaction = qaction @@ -32,18 +31,18 @@ class IdleManager(QtCore.QThread): def tray_exit(self): self.stop() try: - self.time_signals = {} + self.time_callbacks = {} except Exception: pass - def add_time_signal(self, emit_time, signal): - """ If any module want to use IdleManager, need to use add_time_signal - :param emit_time: time when signal will be emitted - :type emit_time: int - :param signal: signal that will be emitted (without objects) - :type signal: QtCore.Signal + def add_time_callback(self, emit_time, callback): + """If any module want to use IdleManager, need to use this method. + + Args: + emit_time(int): Time when callback will be triggered. + callback(func): Callback that will be triggered. """ - self.time_signals[emit_time].append(signal) + self.time_callbacks[emit_time].append(callback) @property def is_running(self): @@ -58,17 +57,26 @@ class IdleManager(QtCore.QThread): def run(self): self.log.info('IdleManager has started') self._is_running = True - thread_mouse = MouseThread(self.signal_reset_timer) + thread_mouse = MouseThread(self._reset_time) thread_mouse.start() - thread_keyboard = KeyboardThread(self.signal_reset_timer) + thread_keyboard = KeyboardThread(self._reset_time) thread_keyboard.start() try: while self.is_running: + if self.idle_time in self.time_callbacks: + for callback in self.time_callbacks[self.idle_time]: + thread = threading.Thread(target=callback) + thread.start() + self.threads.append(thread) + + for thread in tuple(self.threads): + if not thread.isAlive(): + thread.join() + self.threads.remove(thread) + self.idle_time += 1 - if self.idle_time in self.time_signals: - for signal in self.time_signals[self.idle_time]: - signal.emit() time.sleep(1) + except Exception: self.log.warning( 'Idle Manager service has failed', exc_info=True @@ -79,16 +87,14 @@ class IdleManager(QtCore.QThread): # Threads don't have their attrs when Qt application already finished try: - thread_mouse.signal_stop.emit() - thread_mouse.terminate() - thread_mouse.wait() + thread_mouse.stop() + thread_mouse.join() except AttributeError: pass try: - thread_keyboard.signal_stop.emit() - thread_keyboard.terminate() - thread_keyboard.wait() + thread_keyboard.stop() + thread_keyboard.join() except AttributeError: pass @@ -96,49 +102,24 @@ class IdleManager(QtCore.QThread): self.log.info('IdleManager has stopped') -class MouseThread(QtCore.QThread): - """Listens user's mouse movement - """ - signal_stop = QtCore.Signal() +class MouseThread(mouse.Listener): + """Listens user's mouse movement.""" - def __init__(self, signal): - super(MouseThread, self).__init__() - self.signal_stop.connect(self.stop) - self.m_listener = None - - self.signal_reset_timer = signal - - def stop(self): - if self.m_listener is not None: - self.m_listener.stop() + def __init__(self, callback): + super(MouseThread, self).__init__(on_move=self.on_move) + self.callback = callback def on_move(self, posx, posy): - self.signal_reset_timer.emit() - - def run(self): - self.m_listener = mouse.Listener(on_move=self.on_move) - self.m_listener.start() + self.callback() -class KeyboardThread(QtCore.QThread): - """Listens user's keyboard input - """ - signal_stop = QtCore.Signal() +class KeyboardThread(keyboard.Listener): + """Listens user's keyboard input.""" - def __init__(self, signal): - super(KeyboardThread, self).__init__() - self.signal_stop.connect(self.stop) - self.k_listener = None + def __init__(self, callback): + super(KeyboardThread, self).__init__(on_press=self.on_press) - self.signal_reset_timer = signal - - def stop(self): - if self.k_listener is not None: - self.k_listener.stop() + self.callback = callback def on_press(self, key): - self.signal_reset_timer.emit() - - def run(self): - self.k_listener = keyboard.Listener(on_press=self.on_press) - self.k_listener.start() + self.callback() diff --git a/pype/modules/muster/widget_login.py b/pype/modules/muster/widget_login.py index 8de0d3136a..f446c13325 100644 --- a/pype/modules/muster/widget_login.py +++ b/pype/modules/muster/widget_login.py @@ -1,6 +1,7 @@ import os from Qt import QtCore, QtGui, QtWidgets from avalon import style +from pype.api import resources class MusterLogin(QtWidgets.QWidget): @@ -23,10 +24,7 @@ class MusterLogin(QtWidgets.QWidget): elif hasattr(parent, 'parent') and hasattr(parent.parent, 'icon'): self.setWindowIcon(parent.parent.icon) else: - pype_setup = os.getenv('PYPE_SETUP_PATH') - items = [pype_setup, "app", "resources", "icon.png"] - fname = os.path.sep.join(items) - icon = QtGui.QIcon(fname) + icon = QtGui.QIcon(resources.pype_icon_filepath()) self.setWindowIcon(icon) self.setWindowFlags( diff --git a/pype/modules/rest_api/__init__.py b/pype/modules/rest_api/__init__.py index fbeec00c88..55253bc58b 100644 --- a/pype/modules/rest_api/__init__.py +++ b/pype/modules/rest_api/__init__.py @@ -2,6 +2,8 @@ from .rest_api import RestApiServer from .base_class import RestApi, abort, route, register_statics from .lib import RestMethods, CallbackResult +CLASS_DEFINIION = RestApiServer + def tray_init(tray_widget, main_widget): return RestApiServer() diff --git a/pype/modules/rest_api/rest_api.py b/pype/modules/rest_api/rest_api.py index 5f0969a5a2..cc98b56a3b 100644 --- a/pype/modules/rest_api/rest_api.py +++ b/pype/modules/rest_api/rest_api.py @@ -6,7 +6,7 @@ from socketserver import ThreadingMixIn from http.server import HTTPServer from .lib import RestApiFactory, Handler from .base_class import route, register_statics -from pype.api import config, Logger +from pype.api import Logger log = Logger().get_logger("RestApiServer") @@ -85,24 +85,22 @@ class RestApiServer: Callback may return many types. For more information read docstring of `_handle_callback_result` defined in handler. """ + default_port = 8011 + exclude_ports = [] + def __init__(self): self.qaction = None self.failed_icon = None self._is_running = False - try: - self.presets = config.get_presets()["services"]["rest_api"] - except Exception: - self.presets = {"default_port": 8011, "exclude_ports": []} - log.debug(( - "There are not set presets for RestApiModule." - " Using defaults \"{}\"" - ).format(str(self.presets))) - port = self.find_port() self.rest_api_thread = RestApiThread(self, port) - statics_dir = os.path.sep.join([os.environ["PYPE_MODULE_ROOT"], "res"]) + statics_dir = os.path.join( + os.environ["PYPE_MODULE_ROOT"], + "pype", + "resources" + ) self.register_statics("/res", statics_dir) os.environ["PYPE_STATICS_SERVER"] = "{}/res".format( os.environ["PYPE_REST_API_URL"] @@ -126,8 +124,8 @@ class RestApiServer: RestApiFactory.register_obj(obj) def find_port(self): - start_port = self.presets["default_port"] - exclude_ports = self.presets["exclude_ports"] + start_port = self.default_port + exclude_ports = self.exclude_ports found_port = None # port check takes time so it's lowered to 100 ports for port in range(start_port, start_port+100): diff --git a/pype/modules/standalonepublish/widgets/widget_drop_frame.py b/pype/modules/standalonepublish/widgets/widget_drop_frame.py index 80e67aa69a..c91e906f45 100644 --- a/pype/modules/standalonepublish/widgets/widget_drop_frame.py +++ b/pype/modules/standalonepublish/widgets/widget_drop_frame.py @@ -10,10 +10,37 @@ from . import DropEmpty, ComponentsList, ComponentItem class DropDataFrame(QtWidgets.QFrame): + image_extensions = [ + ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", ".cal", + ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", ".fits", + ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", ".icer", + ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2", + ".jng", ".jpeg", ".jpeg-ls", ".jpeg", ".2000", ".jpg", ".xr", + ".jpeg", ".xt", ".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd", + ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", + ".pictor", ".png", ".psd", ".psb", ".psp", ".qtvr", ".ras", + ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", ".tiff/ep", + ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xbm", ".xcf", + ".xpm", ".xwd" + ] + video_extensions = [ + ".3g2", ".3gp", ".amv", ".asf", ".avi", ".drc", ".f4a", ".f4b", + ".f4p", ".f4v", ".flv", ".gif", ".gifv", ".m2v", ".m4p", ".m4v", + ".mkv", ".mng", ".mov", ".mp2", ".mp4", ".mpe", ".mpeg", ".mpg", + ".mpv", ".mxf", ".nsv", ".ogg", ".ogv", ".qt", ".rm", ".rmvb", + ".roq", ".svi", ".vob", ".webm", ".wmv", ".yuv" + ] + extensions = { + "nuke": [".nk"], + "maya": [".ma", ".mb"], + "houdini": [".hip"], + "image_file": image_extensions, + "video_file": video_extensions + } + def __init__(self, parent): super().__init__() self.parent_widget = parent - self.presets = config.get_presets()['standalone_publish'] self.setAcceptDrops(True) layout = QtWidgets.QVBoxLayout(self) @@ -26,7 +53,9 @@ class DropDataFrame(QtWidgets.QFrame): QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.drop_widget.sizePolicy().hasHeightForWidth()) + sizePolicy.setHeightForWidth( + self.drop_widget.sizePolicy().hasHeightForWidth() + ) self.drop_widget.setSizePolicy(sizePolicy) layout.addWidget(self.drop_widget) @@ -255,8 +284,8 @@ class DropDataFrame(QtWidgets.QFrame): file_info = data['file_info'] if ( - ext in self.presets['extensions']['image_file'] or - ext in self.presets['extensions']['video_file'] + ext in self.image_extensions + or ext in self.video_extensions ): probe_data = self.load_data_with_probe(filepath) if 'fps' not in data: @@ -293,7 +322,7 @@ class DropDataFrame(QtWidgets.QFrame): data[key] = value icon = 'default' - for ico, exts in self.presets['extensions'].items(): + for ico, exts in self.extensions.items(): if ext in exts: icon = ico break @@ -304,17 +333,16 @@ class DropDataFrame(QtWidgets.QFrame): icon += 's' data['icon'] = icon data['thumb'] = ( - ext in self.presets['extensions']['image_file'] or - ext in self.presets['extensions']['video_file'] + ext in self.image_extensions + or ext in self.video_extensions ) data['prev'] = ( - ext in self.presets['extensions']['video_file'] or - (new_is_seq and ext in self.presets['extensions']['image_file']) + ext in self.video_extensions + or (new_is_seq and ext in self.image_extensions) ) actions = [] - found = False for item in self.components_list.widgets(): if data['ext'] != item.in_data['ext']: diff --git a/pype/modules/timers_manager/__init__.py b/pype/modules/timers_manager/__init__.py index a6c4535f3d..a8a478d7ae 100644 --- a/pype/modules/timers_manager/__init__.py +++ b/pype/modules/timers_manager/__init__.py @@ -1,6 +1,8 @@ from .timers_manager import TimersManager from .widget_user_idle import WidgetUserIdle +CLASS_DEFINIION = TimersManager + def tray_init(tray_widget, main_widget): return TimersManager(tray_widget, main_widget) diff --git a/pype/modules/timers_manager/timers_manager.py b/pype/modules/timers_manager/timers_manager.py index cec730d007..82ba1013f0 100644 --- a/pype/modules/timers_manager/timers_manager.py +++ b/pype/modules/timers_manager/timers_manager.py @@ -1,5 +1,4 @@ -from Qt import QtCore -from .widget_user_idle import WidgetUserIdle +from .widget_user_idle import WidgetUserIdle, SignalHandler from pype.api import Logger, config @@ -23,32 +22,36 @@ class TimersManager(metaclass=Singleton): If IdleManager is imported then is able to handle about stop timers when user idles for a long time (set in presets). """ - modules = [] - is_running = False - last_task = None + + # Presetable attributes + # - when timer will stop if idle manager is running (minutes) + full_time = 15 + # - how many minutes before the timer is stopped will popup the message + message_time = 0.5 def __init__(self, tray_widget, main_widget): self.log = Logger().get_logger(self.__class__.__name__) + + self.modules = [] + self.is_running = False + self.last_task = None + self.tray_widget = tray_widget self.main_widget = main_widget - self.widget_user_idle = WidgetUserIdle(self) + + self.idle_man = None + self.signal_handler = None + self.widget_user_idle = WidgetUserIdle(self, tray_widget) def set_signal_times(self): try: - timer_info = ( - config.get_presets() - .get('services') - .get('timers_manager') - .get('timer') - ) - full_time = int(float(timer_info['full_time'])*60) - message_time = int(float(timer_info['message_time'])*60) + full_time = int(self.full_time * 60) + message_time = int(self.message_time * 60) self.time_show_message = full_time - message_time self.time_stop_timer = full_time return True except Exception: - self.log.warning('Was not able to load presets for TimersManager') - return False + self.log.error("Couldn't set timer signals.", exc_info=True) def add_module(self, module): """ Adds module to context @@ -114,49 +117,59 @@ class TimersManager(metaclass=Singleton): :param modules: All imported modules from TrayManager :type modules: dict """ - self.s_handler = SignalHandler(self) if 'IdleManager' in modules: + self.signal_handler = SignalHandler(self) if self.set_signal_times() is True: self.register_to_idle_manager(modules['IdleManager']) + def time_callback(self, int_def): + if not self.signal_handler: + return + + if int_def == 0: + self.signal_handler.signal_show_message.emit() + elif int_def == 1: + self.signal_handler.signal_change_label.emit() + elif int_def == 2: + self.signal_handler.signal_stop_timers.emit() + def register_to_idle_manager(self, man_obj): self.idle_man = man_obj + + # Time when message is shown + self.idle_man.add_time_callback( + self.time_show_message, + lambda: self.time_callback(0) + ) + # Times when idle is between show widget and stop timers show_to_stop_range = range( - self.time_show_message-1, self.time_stop_timer + self.time_show_message - 1, self.time_stop_timer ) for num in show_to_stop_range: - self.idle_man.add_time_signal( - num, - self.s_handler.signal_change_label + self.idle_man.add_time_callback( + num, lambda: self.time_callback(1) ) # Times when widget is already shown and user restart idle shown_and_moved_range = range( self.time_stop_timer - self.time_show_message ) for num in shown_and_moved_range: - self.idle_man.add_time_signal( - num, - self.s_handler.signal_change_label + self.idle_man.add_time_callback( + num, lambda: self.time_callback(1) ) - # Time when message is shown - self.idle_man.add_time_signal( - self.time_show_message, - self.s_handler.signal_show_message - ) + # Time when timers are stopped - self.idle_man.add_time_signal( + self.idle_man.add_time_callback( self.time_stop_timer, - self.s_handler.signal_stop_timers + lambda: self.time_callback(2) ) def change_label(self): if self.is_running is False: return - if self.widget_user_idle.bool_is_showed is False: - return - if not hasattr(self, 'idle_man'): + if not self.idle_man or self.widget_user_idle.bool_is_showed is False: return if self.idle_man.idle_time > self.time_show_message: @@ -174,14 +187,3 @@ class TimersManager(metaclass=Singleton): return if self.widget_user_idle.bool_is_showed is False: self.widget_user_idle.show() - - -class SignalHandler(QtCore.QObject): - signal_show_message = QtCore.Signal() - signal_change_label = QtCore.Signal() - signal_stop_timers = QtCore.Signal() - def __init__(self, cls): - super().__init__() - self.signal_show_message.connect(cls.show_message) - self.signal_change_label.connect(cls.change_label) - self.signal_stop_timers.connect(cls.stop_timers) diff --git a/pype/modules/timers_manager/widget_user_idle.py b/pype/modules/timers_manager/widget_user_idle.py index 697c0a04d9..22455846fd 100644 --- a/pype/modules/timers_manager/widget_user_idle.py +++ b/pype/modules/timers_manager/widget_user_idle.py @@ -1,4 +1,3 @@ -from pype.api import Logger from avalon import style from Qt import QtCore, QtGui, QtWidgets @@ -8,18 +7,18 @@ class WidgetUserIdle(QtWidgets.QWidget): SIZE_W = 300 SIZE_H = 160 - def __init__(self, parent): + def __init__(self, module, tray_widget): super(WidgetUserIdle, self).__init__() self.bool_is_showed = False self.bool_not_stopped = True - self.parent_widget = parent - self.setWindowIcon(parent.tray_widget.icon) + self.module = module + self.setWindowIcon(tray_widget.icon) self.setWindowFlags( - QtCore.Qt.WindowCloseButtonHint | - QtCore.Qt.WindowMinimizeButtonHint + QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowMinimizeButtonHint ) self._translate = QtCore.QCoreApplication.translate @@ -129,11 +128,11 @@ class WidgetUserIdle(QtWidgets.QWidget): self.lbl_rest_time.setText(str_time) def stop_timer(self): - self.parent_widget.stop_timers() + self.module.stop_timers() self.close_widget() def restart_timer(self): - self.parent_widget.restart_timers() + self.module.restart_timers() self.close_widget() def continue_timer(self): @@ -154,3 +153,15 @@ class WidgetUserIdle(QtWidgets.QWidget): def showEvent(self, event): self.bool_is_showed = True + + +class SignalHandler(QtCore.QObject): + signal_show_message = QtCore.Signal() + signal_change_label = QtCore.Signal() + signal_stop_timers = QtCore.Signal() + + def __init__(self, cls): + super().__init__() + self.signal_show_message.connect(cls.show_message) + self.signal_change_label.connect(cls.change_label) + self.signal_stop_timers.connect(cls.stop_timers) diff --git a/pype/modules/user/widget_user.py b/pype/modules/user/widget_user.py index 1d43941345..ba211c4737 100644 --- a/pype/modules/user/widget_user.py +++ b/pype/modules/user/widget_user.py @@ -1,6 +1,6 @@ from Qt import QtCore, QtGui, QtWidgets -from pype.resources import get_resource from avalon import style +from pype.api import resources class UserWidget(QtWidgets.QWidget): @@ -14,7 +14,7 @@ class UserWidget(QtWidgets.QWidget): self.module = module # Style - icon = QtGui.QIcon(get_resource("icon.png")) + icon = QtGui.QIcon(resources.pype_icon_filepath()) self.setWindowIcon(icon) self.setWindowTitle("Username Settings") self.setMinimumWidth(self.MIN_WIDTH) diff --git a/pype/plugins/blender/create/create_camera.py b/pype/plugins/blender/create/create_camera.py new file mode 100644 index 0000000000..5817985053 --- /dev/null +++ b/pype/plugins/blender/create/create_camera.py @@ -0,0 +1,32 @@ +"""Create a camera asset.""" + +import bpy + +from avalon import api +from avalon.blender import Creator, lib +import pype.hosts.blender.plugin + + +class CreateCamera(Creator): + """Polygonal static geometry""" + + name = "cameraMain" + label = "Camera" + family = "camera" + icon = "video-camera" + + def process(self): + + asset = self.data["asset"] + subset = self.data["subset"] + name = pype.hosts.blender.plugin.asset_name(asset, subset) + collection = bpy.data.collections.new(name=name) + bpy.context.scene.collection.children.link(collection) + self.data['task'] = api.Session.get('AVALON_TASK') + lib.imprint(collection, self.data) + + if (self.options or {}).get("useSelection"): + for obj in lib.get_selection(): + collection.objects.link(obj) + + return collection diff --git a/pype/plugins/blender/load/load_action.py b/pype/plugins/blender/load/load_action.py index d94bd9aac6..1f2a870640 100644 --- a/pype/plugins/blender/load/load_action.py +++ b/pype/plugins/blender/load/load_action.py @@ -174,22 +174,16 @@ class BlendActionLoader(pype.hosts.blender.plugin.AssetLoader): strips = [] - for obj in collection_metadata["objects"]: - + for obj in list(collection_metadata["objects"]): # Get all the strips that use the action arm_objs = [ arm for arm in bpy.data.objects if arm.type == 'ARMATURE'] for armature_obj in arm_objs: - if armature_obj.animation_data is not None: - for track in armature_obj.animation_data.nla_tracks: - for strip in track.strips: - if strip.action == obj.animation_data.action: - strips.append(strip) bpy.data.actions.remove(obj.animation_data.action) @@ -277,22 +271,16 @@ class BlendActionLoader(pype.hosts.blender.plugin.AssetLoader): objects = collection_metadata["objects"] lib_container = collection_metadata["lib_container"] - for obj in objects: - + for obj in list(objects): # Get all the strips that use the action arm_objs = [ arm for arm in bpy.data.objects if arm.type == 'ARMATURE'] for armature_obj in arm_objs: - if armature_obj.animation_data is not None: - for track in armature_obj.animation_data.nla_tracks: - for strip in track.strips: - if strip.action == obj.animation_data.action: - track.strips.remove(strip) bpy.data.actions.remove(obj.animation_data.action) diff --git a/pype/plugins/blender/load/load_animation.py b/pype/plugins/blender/load/load_animation.py index 1c0e6e0906..32050eca99 100644 --- a/pype/plugins/blender/load/load_animation.py +++ b/pype/plugins/blender/load/load_animation.py @@ -30,9 +30,7 @@ class BlendAnimationLoader(pype.hosts.blender.plugin.AssetLoader): color = "orange" def _remove(self, objects, lib_container): - - for obj in objects: - + for obj in list(objects): if obj.type == 'ARMATURE': bpy.data.armatures.remove(obj.data) elif obj.type == 'MESH': diff --git a/pype/plugins/blender/load/load_camera.py b/pype/plugins/blender/load/load_camera.py new file mode 100644 index 0000000000..eb53870d5c --- /dev/null +++ b/pype/plugins/blender/load/load_camera.py @@ -0,0 +1,247 @@ +"""Load a camera asset in Blender.""" + +import logging +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +from avalon import api, blender +import bpy +import pype.hosts.blender.plugin + +logger = logging.getLogger("pype").getChild("blender").getChild("load_camera") + + +class BlendCameraLoader(pype.hosts.blender.plugin.AssetLoader): + """Load a camera from a .blend file. + + Warning: + Loading the same asset more then once is not properly supported at the + moment. + """ + + families = ["camera"] + representations = ["blend"] + + label = "Link Camera" + icon = "code-fork" + color = "orange" + + def _remove(self, objects, lib_container): + for obj in list(objects): + bpy.data.cameras.remove(obj.data) + + bpy.data.collections.remove(bpy.data.collections[lib_container]) + + def _process(self, libpath, lib_container, container_name, actions): + + relative = bpy.context.preferences.filepaths.use_relative_paths + with bpy.data.libraries.load( + libpath, link=True, relative=relative + ) as (_, data_to): + data_to.collections = [lib_container] + + scene = bpy.context.scene + + scene.collection.children.link(bpy.data.collections[lib_container]) + + camera_container = scene.collection.children[lib_container].make_local() + + objects_list = [] + + for obj in camera_container.objects: + local_obj = obj.make_local() + local_obj.data.make_local() + + if not local_obj.get(blender.pipeline.AVALON_PROPERTY): + local_obj[blender.pipeline.AVALON_PROPERTY] = dict() + + avalon_info = local_obj[blender.pipeline.AVALON_PROPERTY] + avalon_info.update({"container_name": container_name}) + + if actions[0] is not None: + if local_obj.animation_data is None: + local_obj.animation_data_create() + local_obj.animation_data.action = actions[0] + + if actions[1] is not None: + if local_obj.data.animation_data is None: + local_obj.data.animation_data_create() + local_obj.data.animation_data.action = actions[1] + + objects_list.append(local_obj) + + camera_container.pop(blender.pipeline.AVALON_PROPERTY) + + bpy.ops.object.select_all(action='DESELECT') + + return objects_list + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + lib_container = pype.hosts.blender.plugin.asset_name(asset, subset) + container_name = pype.hosts.blender.plugin.asset_name( + asset, subset, namespace + ) + + container = bpy.data.collections.new(lib_container) + container.name = container_name + blender.pipeline.containerise_existing( + container, + name, + namespace, + context, + self.__class__.__name__, + ) + + container_metadata = container.get( + blender.pipeline.AVALON_PROPERTY) + + container_metadata["libpath"] = libpath + container_metadata["lib_container"] = lib_container + + objects_list = self._process( + libpath, lib_container, container_name, (None, None)) + + # Save the list of objects in the metadata container + container_metadata["objects"] = objects_list + + nodes = list(container.objects) + nodes.append(container) + self[:] = nodes + return nodes + + def update(self, container: Dict, representation: Dict): + """Update the loaded asset. + + This will remove all objects of the current collection, load the new + ones and add them to the collection. + If the objects of the collection are used in another collection they + will not be removed, only unlinked. Normally this should not be the + case though. + + Warning: + No nested collections are supported at the moment! + """ + + collection = bpy.data.collections.get( + container["objectName"] + ) + + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + logger.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert collection, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert not (collection.children), ( + "Nested collections are not supported." + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in pype.hosts.blender.plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + collection_metadata = collection.get( + blender.pipeline.AVALON_PROPERTY) + collection_libpath = collection_metadata["libpath"] + objects = collection_metadata["objects"] + lib_container = collection_metadata["lib_container"] + + normalized_collection_libpath = ( + str(Path(bpy.path.abspath(collection_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + logger.debug( + "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_collection_libpath, + normalized_libpath, + ) + if normalized_collection_libpath == normalized_libpath: + logger.info("Library already loaded, not updating...") + return + + camera = objects[0] + + camera_action = None + camera_data_action = None + + if camera.animation_data and camera.animation_data.action: + camera_action = camera.animation_data.action + + if camera.data.animation_data and camera.data.animation_data.action: + camera_data_action = camera.data.animation_data.action + + actions = (camera_action, camera_data_action) + + self._remove(objects, lib_container) + + objects_list = self._process( + str(libpath), lib_container, collection.name, actions) + + # Save the list of objects in the metadata container + collection_metadata["objects"] = objects_list + collection_metadata["libpath"] = str(libpath) + collection_metadata["representation"] = str(representation["_id"]) + + bpy.ops.object.select_all(action='DESELECT') + + def remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (avalon-core:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + + Warning: + No nested collections are supported at the moment! + """ + + collection = bpy.data.collections.get( + container["objectName"] + ) + if not collection: + return False + assert not (collection.children), ( + "Nested collections are not supported." + ) + + collection_metadata = collection.get( + blender.pipeline.AVALON_PROPERTY) + objects = collection_metadata["objects"] + lib_container = collection_metadata["lib_container"] + + self._remove(objects, lib_container) + + bpy.data.collections.remove(collection) + + return True diff --git a/pype/plugins/blender/load/load_layout.py b/pype/plugins/blender/load/load_layout.py index 0c1032c4fb..2c8948dd48 100644 --- a/pype/plugins/blender/load/load_layout.py +++ b/pype/plugins/blender/load/load_layout.py @@ -7,20 +7,11 @@ from typing import Dict, List, Optional from avalon import api, blender import bpy -import pype.hosts.blender.plugin +import pype.hosts.blender.plugin as plugin -logger = logging.getLogger("pype").getChild( - "blender").getChild("load_layout") - - -class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): - """Load animations from a .blend file. - - Warning: - Loading the same asset more then once is not properly supported at the - moment. - """ +class BlendLayoutLoader(plugin.AssetLoader): + """Load layout from a .blend file.""" families = ["layout"] representations = ["blend"] @@ -29,24 +20,25 @@ class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): icon = "code-fork" color = "orange" - def _remove(self, objects, lib_container): - - for obj in objects: - + def _remove(self, objects, obj_container): + for obj in list(objects): if obj.type == 'ARMATURE': bpy.data.armatures.remove(obj.data) elif obj.type == 'MESH': bpy.data.meshes.remove(obj.data) + elif obj.type == 'CAMERA': + bpy.data.cameras.remove(obj.data) + elif obj.type == 'CURVE': + bpy.data.curves.remove(obj.data) - for element_container in bpy.data.collections[lib_container].children: + for element_container in obj_container.children: for child in element_container.children: bpy.data.collections.remove(child) bpy.data.collections.remove(element_container) - bpy.data.collections.remove(bpy.data.collections[lib_container]) + bpy.data.collections.remove(obj_container) def _process(self, libpath, lib_container, container_name, actions): - relative = bpy.context.preferences.filepaths.use_relative_paths with bpy.data.libraries.load( libpath, link=True, relative=relative @@ -58,45 +50,55 @@ class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): scene.collection.children.link(bpy.data.collections[lib_container]) layout_container = scene.collection.children[lib_container].make_local() + layout_container.name = container_name - meshes = [] + objects_local_types = ['MESH', 'CAMERA', 'CURVE'] + + objects = [] armatures = [] - objects_list = [] + containers = list(layout_container.children) - for element_container in layout_container.children: - element_container.make_local() - meshes.extend([obj for obj in element_container.objects if obj.type == 'MESH']) - armatures.extend([obj for obj in element_container.objects if obj.type == 'ARMATURE']) - for child in element_container.children: - child.make_local() - meshes.extend(child.objects) + for container in layout_container.children: + if container.name == blender.pipeline.AVALON_CONTAINERS: + containers.remove(container) + + for container in containers: + container.make_local() + objects.extend([ + obj for obj in container.objects + if obj.type in objects_local_types + ]) + armatures.extend([ + obj for obj in container.objects + if obj.type == 'ARMATURE' + ]) + containers.extend(list(container.children)) # Link meshes first, then armatures. # The armature is unparented for all the non-local meshes, # when it is made local. - for obj in meshes + armatures: - obj = obj.make_local() - obj.data.make_local() + for obj in objects + armatures: + local_obj = obj.make_local() + if obj.data: + obj.data.make_local() - if not obj.get(blender.pipeline.AVALON_PROPERTY): - obj[blender.pipeline.AVALON_PROPERTY] = dict() + if not local_obj.get(blender.pipeline.AVALON_PROPERTY): + local_obj[blender.pipeline.AVALON_PROPERTY] = dict() - avalon_info = obj[blender.pipeline.AVALON_PROPERTY] + avalon_info = local_obj[blender.pipeline.AVALON_PROPERTY] avalon_info.update({"container_name": container_name}) - action = actions.get( obj.name, None ) + action = actions.get(local_obj.name, None) - if obj.type == 'ARMATURE' and action is not None: - obj.animation_data.action = action - - objects_list.append(obj) + if local_obj.type == 'ARMATURE' and action is not None: + local_obj.animation_data.action = action layout_container.pop(blender.pipeline.AVALON_PROPERTY) bpy.ops.object.select_all(action='DESELECT') - return objects_list + return layout_container def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, @@ -113,9 +115,15 @@ class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): libpath = self.fname asset = context["asset"]["name"] subset = context["subset"]["name"] - lib_container = pype.hosts.blender.plugin.asset_name(asset, subset) - container_name = pype.hosts.blender.plugin.asset_name( - asset, subset, namespace + lib_container = plugin.asset_name( + asset, subset + ) + unique_number = plugin.get_unique_number( + asset, subset + ) + namespace = namespace or f"{asset}_{unique_number}" + container_name = plugin.asset_name( + asset, subset, unique_number ) container = bpy.data.collections.new(lib_container) @@ -134,11 +142,13 @@ class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): container_metadata["libpath"] = libpath container_metadata["lib_container"] = lib_container - objects_list = self._process( + obj_container = self._process( libpath, lib_container, container_name, {}) + container_metadata["obj_container"] = obj_container + # Save the list of objects in the metadata container - container_metadata["objects"] = objects_list + container_metadata["objects"] = obj_container.all_objects nodes = list(container.objects) nodes.append(container) @@ -157,7 +167,6 @@ class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): Warning: No nested collections are supported at the moment! """ - collection = bpy.data.collections.get( container["objectName"] ) @@ -165,7 +174,7 @@ class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): libpath = Path(api.get_representation_path(representation)) extension = libpath.suffix.lower() - logger.info( + self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), @@ -189,41 +198,41 @@ class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): collection_metadata = collection.get( blender.pipeline.AVALON_PROPERTY) - collection_libpath = collection_metadata["libpath"] + objects = collection_metadata["objects"] + lib_container = collection_metadata["lib_container"] + obj_container = collection_metadata["obj_container"] + normalized_collection_libpath = ( str(Path(bpy.path.abspath(collection_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) - logger.debug( + self.log.debug( "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", normalized_collection_libpath, normalized_libpath, ) if normalized_collection_libpath == normalized_libpath: - logger.info("Library already loaded, not updating...") + self.log.info("Library already loaded, not updating...") return - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - actions = {} for obj in objects: - if obj.type == 'ARMATURE': + if obj.animation_data and obj.animation_data.action: + actions[obj.name] = obj.animation_data.action - actions[obj.name] = obj.animation_data.action + self._remove(objects, obj_container) - self._remove(objects, lib_container) - - objects_list = self._process( + obj_container = self._process( str(libpath), lib_container, collection.name, actions) # Save the list of objects in the metadata container - collection_metadata["objects"] = objects_list + collection_metadata["obj_container"] = obj_container + collection_metadata["objects"] = obj_container.all_objects collection_metadata["libpath"] = str(libpath) collection_metadata["representation"] = str(representation["_id"]) @@ -255,9 +264,9 @@ class BlendLayoutLoader(pype.hosts.blender.plugin.AssetLoader): collection_metadata = collection.get( blender.pipeline.AVALON_PROPERTY) objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] + obj_container = collection_metadata["obj_container"] - self._remove(objects, lib_container) + self._remove(objects, obj_container) bpy.data.collections.remove(collection) diff --git a/pype/plugins/blender/load/load_model.py b/pype/plugins/blender/load/load_model.py index 4a8f43cd48..59dc00726d 100644 --- a/pype/plugins/blender/load/load_model.py +++ b/pype/plugins/blender/load/load_model.py @@ -7,20 +7,14 @@ from typing import Dict, List, Optional from avalon import api, blender import bpy -import pype.hosts.blender.plugin - -logger = logging.getLogger("pype").getChild("blender").getChild("load_model") +import pype.hosts.blender.plugin as plugin -class BlendModelLoader(pype.hosts.blender.plugin.AssetLoader): +class BlendModelLoader(plugin.AssetLoader): """Load models from a .blend file. Because they come from a .blend file we can simply link the collection that contains the model. There is no further need to 'containerise' it. - - Warning: - Loading the same asset more then once is not properly supported at the - moment. """ families = ["model"] @@ -30,54 +24,52 @@ class BlendModelLoader(pype.hosts.blender.plugin.AssetLoader): icon = "code-fork" color = "orange" - def _remove(self, objects, lib_container): - - for obj in objects: - + def _remove(self, objects, container): + for obj in list(objects): + for material_slot in list(obj.material_slots): + bpy.data.materials.remove(material_slot.material) bpy.data.meshes.remove(obj.data) - bpy.data.collections.remove(bpy.data.collections[lib_container]) - - def _process(self, libpath, lib_container, container_name): + bpy.data.collections.remove(container) + def _process( + self, libpath, lib_container, container_name, + parent_collection + ): relative = bpy.context.preferences.filepaths.use_relative_paths with bpy.data.libraries.load( libpath, link=True, relative=relative ) as (_, data_to): data_to.collections = [lib_container] - scene = bpy.context.scene + parent = parent_collection - scene.collection.children.link(bpy.data.collections[lib_container]) + if parent is None: + parent = bpy.context.scene.collection - model_container = scene.collection.children[lib_container].make_local() + parent.children.link(bpy.data.collections[lib_container]) - objects_list = [] + model_container = parent.children[lib_container].make_local() + model_container.name = container_name for obj in model_container.objects: + local_obj = plugin.prepare_data(obj, container_name) + plugin.prepare_data(local_obj.data, container_name) - obj = obj.make_local() - - obj.data.make_local() - - for material_slot in obj.material_slots: - - material_slot.material.make_local() + for material_slot in local_obj.material_slots: + plugin.prepare_data(material_slot.material, container_name) if not obj.get(blender.pipeline.AVALON_PROPERTY): + local_obj[blender.pipeline.AVALON_PROPERTY] = dict() - obj[blender.pipeline.AVALON_PROPERTY] = dict() - - avalon_info = obj[blender.pipeline.AVALON_PROPERTY] + avalon_info = local_obj[blender.pipeline.AVALON_PROPERTY] avalon_info.update({"container_name": container_name}) - objects_list.append(obj) - model_container.pop(blender.pipeline.AVALON_PROPERTY) bpy.ops.object.select_all(action='DESELECT') - return objects_list + return model_container def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, @@ -94,35 +86,44 @@ class BlendModelLoader(pype.hosts.blender.plugin.AssetLoader): libpath = self.fname asset = context["asset"]["name"] subset = context["subset"]["name"] - lib_container = pype.hosts.blender.plugin.asset_name(asset, subset) - container_name = pype.hosts.blender.plugin.asset_name( - asset, subset, namespace + + lib_container = plugin.asset_name( + asset, subset + ) + unique_number = plugin.get_unique_number( + asset, subset + ) + namespace = namespace or f"{asset}_{unique_number}" + container_name = plugin.asset_name( + asset, subset, unique_number ) - collection = bpy.data.collections.new(lib_container) - collection.name = container_name + container = bpy.data.collections.new(lib_container) + container.name = container_name blender.pipeline.containerise_existing( - collection, + container, name, namespace, context, self.__class__.__name__, ) - container_metadata = collection.get( + container_metadata = container.get( blender.pipeline.AVALON_PROPERTY) container_metadata["libpath"] = libpath container_metadata["lib_container"] = lib_container - objects_list = self._process( - libpath, lib_container, container_name) + obj_container = self._process( + libpath, lib_container, container_name, None) + + container_metadata["obj_container"] = obj_container # Save the list of objects in the metadata container - container_metadata["objects"] = objects_list + container_metadata["objects"] = obj_container.all_objects - nodes = list(collection.objects) - nodes.append(collection) + nodes = list(container.objects) + nodes.append(container) self[:] = nodes return nodes @@ -144,7 +145,7 @@ class BlendModelLoader(pype.hosts.blender.plugin.AssetLoader): libpath = Path(api.get_representation_path(representation)) extension = libpath.suffix.lower() - logger.debug( + self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), @@ -162,38 +163,47 @@ class BlendModelLoader(pype.hosts.blender.plugin.AssetLoader): assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) - assert extension in pype.hosts.blender.plugin.VALID_EXTENSIONS, ( + assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) collection_metadata = collection.get( blender.pipeline.AVALON_PROPERTY) collection_libpath = collection_metadata["libpath"] - objects = collection_metadata["objects"] lib_container = collection_metadata["lib_container"] + obj_container = plugin.get_local_collection_with_name( + collection_metadata["obj_container"].name + ) + objects = obj_container.all_objects + + container_name = obj_container.name + normalized_collection_libpath = ( str(Path(bpy.path.abspath(collection_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) - logger.debug( + self.log.debug( "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", normalized_collection_libpath, normalized_libpath, ) if normalized_collection_libpath == normalized_libpath: - logger.info("Library already loaded, not updating...") + self.log.info("Library already loaded, not updating...") return - self._remove(objects, lib_container) + parent = plugin.get_parent_collection(obj_container) - objects_list = self._process( - str(libpath), lib_container, collection.name) + self._remove(objects, obj_container) + + obj_container = self._process( + str(libpath), lib_container, container_name, parent) # Save the list of objects in the metadata container - collection_metadata["objects"] = objects_list + collection_metadata["obj_container"] = obj_container + collection_metadata["objects"] = obj_container.all_objects collection_metadata["libpath"] = str(libpath) collection_metadata["representation"] = str(representation["_id"]) @@ -221,17 +231,20 @@ class BlendModelLoader(pype.hosts.blender.plugin.AssetLoader): collection_metadata = collection.get( blender.pipeline.AVALON_PROPERTY) - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - self._remove(objects, lib_container) + obj_container = plugin.get_local_collection_with_name( + collection_metadata["obj_container"].name + ) + objects = obj_container.all_objects + + self._remove(objects, obj_container) bpy.data.collections.remove(collection) return True -class CacheModelLoader(pype.hosts.blender.plugin.AssetLoader): +class CacheModelLoader(plugin.AssetLoader): """Load cache models. Stores the imported asset in a collection named after the asset. @@ -267,7 +280,7 @@ class CacheModelLoader(pype.hosts.blender.plugin.AssetLoader): subset = context["subset"]["name"] # TODO (jasper): evaluate use of namespace which is 'alien' to Blender. lib_container = container_name = ( - pype.hosts.blender.plugin.asset_name(asset, subset, namespace) + plugin.asset_name(asset, subset, namespace) ) relative = bpy.context.preferences.filepaths.use_relative_paths diff --git a/pype/plugins/blender/load/load_rig.py b/pype/plugins/blender/load/load_rig.py index 3e53ff0363..7b60b20064 100644 --- a/pype/plugins/blender/load/load_rig.py +++ b/pype/plugins/blender/load/load_rig.py @@ -7,20 +7,14 @@ from typing import Dict, List, Optional from avalon import api, blender import bpy -import pype.hosts.blender.plugin - -logger = logging.getLogger("pype").getChild("blender").getChild("load_model") +import pype.hosts.blender.plugin as plugin -class BlendRigLoader(pype.hosts.blender.plugin.AssetLoader): +class BlendRigLoader(plugin.AssetLoader): """Load rigs from a .blend file. Because they come from a .blend file we can simply link the collection that contains the model. There is no further need to 'containerise' it. - - Warning: - Loading the same asset more then once is not properly supported at the - moment. """ families = ["rig"] @@ -30,67 +24,69 @@ class BlendRigLoader(pype.hosts.blender.plugin.AssetLoader): icon = "code-fork" color = "orange" - def _remove(self, objects, lib_container): - - for obj in objects: - + def _remove(self, objects, obj_container): + for obj in list(objects): if obj.type == 'ARMATURE': bpy.data.armatures.remove(obj.data) elif obj.type == 'MESH': bpy.data.meshes.remove(obj.data) - for child in bpy.data.collections[lib_container].children: + for child in obj_container.children: bpy.data.collections.remove(child) - bpy.data.collections.remove(bpy.data.collections[lib_container]) - - def _process(self, libpath, lib_container, container_name, action): + bpy.data.collections.remove(obj_container) + def _process( + self, libpath, lib_container, container_name, + action, parent_collection + ): relative = bpy.context.preferences.filepaths.use_relative_paths with bpy.data.libraries.load( libpath, link=True, relative=relative ) as (_, data_to): data_to.collections = [lib_container] - scene = bpy.context.scene + parent = parent_collection - scene.collection.children.link(bpy.data.collections[lib_container]) + if parent is None: + parent = bpy.context.scene.collection - rig_container = scene.collection.children[lib_container].make_local() + parent.children.link(bpy.data.collections[lib_container]) + + rig_container = parent.children[lib_container].make_local() + rig_container.name = container_name meshes = [] armatures = [ - obj for obj in rig_container.objects if obj.type == 'ARMATURE'] - - objects_list = [] + obj for obj in rig_container.objects + if obj.type == 'ARMATURE' + ] for child in rig_container.children: - child.make_local() - meshes.extend( child.objects ) + local_child = plugin.prepare_data(child, container_name) + meshes.extend(local_child.objects) # Link meshes first, then armatures. # The armature is unparented for all the non-local meshes, # when it is made local. for obj in meshes + armatures: - obj = obj.make_local() - obj.data.make_local() - - if not obj.get(blender.pipeline.AVALON_PROPERTY): - obj[blender.pipeline.AVALON_PROPERTY] = dict() - - avalon_info = obj[blender.pipeline.AVALON_PROPERTY] - avalon_info.update({"container_name": container_name}) - - if obj.type == 'ARMATURE' and action is not None: - obj.animation_data.action = action - - objects_list.append(obj) - + local_obj = plugin.prepare_data(obj, container_name) + plugin.prepare_data(local_obj.data, container_name) + + if not local_obj.get(blender.pipeline.AVALON_PROPERTY): + local_obj[blender.pipeline.AVALON_PROPERTY] = dict() + + avalon_info = local_obj[blender.pipeline.AVALON_PROPERTY] + avalon_info.update({"container_name": container_name}) + + if local_obj.type == 'ARMATURE' and action is not None: + local_obj.animation_data.action = action + rig_container.pop(blender.pipeline.AVALON_PROPERTY) bpy.ops.object.select_all(action='DESELECT') - return objects_list + return rig_container def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, @@ -107,9 +103,15 @@ class BlendRigLoader(pype.hosts.blender.plugin.AssetLoader): libpath = self.fname asset = context["asset"]["name"] subset = context["subset"]["name"] - lib_container = pype.hosts.blender.plugin.asset_name(asset, subset) - container_name = pype.hosts.blender.plugin.asset_name( - asset, subset, namespace + lib_container = plugin.asset_name( + asset, subset + ) + unique_number = plugin.get_unique_number( + asset, subset + ) + namespace = namespace or f"{asset}_{unique_number}" + container_name = plugin.asset_name( + asset, subset, unique_number ) container = bpy.data.collections.new(lib_container) @@ -128,11 +130,13 @@ class BlendRigLoader(pype.hosts.blender.plugin.AssetLoader): container_metadata["libpath"] = libpath container_metadata["lib_container"] = lib_container - objects_list = self._process( - libpath, lib_container, container_name, None) + obj_container = self._process( + libpath, lib_container, container_name, None, None) + + container_metadata["obj_container"] = obj_container # Save the list of objects in the metadata container - container_metadata["objects"] = objects_list + container_metadata["objects"] = obj_container.all_objects nodes = list(container.objects) nodes.append(container) @@ -151,15 +155,13 @@ class BlendRigLoader(pype.hosts.blender.plugin.AssetLoader): Warning: No nested collections are supported at the moment! """ - collection = bpy.data.collections.get( container["objectName"] ) - libpath = Path(api.get_representation_path(representation)) extension = libpath.suffix.lower() - logger.info( + self.log.info( "Container: %s\nRepresentation: %s", pformat(container, indent=2), pformat(representation, indent=2), @@ -177,44 +179,55 @@ class BlendRigLoader(pype.hosts.blender.plugin.AssetLoader): assert libpath.is_file(), ( f"The file doesn't exist: {libpath}" ) - assert extension in pype.hosts.blender.plugin.VALID_EXTENSIONS, ( + assert extension in plugin.VALID_EXTENSIONS, ( f"Unsupported file: {libpath}" ) collection_metadata = collection.get( blender.pipeline.AVALON_PROPERTY) collection_libpath = collection_metadata["libpath"] - objects = collection_metadata["objects"] lib_container = collection_metadata["lib_container"] + obj_container = plugin.get_local_collection_with_name( + collection_metadata["obj_container"].name + ) + objects = obj_container.all_objects + + container_name = obj_container.name + normalized_collection_libpath = ( str(Path(bpy.path.abspath(collection_libpath)).resolve()) ) normalized_libpath = ( str(Path(bpy.path.abspath(str(libpath))).resolve()) ) - logger.debug( + self.log.debug( "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", normalized_collection_libpath, normalized_libpath, ) if normalized_collection_libpath == normalized_libpath: - logger.info("Library already loaded, not updating...") + self.log.info("Library already loaded, not updating...") return # Get the armature of the rig armatures = [obj for obj in objects if obj.type == 'ARMATURE'] assert(len(armatures) == 1) - action = armatures[0].animation_data.action + action = None + if armatures[0].animation_data and armatures[0].animation_data.action: + action = armatures[0].animation_data.action - self._remove(objects, lib_container) + parent = plugin.get_parent_collection(obj_container) - objects_list = self._process( - str(libpath), lib_container, collection.name, action) + self._remove(objects, obj_container) + + obj_container = self._process( + str(libpath), lib_container, container_name, action, parent) # Save the list of objects in the metadata container - collection_metadata["objects"] = objects_list + collection_metadata["obj_container"] = obj_container + collection_metadata["objects"] = obj_container.all_objects collection_metadata["libpath"] = str(libpath) collection_metadata["representation"] = str(representation["_id"]) @@ -245,10 +258,13 @@ class BlendRigLoader(pype.hosts.blender.plugin.AssetLoader): collection_metadata = collection.get( blender.pipeline.AVALON_PROPERTY) - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - self._remove(objects, lib_container) + obj_container = plugin.get_local_collection_with_name( + collection_metadata["obj_container"].name + ) + objects = obj_container.all_objects + + self._remove(objects, obj_container) bpy.data.collections.remove(collection) diff --git a/pype/plugins/blender/publish/extract_blend.py b/pype/plugins/blender/publish/extract_blend.py index 0924763f12..a5e76dcf4e 100644 --- a/pype/plugins/blender/publish/extract_blend.py +++ b/pype/plugins/blender/publish/extract_blend.py @@ -9,7 +9,7 @@ class ExtractBlend(pype.api.Extractor): label = "Extract Blend" hosts = ["blender"] - families = ["animation", "model", "rig", "action", "layout"] + families = ["model", "camera", "rig", "action", "layout", "animation"] optional = True def process(self, instance): diff --git a/pype/plugins/celaction/publish/collect_render_path.py b/pype/plugins/celaction/publish/collect_render_path.py index d5fe6c07a5..a3918a52b6 100644 --- a/pype/plugins/celaction/publish/collect_render_path.py +++ b/pype/plugins/celaction/publish/collect_render_path.py @@ -10,9 +10,14 @@ class CollectRenderPath(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.495 families = ["render.farm"] + # Presets + anatomy_render_key = None + anatomy_publish_render_key = None + def process(self, instance): anatomy = instance.context.data["anatomy"] anatomy_data = copy.deepcopy(instance.data["anatomyData"]) + anatomy_data["family"] = "render" padding = anatomy.templates.get("frame_padding", 4) anatomy_data.update({ "frame": f"%0{padding}d", @@ -21,12 +26,28 @@ class CollectRenderPath(pyblish.api.InstancePlugin): anatomy_filled = anatomy.format(anatomy_data) - render_dir = anatomy_filled["render_tmp"]["folder"] - render_path = anatomy_filled["render_tmp"]["path"] + # get anatomy rendering keys + anatomy_render_key = self.anatomy_render_key or "render" + anatomy_publish_render_key = self.anatomy_publish_render_key or "render" + + # get folder and path for rendering images from celaction + render_dir = anatomy_filled[anatomy_render_key]["folder"] + render_path = anatomy_filled[anatomy_render_key]["path"] # create dir if it doesnt exists - os.makedirs(render_dir, exist_ok=True) + try: + if not os.path.isdir(render_dir): + os.makedirs(render_dir, exist_ok=True) + except OSError: + # directory is not available + self.log.warning("Path is unreachable: `{}`".format(render_dir)) + # add rendering path to instance data instance.data["path"] = render_path + # get anatomy for published renders folder path + if anatomy_filled.get(anatomy_publish_render_key): + instance.data["publishRenderFolder"] = anatomy_filled[ + anatomy_publish_render_key]["folder"] + self.log.info(f"Render output path set to: `{render_path}`") diff --git a/pype/plugins/celaction/publish/integrate_version_up.py b/pype/plugins/celaction/publish/integrate_version_up.py index 7fb1efa8aa..1822ceabcb 100644 --- a/pype/plugins/celaction/publish/integrate_version_up.py +++ b/pype/plugins/celaction/publish/integrate_version_up.py @@ -4,9 +4,9 @@ import pyblish.api class VersionUpScene(pyblish.api.ContextPlugin): - order = pyblish.api.IntegratorOrder + order = pyblish.api.IntegratorOrder + 0.5 label = 'Version Up Scene' - families = ['scene'] + families = ['workfile'] optional = True active = True diff --git a/pype/plugins/celaction/publish/submit_celaction_deadline.py b/pype/plugins/celaction/publish/submit_celaction_deadline.py index c749ec111f..9091b24150 100644 --- a/pype/plugins/celaction/publish/submit_celaction_deadline.py +++ b/pype/plugins/celaction/publish/submit_celaction_deadline.py @@ -74,6 +74,7 @@ class ExtractCelactionDeadline(pyblish.api.InstancePlugin): resolution_width = instance.data["resolutionWidth"] resolution_height = instance.data["resolutionHeight"] render_dir = os.path.normpath(os.path.dirname(render_path)) + render_path = os.path.normpath(render_path) script_name = os.path.basename(script_path) jobname = "%s - %s" % (script_name, instance.name) @@ -98,6 +99,7 @@ class ExtractCelactionDeadline(pyblish.api.InstancePlugin): args = [ f"{script_path}", "-a", + "-16", "-s ", "-e ", f"-d {render_dir}", @@ -135,8 +137,10 @@ class ExtractCelactionDeadline(pyblish.api.InstancePlugin): # Optional, enable double-click to preview rendered # frames from Deadline Monitor - "OutputFilename0": output_filename_0.replace("\\", "/") + "OutputFilename0": output_filename_0.replace("\\", "/"), + # # Asset dependency to wait for at least the scene file to sync. + # "AssetDependency0": script_path }, "PluginInfo": { # Input diff --git a/pype/plugins/ftrack/publish/collect_ftrack_api.py b/pype/plugins/ftrack/publish/collect_ftrack_api.py index 151b8882a3..bbda6da3b0 100644 --- a/pype/plugins/ftrack/publish/collect_ftrack_api.py +++ b/pype/plugins/ftrack/publish/collect_ftrack_api.py @@ -96,6 +96,6 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): task_entity = None self.log.warning("Task name is not set.") - context.data["ftrackProject"] = asset_entity + context.data["ftrackProject"] = project_entity context.data["ftrackEntity"] = asset_entity context.data["ftrackTask"] = task_entity diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_api.py b/pype/plugins/ftrack/publish/integrate_ftrack_api.py index cd94b2a150..0c4c6d49b5 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_api.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_api.py @@ -54,8 +54,52 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): self.log.debug(query) return query - def process(self, instance): + def _set_task_status(self, instance, task_entity, session): + project_entity = instance.context.data.get("ftrackProject") + if not project_entity: + self.log.info("Task status won't be set, project is not known.") + return + if not task_entity: + self.log.info("Task status won't be set, task is not known.") + return + + status_name = instance.context.data.get("ftrackStatus") + if not status_name: + self.log.info("Ftrack status name is not set.") + return + + self.log.debug( + "Ftrack status name will be (maybe) set to \"{}\"".format( + status_name + ) + ) + + project_schema = project_entity["project_schema"] + task_statuses = project_schema.get_statuses( + "Task", task_entity["type_id"] + ) + task_statuses_by_low_name = { + status["name"].lower(): status for status in task_statuses + } + status = task_statuses_by_low_name.get(status_name.lower()) + if not status: + self.log.warning(( + "Task status \"{}\" won't be set," + " status is now allowed on task type \"{}\"." + ).format(status_name, task_entity["type"]["name"])) + return + + self.log.info("Setting task status to \"{}\"".format(status_name)) + task_entity["status"] = status + try: + session.commit() + except Exception: + tp, value, tb = sys.exc_info() + session.rollback() + six.reraise(tp, value, tb) + + def process(self, instance): session = instance.context.data["ftrackSession"] if instance.data.get("ftrackTask"): task = instance.data["ftrackTask"] @@ -78,9 +122,11 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): info_msg += ", metadata: {metadata}." used_asset_versions = [] + + self._set_task_status(instance, task, session) + # Iterate over components and publish for data in instance.data.get("ftrackComponentsList", []): - # AssetType # Get existing entity. assettype_data = {"short": "upload"} @@ -94,9 +140,9 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): # Create a new entity if none exits. if not assettype_entity: assettype_entity = session.create("AssetType", assettype_data) - self.log.debug( - "Created new AssetType with data: ".format(assettype_data) - ) + self.log.debug("Created new AssetType with data: {}".format( + assettype_data + )) # Asset # Get existing entity. diff --git a/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py b/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py index a12fdfd36c..a0059c55a6 100644 --- a/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py +++ b/pype/plugins/ftrack/publish/integrate_hierarchy_ftrack.py @@ -1,9 +1,13 @@ import sys - import six import pyblish.api from avalon import io +try: + from pype.modules.ftrack.lib.avalon_sync import CUST_ATTR_AUTO_SYNC +except Exception: + CUST_ATTR_AUTO_SYNC = "avalon_auto_sync" + class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): """ @@ -39,15 +43,32 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): if "hierarchyContext" not in context.data: return + self.session = self.context.data["ftrackSession"] + project_name = self.context.data["projectEntity"]["name"] + query = 'Project where full_name is "{}"'.format(project_name) + project = self.session.query(query).one() + auto_sync_state = project[ + "custom_attributes"][CUST_ATTR_AUTO_SYNC] + if not io.Session: io.install() self.ft_project = None - self.session = context.data["ftrackSession"] input_data = context.data["hierarchyContext"] - self.import_to_ftrack(input_data) + # disable termporarily ftrack project's autosyncing + if auto_sync_state: + self.auto_sync_off(project) + + try: + # import ftrack hierarchy + self.import_to_ftrack(input_data) + except Exception: + raise + finally: + if auto_sync_state: + self.auto_sync_on(project) def import_to_ftrack(self, input_data, parent=None): for entity_name in input_data: @@ -217,3 +238,28 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): six.reraise(tp, value, tb) return entity + + def auto_sync_off(self, project): + project["custom_attributes"][CUST_ATTR_AUTO_SYNC] = False + + self.log.info("Ftrack autosync swithed off") + + try: + self.session.commit() + except Exception: + tp, value, tb = sys.exc_info() + self.session.rollback() + raise + + def auto_sync_on(self, project): + + project["custom_attributes"][CUST_ATTR_AUTO_SYNC] = True + + self.log.info("Ftrack autosync swithed on") + + try: + self.session.commit() + except Exception: + tp, value, tb = sys.exc_info() + self.session.rollback() + raise diff --git a/pype/plugins/global/load/open_djv.py b/pype/plugins/global/load/open_djv.py index 650936a4dc..a500333875 100644 --- a/pype/plugins/global/load/open_djv.py +++ b/pype/plugins/global/load/open_djv.py @@ -1,34 +1,27 @@ import os import subprocess -import json -from pype.api import config from avalon import api -def get_families(): - families = [] - paths = config.get_presets().get("djv_view", {}).get("config", {}).get( - "djv_paths", [] - ) - for path in paths: +def existing_djv_path(): + djv_paths = os.environ.get("DJV_PATH") or "" + for path in djv_paths.split(os.pathsep): if os.path.exists(path): - families.append("*") - break - return families - - -def get_representation(): - return config.get_presets().get("djv_view", {}).get("config", {}).get( - 'file_ext', [] - ) + return path + return None class OpenInDJV(api.Loader): """Open Image Sequence with system default""" - config_data = config.get_presets().get("djv_view", {}).get("config", {}) - families = get_families() - representations = get_representation() + djv_path = existing_djv_path() + families = ["*"] if djv_path else [] + representations = [ + "cin", "dpx", "avi", "dv", "gif", "flv", "mkv", "mov", "mpg", "mpeg", + "mp4", "m4v", "mxf", "iff", "z", "ifl", "jpeg", "jpg", "jfif", "lut", + "1dl", "exr", "pic", "png", "ppm", "pnm", "pgm", "pbm", "rla", "rpf", + "sgi", "rgba", "rgb", "bw", "tga", "tiff", "tif", "img" + ] label = "Open in DJV" order = -10 @@ -36,14 +29,6 @@ class OpenInDJV(api.Loader): color = "orange" def load(self, context, name, namespace, data): - self.djv_path = None - paths = config.get_presets().get("djv_view", {}).get("config", {}).get( - "djv_paths", [] - ) - for path in paths: - if os.path.exists(path): - self.djv_path = path - break directory = os.path.dirname(self.fname) from avalon.vendor import clique diff --git a/pype/plugins/global/publish/integrate_new.py b/pype/plugins/global/publish/integrate_new.py index 6d5c693b79..1d50e24e86 100644 --- a/pype/plugins/global/publish/integrate_new.py +++ b/pype/plugins/global/publish/integrate_new.py @@ -83,6 +83,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "textures", "action", "harmony.template", + "harmony.palette", "editorial" ] exclude_families = ["clip"] @@ -515,12 +516,6 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): instance: the instance to integrate """ transfers = instance.data.get("transfers", list()) - - for src, dest in transfers: - if os.path.normpath(src) != os.path.normpath(dest): - self.copy_file(src, dest) - - transfers = instance.data.get("transfers", list()) for src, dest in transfers: self.copy_file(src, dest) @@ -558,12 +553,12 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): # copy file with speedcopy and check if size of files are simetrical while True: + import shutil try: copyfile(src, dst) - except (OSError, AttributeError) as e: - self.log.warning(e) - # try it again with shutil - import shutil + except shutil.SameFileError as sfe: + self.log.critical("files are the same {} to {}".format(src, dst)) + os.remove(dst) try: shutil.copyfile(src, dst) self.log.debug("Copying files with shutil...") @@ -607,7 +602,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "type": "subset", "name": subset_name, "data": { - "families": instance.data.get('families') + "families": instance.data.get("families", []) }, "parent": asset["_id"] }).inserted_id @@ -747,6 +742,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): value += 1 if value > highest_value: + matching_profiles = {} highest_value = value if value == highest_value: diff --git a/pype/plugins/global/publish/submit_publish_job.py b/pype/plugins/global/publish/submit_publish_job.py index 10c01886fa..9f89466c31 100644 --- a/pype/plugins/global/publish/submit_publish_job.py +++ b/pype/plugins/global/publish/submit_publish_job.py @@ -12,7 +12,15 @@ from avalon.vendor import requests, clique import pyblish.api -def _get_script(): +def _get_script(path): + + # pass input path if exists + if path: + if os.path.exists(path): + return str(path) + else: + raise + """Get path to the image sequence script.""" try: from pathlib import Path @@ -192,6 +200,38 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): families_transfer = ["render3d", "render2d", "ftrack", "slate"] plugin_python_version = "3.7" + # script path for publish_filesequence.py + publishing_script = None + + def _create_metadata_path(self, instance): + ins_data = instance.data + # Ensure output dir exists + output_dir = ins_data.get("publishRenderFolder", ins_data["outputDir"]) + + try: + if not os.path.isdir(output_dir): + os.makedirs(output_dir) + except OSError: + # directory is not available + self.log.warning("Path is unreachable: `{}`".format(output_dir)) + + metadata_filename = "{}_metadata.json".format(ins_data["subset"]) + + metadata_path = os.path.join(output_dir, metadata_filename) + + # Convert output dir to `{root}/rest/of/path/...` with Anatomy + success, roothless_mtdt_p = self.anatomy.find_root_template_from_path( + metadata_path) + if not success: + # `rootless_path` is not set to `output_dir` if none of roots match + self.log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues on farm." + ).format(output_dir)) + roothless_mtdt_p = metadata_path + + return (metadata_path, roothless_mtdt_p) + def _submit_deadline_post_job(self, instance, job): """Submit publish job to Deadline. @@ -205,17 +245,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): job_name = "Publish - {subset}".format(subset=subset) output_dir = instance.data["outputDir"] - # Convert output dir to `{root}/rest/of/path/...` with Anatomy - success, rootless_path = ( - self.anatomy.find_root_template_from_path(output_dir) - ) - if not success: - # `rootless_path` is not set to `output_dir` if none of roots match - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues on farm." - ).format(output_dir)) - rootless_path = output_dir # Generate the payload for Deadline submission payload = { @@ -239,7 +268,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): }, "PluginInfo": { "Version": self.plugin_python_version, - "ScriptFile": _get_script(), + "ScriptFile": _get_script(self.publishing_script), "Arguments": "", "SingleFrameOnly": "True", }, @@ -249,11 +278,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # Transfer the environment from the original job to this dependent # job so they use the same environment - metadata_filename = "{}_metadata.json".format(subset) - metadata_path = os.path.join(rootless_path, metadata_filename) + metadata_path, roothless_metadata_path = self._create_metadata_path( + instance) environment = job["Props"].get("Env", {}) - environment["PYPE_METADATA_FILE"] = metadata_path + environment["PYPE_METADATA_FILE"] = roothless_metadata_path environment["AVALON_PROJECT"] = io.Session["AVALON_PROJECT"] environment["PYPE_LOG_NO_COLORS"] = "1" try: @@ -488,7 +517,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if bake_render_path: preview = False - if "celaction" in self.hosts: + if "celaction" in pyblish.api.registered_hosts(): preview = True staging = os.path.dirname(list(collection)[0]) @@ -847,14 +876,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): } publish_job.update({"ftrack": ftrack}) - # Ensure output dir exists - output_dir = instance.data["outputDir"] - if not os.path.isdir(output_dir): - os.makedirs(output_dir) + metadata_path, roothless_metadata_path = self._create_metadata_path( + instance) - metadata_filename = "{}_metadata.json".format(subset) - - metadata_path = os.path.join(output_dir, metadata_filename) self.log.info("Writing json file: {}".format(metadata_path)) with open(metadata_path, "w") as f: json.dump(publish_job, f, indent=4, sort_keys=True) diff --git a/pype/plugins/global/publish/validate_containers.py b/pype/plugins/global/publish/validate_containers.py index 44cb5def3c..1bf4967ec2 100644 --- a/pype/plugins/global/publish/validate_containers.py +++ b/pype/plugins/global/publish/validate_containers.py @@ -19,7 +19,7 @@ class ValidateContainers(pyblish.api.ContextPlugin): label = "Validate Containers" order = pyblish.api.ValidatorOrder - hosts = ["maya", "houdini", "nuke"] + hosts = ["maya", "houdini", "nuke", "harmony", "photoshop"] optional = True actions = [ShowInventory] diff --git a/pype/plugins/harmony/load/load_imagesequence.py b/pype/plugins/harmony/load/load_imagesequence.py index 7862e027af..f81018d0fb 100644 --- a/pype/plugins/harmony/load/load_imagesequence.py +++ b/pype/plugins/harmony/load/load_imagesequence.py @@ -1,8 +1,10 @@ import os +import uuid import clique from avalon import api, harmony +import pype.lib copy_files = """function copyFile(srcFilename, dstFilename) { @@ -98,33 +100,63 @@ function import_files(args) transparencyModeAttr.setValue(SGITransparencyMode); if (extension == "psd") transparencyModeAttr.setValue(FlatPSDTransparencyMode); + if (extension == "jpg") + transparencyModeAttr.setValue(LayeredPSDTransparencyMode); node.linkAttr(read, "DRAWING.ELEMENT", uniqueColumnName); - // Create a drawing for each file. - for( var i =0; i <= files.length - 1; ++i) + if (files.length == 1) { - timing = start_frame + i // Create a drawing drawing, 'true' indicate that the file exists. - Drawing.create(elemId, timing, true); + Drawing.create(elemId, 1, true); // Get the actual path, in tmp folder. - var drawingFilePath = Drawing.filename(elemId, timing.toString()); - copyFile( files[i], drawingFilePath ); + var drawingFilePath = Drawing.filename(elemId, "1"); + copyFile(files[0], drawingFilePath); + // Expose the image for the entire frame range. + for( var i =0; i <= frame.numberOf() - 1; ++i) + { + timing = start_frame + i + column.setEntry(uniqueColumnName, 1, timing, "1"); + } + } else { + // Create a drawing for each file. + for( var i =0; i <= files.length - 1; ++i) + { + timing = start_frame + i + // Create a drawing drawing, 'true' indicate that the file exists. + Drawing.create(elemId, timing, true); + // Get the actual path, in tmp folder. + var drawingFilePath = Drawing.filename(elemId, timing.toString()); + copyFile( files[i], drawingFilePath ); - column.setEntry(uniqueColumnName, 1, timing, timing.toString()); + column.setEntry(uniqueColumnName, 1, timing, timing.toString()); + } } + + var green_color = new ColorRGBA(0, 255, 0, 255); + node.setColor(read, green_color); + return read; } import_files """ -replace_files = """function replace_files(args) +replace_files = """var PNGTransparencyMode = 0; //Premultiplied wih Black +var TGATransparencyMode = 0; //Premultiplied wih Black +var SGITransparencyMode = 0; //Premultiplied wih Black +var LayeredPSDTransparencyMode = 1; //Straight +var FlatPSDTransparencyMode = 2; //Premultiplied wih White + +function replace_files(args) { var files = args[0]; + MessageLog.trace(files); + MessageLog.trace(files.length); var _node = args[1]; var start_frame = args[2]; var _column = node.linkedColumn(_node, "DRAWING.ELEMENT"); + var elemId = column.getElementIdOfDrawing(_column); // Delete existing drawings. var timings = column.getDrawingTimings(_column); @@ -133,20 +165,62 @@ replace_files = """function replace_files(args) column.deleteDrawingAt(_column, parseInt(timings[i])); } - // Create new drawings. - for( var i =0; i <= files.length - 1; ++i) - { - timing = start_frame + i - // Create a drawing drawing, 'true' indicate that the file exists. - Drawing.create(node.getElementId(_node), timing, true); - // Get the actual path, in tmp folder. - var drawingFilePath = Drawing.filename( - node.getElementId(_node), timing.toString() - ); - copyFile( files[i], drawingFilePath ); - column.setEntry(_column, 1, timing, timing.toString()); + var filename = files[0]; + var pos = filename.lastIndexOf("."); + if( pos < 0 ) + return null; + var extension = filename.substr(pos+1).toLowerCase(); + + if(extension == "jpeg") + extension = "jpg"; + + var transparencyModeAttr = node.getAttr( + _node, frame.current(), "applyMatteToColor" + ); + if (extension == "png") + transparencyModeAttr.setValue(PNGTransparencyMode); + if (extension == "tga") + transparencyModeAttr.setValue(TGATransparencyMode); + if (extension == "sgi") + transparencyModeAttr.setValue(SGITransparencyMode); + if (extension == "psd") + transparencyModeAttr.setValue(FlatPSDTransparencyMode); + if (extension == "jpg") + transparencyModeAttr.setValue(LayeredPSDTransparencyMode); + + if (files.length == 1) + { + // Create a drawing drawing, 'true' indicate that the file exists. + Drawing.create(elemId, 1, true); + // Get the actual path, in tmp folder. + var drawingFilePath = Drawing.filename(elemId, "1"); + copyFile(files[0], drawingFilePath); + MessageLog.trace(files[0]); + MessageLog.trace(drawingFilePath); + // Expose the image for the entire frame range. + for( var i =0; i <= frame.numberOf() - 1; ++i) + { + timing = start_frame + i + column.setEntry(_column, 1, timing, "1"); + } + } else { + // Create a drawing for each file. + for( var i =0; i <= files.length - 1; ++i) + { + timing = start_frame + i + // Create a drawing drawing, 'true' indicate that the file exists. + Drawing.create(elemId, timing, true); + // Get the actual path, in tmp folder. + var drawingFilePath = Drawing.filename(elemId, timing.toString()); + copyFile( files[i], drawingFilePath ); + + column.setEntry(_column, 1, timing, timing.toString()); + } } + + var green_color = new ColorRGBA(0, 255, 0, 255); + node.setColor(_node, green_color); } replace_files """ @@ -156,8 +230,8 @@ class ImageSequenceLoader(api.Loader): """Load images Stores the imported asset in a container named after the asset. """ - families = ["shot", "render"] - representations = ["jpeg", "png"] + families = ["shot", "render", "image"] + representations = ["jpeg", "png", "jpg"] def load(self, context, name=None, namespace=None, data=None): @@ -165,20 +239,29 @@ class ImageSequenceLoader(api.Loader): os.listdir(os.path.dirname(self.fname)) ) files = [] - for f in list(collections[0]): + if collections: + for f in list(collections[0]): + files.append( + os.path.join( + os.path.dirname(self.fname), f + ).replace("\\", "/") + ) + else: files.append( - os.path.join(os.path.dirname(self.fname), f).replace("\\", "/") + os.path.join( + os.path.dirname(self.fname), remainder[0] + ).replace("\\", "/") ) + name = context["subset"]["name"] + name += "_{}".format(uuid.uuid4()) read_node = harmony.send( { "function": copy_files + import_files, - "args": ["Top", files, context["subset"]["name"], 1] + "args": ["Top", files, name, 1] } )["result"] - self[:] = [read_node] - return harmony.containerise( name, namespace, @@ -188,17 +271,25 @@ class ImageSequenceLoader(api.Loader): ) def update(self, container, representation): - node = container.pop("node") + node = harmony.find_node_by_name(container["name"], "READ") + path = api.get_representation_path(representation) collections, remainder = clique.assemble( - os.listdir( - os.path.dirname(api.get_representation_path(representation)) - ) + os.listdir(os.path.dirname(path)) ) files = [] - for f in list(collections[0]): + if collections: + for f in list(collections[0]): + files.append( + os.path.join( + os.path.dirname(path), f + ).replace("\\", "/") + ) + else: files.append( - os.path.join(os.path.dirname(self.fname), f).replace("\\", "/") + os.path.join( + os.path.dirname(path), remainder[0] + ).replace("\\", "/") ) harmony.send( @@ -208,12 +299,34 @@ class ImageSequenceLoader(api.Loader): } ) + # Colour node. + func = """function func(args){ + for( var i =0; i <= args[0].length - 1; ++i) + { + var red_color = new ColorRGBA(255, 0, 0, 255); + var green_color = new ColorRGBA(0, 255, 0, 255); + if (args[1] == "red"){ + node.setColor(args[0], red_color); + } + if (args[1] == "green"){ + node.setColor(args[0], green_color); + } + } + } + func + """ + if pype.lib.is_latest(representation): + harmony.send({"function": func, "args": [node, "green"]}) + else: + harmony.send({"function": func, "args": [node, "red"]}) + harmony.imprint( node, {"representation": str(representation["_id"])} ) def remove(self, container): - node = container.pop("node") + node = harmony.find_node_by_name(container["name"], "READ") + func = """function deleteNode(_node) { node.deleteNode(_node, true, true); diff --git a/pype/plugins/harmony/load/load_palette.py b/pype/plugins/harmony/load/load_palette.py new file mode 100644 index 0000000000..001758d5a8 --- /dev/null +++ b/pype/plugins/harmony/load/load_palette.py @@ -0,0 +1,66 @@ +import os +import shutil + +from avalon import api, harmony +from avalon.vendor import Qt + + +class ImportPaletteLoader(api.Loader): + """Import palettes.""" + + families = ["harmony.palette"] + representations = ["plt"] + label = "Import Palette" + + def load(self, context, name=None, namespace=None, data=None): + name = self.load_palette(context["representation"]) + + return harmony.containerise( + name, + namespace, + name, + context, + self.__class__.__name__ + ) + + def load_palette(self, representation): + subset_name = representation["context"]["subset"] + name = subset_name.replace("palette", "") + + # Overwrite palette on disk. + scene_path = harmony.send( + {"function": "scene.currentProjectPath"} + )["result"] + src = api.get_representation_path(representation) + dst = os.path.join( + scene_path, + "palette-library", + "{}.plt".format(name) + ) + shutil.copy(src, dst) + + harmony.save_scene() + + # Dont allow instances with the same name. + message_box = Qt.QtWidgets.QMessageBox() + message_box.setIcon(Qt.QtWidgets.QMessageBox.Warning) + msg = "Updated {}.".format(subset_name) + msg += " You need to reload the scene to see the changes." + message_box.setText(msg) + message_box.exec_() + + return name + + def remove(self, container): + harmony.remove(container["name"]) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + self.remove(container) + name = self.load_palette(representation) + + container["representation"] = str(representation["_id"]) + container["name"] = name + harmony.imprint(name, container) diff --git a/pype/plugins/harmony/load/load_template_workfile.py b/pype/plugins/harmony/load/load_template_workfile.py index a9dcd0c776..db67f20ff7 100644 --- a/pype/plugins/harmony/load/load_template_workfile.py +++ b/pype/plugins/harmony/load/load_template_workfile.py @@ -9,7 +9,7 @@ from avalon import api, harmony class ImportTemplateLoader(api.Loader): """Import templates.""" - families = ["harmony.template"] + families = ["harmony.template", "workfile"] representations = ["*"] label = "Import Template" @@ -40,5 +40,5 @@ class ImportWorkfileLoader(ImportTemplateLoader): """Import workfiles.""" families = ["workfile"] - representations = ["*"] + representations = ["zip"] label = "Import Workfile" diff --git a/pype/plugins/harmony/publish/collect_palettes.py b/pype/plugins/harmony/publish/collect_palettes.py new file mode 100644 index 0000000000..2a2c1066c0 --- /dev/null +++ b/pype/plugins/harmony/publish/collect_palettes.py @@ -0,0 +1,45 @@ +import os +import json + +import pyblish.api +from avalon import harmony + + +class CollectPalettes(pyblish.api.ContextPlugin): + """Gather palettes from scene when publishing templates.""" + + label = "Palettes" + order = pyblish.api.CollectorOrder + hosts = ["harmony"] + + def process(self, context): + func = """function func() + { + var palette_list = PaletteObjectManager.getScenePaletteList(); + + var palettes = {}; + for(var i=0; i < palette_list.numPalettes; ++i) + { + var palette = palette_list.getPaletteByIndex(i); + palettes[palette.getName()] = palette.id; + } + + return palettes; + } + func + """ + palettes = harmony.send({"function": func})["result"] + + for name, id in palettes.items(): + instance = context.create_instance(name) + instance.data.update({ + "id": id, + "family": "harmony.palette", + "asset": os.environ["AVALON_ASSET"], + "subset": "palette" + name + }) + self.log.info( + "Created instance:\n" + json.dumps( + instance.data, sort_keys=True, indent=4 + ) + ) diff --git a/pype/plugins/harmony/publish/extract_palette.py b/pype/plugins/harmony/publish/extract_palette.py new file mode 100644 index 0000000000..9bca005278 --- /dev/null +++ b/pype/plugins/harmony/publish/extract_palette.py @@ -0,0 +1,34 @@ +import os + +from avalon import harmony +import pype.api +import pype.hosts.harmony + + +class ExtractPalette(pype.api.Extractor): + """Extract palette.""" + + label = "Extract Palette" + hosts = ["harmony"] + families = ["harmony.palette"] + + def process(self, instance): + func = """function func(args) + { + var palette_list = PaletteObjectManager.getScenePaletteList(); + var palette = palette_list.getPaletteById(args[0]); + return (palette.getPath() + "/" + palette.getName() + ".plt"); + } + func + """ + palette_file = harmony.send( + {"function": func, "args": [instance.data["id"]]} + )["result"] + + representation = { + "name": "plt", + "ext": "plt", + "files": os.path.basename(palette_file), + "stagingDir": os.path.dirname(palette_file) + } + instance.data["representations"] = [representation] diff --git a/pype/plugins/harmony/publish/extract_render.py b/pype/plugins/harmony/publish/extract_render.py index 7ca83d3f0f..fe1352f9f9 100644 --- a/pype/plugins/harmony/publish/extract_render.py +++ b/pype/plugins/harmony/publish/extract_render.py @@ -111,13 +111,22 @@ class ExtractRender(pyblish.api.InstancePlugin): # Generate mov. mov_path = os.path.join(path, instance.data["name"] + ".mov") - args = [ - "ffmpeg", "-y", - "-i", audio_path, - "-i", - os.path.join(path, collection.head + "%04d" + collection.tail), - mov_path - ] + if os.path.isfile(audio_path): + args = [ + "ffmpeg", "-y", + "-i", audio_path, + "-i", + os.path.join(path, collection.head + "%04d" + collection.tail), + mov_path + ] + else: + args = [ + "ffmpeg", "-y", + "-i", + os.path.join(path, collection.head + "%04d" + collection.tail), + mov_path + ] + process = subprocess.Popen( args, stdout=subprocess.PIPE, diff --git a/pype/plugins/harmony/publish/validate_audio.py b/pype/plugins/harmony/publish/validate_audio.py new file mode 100644 index 0000000000..ba113e7610 --- /dev/null +++ b/pype/plugins/harmony/publish/validate_audio.py @@ -0,0 +1,37 @@ +import json +import os + +import pyblish.api + +import avalon.harmony +import pype.hosts.harmony + + +class ValidateAudio(pyblish.api.InstancePlugin): + """Ensures that there is an audio file in the scene. If you are sure that you want to send render without audio, you can disable this validator before clicking on "publish" """ + + order = pyblish.api.ValidatorOrder + label = "Validate Audio" + families = ["render"] + hosts = ["harmony"] + optional = True + + def process(self, instance): + # Collect scene data. + func = """function func(write_node) + { + return [ + sound.getSoundtrackAll().path() + ] + } + func + """ + result = avalon.harmony.send( + {"function": func, "args": [instance[0]]} + )["result"] + + audio_path = result[0] + + msg = "You are missing audio file:\n{}".format(audio_path) + + assert os.path.isfile(audio_path), msg diff --git a/pype/plugins/maya/load/load_image_plane.py b/pype/plugins/maya/load/load_image_plane.py index e95ea6cd8f..653a8d4128 100644 --- a/pype/plugins/maya/load/load_image_plane.py +++ b/pype/plugins/maya/load/load_image_plane.py @@ -50,8 +50,11 @@ class ImagePlaneLoader(api.Loader): camera = selection[0] - camera.displayResolution.set(1) - camera.farClipPlane.set(image_plane_depth * 10) + try: + camera.displayResolution.set(1) + camera.farClipPlane.set(image_plane_depth * 10) + except RuntimeError: + pass # Create image plane image_plane_transform, image_plane_shape = pc.imagePlane( diff --git a/pype/plugins/maya/publish/validate_transform_naming_suffix.py b/pype/plugins/maya/publish/validate_transform_naming_suffix.py index 17066f6b12..120123af4b 100644 --- a/pype/plugins/maya/publish/validate_transform_naming_suffix.py +++ b/pype/plugins/maya/publish/validate_transform_naming_suffix.py @@ -103,9 +103,7 @@ class ValidateTransformNamingSuffix(pyblish.api.InstancePlugin): instance (:class:`pyblish.api.Instance`): published instance. """ - invalid = self.get_invalid(instance, - self.SUFFIX_NAMING_TABLE, - self.ALLOW_IF_NOT_IN_SUFFIX_TABLE) + invalid = self.get_invalid(instance) if invalid: raise ValueError("Incorrectly named geometry " "transforms: {0}".format(invalid)) diff --git a/pype/plugins/nuke/publish/submit_nuke_deadline.py b/pype/plugins/nuke/publish/submit_nuke_deadline.py index 3731cd25f0..26d3f9b571 100644 --- a/pype/plugins/nuke/publish/submit_nuke_deadline.py +++ b/pype/plugins/nuke/publish/submit_nuke_deadline.py @@ -49,6 +49,24 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): render_path = instance.data['path'] script_path = context.data["currentFile"] + for item in context: + if "workfile" in item.data["families"]: + msg = "Workfile (scene) must be published along" + assert item.data["publish"] is True, msg + + template_data = item.data.get("anatomyData") + rep = item.data.get("representations")[0].get("name") + template_data["representation"] = rep + template_data["ext"] = rep + template_data["comment"] = None + anatomy_filled = context.data["anatomy"].format(template_data) + template_filled = anatomy_filled["publish"]["path"] + script_path = os.path.normpath(template_filled) + + self.log.info( + "Using published scene for render {}".format(script_path) + ) + # exception for slate workflow if "slate" in instance.data["families"]: self._frame_start -= 1 @@ -120,7 +138,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): chunk_size = self.deadline_chunk_size priority = instance.data.get("deadlinePriority") - if priority != 50: + if not priority: priority = self.deadline_priority payload = { diff --git a/pype/plugins/nuke/publish/validate_write_knobs.py b/pype/plugins/nuke/publish/validate_knobs.py similarity index 61% rename from pype/plugins/nuke/publish/validate_write_knobs.py rename to pype/plugins/nuke/publish/validate_knobs.py index 24572bedb3..22f0d344c9 100644 --- a/pype/plugins/nuke/publish/validate_write_knobs.py +++ b/pype/plugins/nuke/publish/validate_knobs.py @@ -4,14 +4,14 @@ import pyblish.api import pype.api -class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): +class ValidateKnobs(pyblish.api.ContextPlugin): """Ensure knobs are consistent. Knobs to validate and their values comes from the Example for presets in config: "presets/plugins/nuke/publish.json" preset, which needs this structure: - "ValidateNukeWriteKnobs": { + "ValidateKnobs": { "enabled": true, "knobs": { "family": { @@ -22,22 +22,31 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): """ order = pyblish.api.ValidatorOrder - label = "Validate Write Knobs" + label = "Validate Knobs" hosts = ["nuke"] actions = [pype.api.RepairContextAction] optional = True def process(self, context): - # Check for preset existence. - if not getattr(self, "knobs"): + nuke_presets = context.data["presets"].get("nuke") + + if not nuke_presets: + return + + publish_presets = nuke_presets.get("publish") + + if not publish_presets: + return + + plugin_preset = publish_presets.get("ValidateKnobs") + + if not plugin_preset: return - - self.log.debug("__ self.knobs: {}".format(self.knobs)) invalid = self.get_invalid(context, compute=True) if invalid: raise RuntimeError( - "Found knobs with invalid values: {}".format(invalid) + "Found knobs with invalid values:\n{}".format(invalid) ) @classmethod @@ -51,6 +60,8 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): @classmethod def get_invalid_knobs(cls, context): invalid_knobs = [] + publish_presets = context.data["presets"]["nuke"]["publish"] + knobs_preset = publish_presets["ValidateKnobs"]["knobs"] for instance in context: # Filter publisable instances. if not instance.data["publish"]: @@ -59,15 +70,15 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): # Filter families. families = [instance.data["family"]] families += instance.data.get("families", []) - families = list(set(families) & set(cls.knobs.keys())) + families = list(set(families) & set(knobs_preset.keys())) if not families: continue # Get all knobs to validate. knobs = {} for family in families: - for preset in cls.knobs[family]: - knobs.update({preset: cls.knobs[family][preset]}) + for preset in knobs_preset[family]: + knobs.update({preset: knobs_preset[family][preset]}) # Get invalid knobs. nodes = [] @@ -82,16 +93,20 @@ class ValidateNukeWriteKnobs(pyblish.api.ContextPlugin): for node in nodes: for knob in node.knobs(): - if knob in knobs.keys(): - expected = knobs[knob] - if node[knob].value() != expected: - invalid_knobs.append( - { - "knob": node[knob], - "expected": expected, - "current": node[knob].value() - } - ) + if knob not in knobs.keys(): + continue + + expected = knobs[knob] + if node[knob].value() != expected: + invalid_knobs.append( + { + "knob": node[knob], + "name": node[knob].name(), + "label": node[knob].label(), + "expected": expected, + "current": node[knob].value() + } + ) context.data["invalid_knobs"] = invalid_knobs return invalid_knobs diff --git a/pype/plugins/photoshop/create/create_image.py b/pype/plugins/photoshop/create/create_image.py index ff0a5dcb6c..5b2f9f7981 100644 --- a/pype/plugins/photoshop/create/create_image.py +++ b/pype/plugins/photoshop/create/create_image.py @@ -74,4 +74,5 @@ class CreateImage(api.Creator): groups.append(group) for group in groups: + self.data.update({"subset": "image" + group.Name}) photoshop.imprint(group, self.data) diff --git a/pype/plugins/photoshop/publish/collect_review.py b/pype/plugins/photoshop/publish/collect_review.py new file mode 100644 index 0000000000..30042d188b --- /dev/null +++ b/pype/plugins/photoshop/publish/collect_review.py @@ -0,0 +1,36 @@ +import os + +import pythoncom + +import pyblish.api + + +class CollectReview(pyblish.api.ContextPlugin): + """Gather the active document as review instance.""" + + label = "Review" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + + def process(self, context): + # Necessary call when running in a different thread which pyblish-qml + # can be. + pythoncom.CoInitialize() + + family = "review" + task = os.getenv("AVALON_TASK", None) + subset = family + task.capitalize() + + file_path = context.data["currentFile"] + base_name = os.path.basename(file_path) + + instance = context.create_instance(subset) + instance.data.update({ + "subset": subset, + "label": base_name, + "name": base_name, + "family": family, + "families": ["ftrack"], + "representations": [], + "asset": os.environ["AVALON_ASSET"] + }) diff --git a/pype/plugins/photoshop/publish/extract_review.py b/pype/plugins/photoshop/publish/extract_review.py new file mode 100644 index 0000000000..d784dc0998 --- /dev/null +++ b/pype/plugins/photoshop/publish/extract_review.py @@ -0,0 +1,105 @@ +import os + +import pype.api +import pype.lib +from avalon import photoshop + + +class ExtractReview(pype.api.Extractor): + """Produce a flattened image file from all instances.""" + + label = "Extract Review" + hosts = ["photoshop"] + families = ["review"] + + def process(self, instance): + + staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(staging_dir)) + + layers = [] + for image_instance in instance.context: + if image_instance.data["family"] != "image": + continue + layers.append(image_instance[0]) + + # Perform extraction + output_image = "{} copy.jpg".format( + os.path.splitext(photoshop.app().ActiveDocument.Name)[0] + ) + with photoshop.maintained_visibility(): + # Hide all other layers. + extract_ids = [ + x.id for x in photoshop.get_layers_in_layers(layers) + ] + for layer in photoshop.get_layers_in_document(): + if layer.id in extract_ids: + layer.Visible = True + else: + layer.Visible = False + + photoshop.app().ActiveDocument.SaveAs( + staging_dir, photoshop.com_objects.JPEGSaveOptions(), True + ) + + ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") + + instance.data["representations"].append({ + "name": "jpg", + "ext": "jpg", + "files": output_image, + "stagingDir": staging_dir + }) + instance.data["stagingDir"] = staging_dir + + # Generate thumbnail. + thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") + args = [ + ffmpeg_path, "-y", + "-i", os.path.join(staging_dir, output_image), + "-vf", "scale=300:-1", + "-vframes", "1", + thumbnail_path + ] + output = pype.lib._subprocess(args) + + self.log.debug(output) + + instance.data["representations"].append({ + "name": "thumbnail", + "ext": "jpg", + "files": os.path.basename(thumbnail_path), + "stagingDir": staging_dir, + "tags": ["thumbnail"] + }) + + # Generate mov. + mov_path = os.path.join(staging_dir, "review.mov") + args = [ + ffmpeg_path, "-y", + "-i", os.path.join(staging_dir, output_image), + "-vframes", "1", + mov_path + ] + output = pype.lib._subprocess(args) + + self.log.debug(output) + + instance.data["representations"].append({ + "name": "mov", + "ext": "mov", + "files": os.path.basename(mov_path), + "stagingDir": staging_dir, + "frameStart": 1, + "frameEnd": 1, + "fps": 25, + "preview": True, + "tags": ["review", "ftrackreview"] + }) + + # Required for extract_review plugin (L222 onwards). + instance.data["frameStart"] = 1 + instance.data["frameEnd"] = 1 + instance.data["fps"] = 25 + + self.log.info(f"Extracted {instance} to {staging_dir}") diff --git a/pype/plugins/photoshop/publish/validate_naming.py b/pype/plugins/photoshop/publish/validate_naming.py index 1d85ea99a0..51e00da352 100644 --- a/pype/plugins/photoshop/publish/validate_naming.py +++ b/pype/plugins/photoshop/publish/validate_naming.py @@ -1,5 +1,6 @@ import pyblish.api import pype.api +from avalon import photoshop class ValidateNamingRepair(pyblish.api.Action): @@ -22,7 +23,11 @@ class ValidateNamingRepair(pyblish.api.Action): instances = pyblish.api.instances_by_plugin(failed, plugin) for instance in instances: - instance[0].Name = instance.data["name"].replace(" ", "_") + name = instance.data["name"].replace(" ", "_") + instance[0].Name = name + data = photoshop.read(instance[0]) + data["subset"] = "image" + name + photoshop.imprint(instance[0], data) return True @@ -42,3 +47,6 @@ class ValidateNaming(pyblish.api.InstancePlugin): def process(self, instance): msg = "Name \"{}\" is not allowed.".format(instance.data["name"]) assert " " not in instance.data["name"], msg + + msg = "Subset \"{}\" is not allowed.".format(instance.data["subset"]) + assert " " not in instance.data["subset"], msg diff --git a/pype/plugins/premiere/publish/collect_frameranges.py b/pype/plugins/premiere/publish/collect_frameranges.py index ffcc1023b5..075f84e8e3 100644 --- a/pype/plugins/premiere/publish/collect_frameranges.py +++ b/pype/plugins/premiere/publish/collect_frameranges.py @@ -11,7 +11,7 @@ class CollectFrameranges(pyblish.api.InstancePlugin): """ label = "Collect Clip Frameranges" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder - 0.01 families = ['clip'] def process(self, instance): diff --git a/pype/plugins/premiere/publish/collect_instance_representations.py b/pype/plugins/premiere/publish/collect_instance_representations.py index f53c60ad64..b62b47c473 100644 --- a/pype/plugins/premiere/publish/collect_instance_representations.py +++ b/pype/plugins/premiere/publish/collect_instance_representations.py @@ -12,7 +12,7 @@ class CollectClipRepresentations(pyblish.api.InstancePlugin): """ label = "Collect Clip Representations" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder + 0.1 families = ['clip'] def process(self, instance): diff --git a/pype/plugins/premiere/publish/validate_auto_sync_off.py b/pype/plugins/premiere/publish/validate_auto_sync_off.py deleted file mode 100644 index b6429cfa05..0000000000 --- a/pype/plugins/premiere/publish/validate_auto_sync_off.py +++ /dev/null @@ -1,58 +0,0 @@ -import sys -import pyblish.api -import pype.api -import avalon.api -import six - - -class ValidateAutoSyncOff(pyblish.api.ContextPlugin): - """Ensure that autosync value in ftrack project is set to False. - - In case was set to True and event server with the sync to avalon event - is running will cause integration to avalon will be override. - - """ - - order = pyblish.api.ValidatorOrder - families = ['clip'] - label = 'Ftrack project\'s auto sync off' - actions = [pype.api.RepairAction] - - def process(self, context): - session = context.data["ftrackSession"] - project_name = avalon.api.Session["AVALON_PROJECT"] - query = 'Project where full_name is "{}"'.format(project_name) - project = session.query(query).one() - invalid = self.get_invalid(context) - - assert not invalid, ( - "Ftrack Project has 'Auto sync' set to On." - " That may cause issues during integration." - ) - - @staticmethod - def get_invalid(context): - session = context.data["ftrackSession"] - project_name = avalon.api.Session["AVALON_PROJECT"] - query = 'Project where full_name is "{}"'.format(project_name) - project = session.query(query).one() - - invalid = None - - if project.get('custom_attributes', {}).get( - 'avalon_auto_sync', False): - invalid = project - - return invalid - - @classmethod - def repair(cls, context): - session = context.data["ftrackSession"] - invalid = cls.get_invalid(context) - invalid['custom_attributes']['avalon_auto_sync'] = False - try: - session.commit() - except Exception: - tp, value, tb = sys.exc_info() - session.rollback() - six.reraise(tp, value, tb) diff --git a/pype/plugins/standalonepublisher/publish/collect_shots.py b/pype/plugins/standalonepublisher/publish/collect_shots.py index 853ba4e8de..4f682bd808 100644 --- a/pype/plugins/standalonepublisher/publish/collect_shots.py +++ b/pype/plugins/standalonepublisher/publish/collect_shots.py @@ -56,12 +56,18 @@ class CollectShots(pyblish.api.InstancePlugin): asset_entity = instance.context.data["assetEntity"] asset_name = asset_entity["name"] + # Ask user for sequence start. Usually 10:00:00:00. + sequence_start_frame = 900000 + # Project specific prefix naming. This needs to be replaced with some # options to be more flexible. asset_name = asset_name.split("_")[0] instances = [] for track in tracks: + track_start_frame = ( + abs(track.source_range.start_time.value) - sequence_start_frame + ) for child in track.each_child(): # Transitions are ignored, because Clips have the full frame @@ -69,12 +75,17 @@ class CollectShots(pyblish.api.InstancePlugin): if isinstance(child, otio.schema.transition.Transition): continue + if child.name is None: + continue + # Hardcoded to expect a shot name of "[name].[extension]" child_name = os.path.splitext(child.name)[0].lower() name = f"{asset_name}_{child_name}" - frame_start = child.range_in_parent().start_time.value - frame_end = child.range_in_parent().end_time_inclusive().value + frame_start = track_start_frame + frame_start += child.range_in_parent().start_time.value + frame_end = track_start_frame + frame_end += child.range_in_parent().end_time_inclusive().value label = f"{name} (framerange: {frame_start}-{frame_end})" instances.append( diff --git a/pype/resources/__init__.py b/pype/resources/__init__.py index 248614ae9d..ba882a84fb 100644 --- a/pype/resources/__init__.py +++ b/pype/resources/__init__.py @@ -14,3 +14,25 @@ def get_resource(*args): *args ) ) + + +def pype_icon_filepath(debug=None): + if debug is None: + debug = bool(os.getenv("PYPE_DEV")) + + if debug: + icon_file_name = "pype_icon_dev.png" + else: + icon_file_name = "pype_icon.png" + return get_resource("icons", icon_file_name) + + +def pype_splash_filepath(debug=None): + if debug is None: + debug = bool(os.getenv("PYPE_DEV")) + + if debug: + splash_file_name = "pype_splash_dev.png" + else: + splash_file_name = "pype_splash.png" + return get_resource("icons", splash_file_name) diff --git a/res/app_icons/Aport.png b/pype/resources/app_icons/Aport.png similarity index 100% rename from res/app_icons/Aport.png rename to pype/resources/app_icons/Aport.png diff --git a/res/app_icons/blender.png b/pype/resources/app_icons/blender.png similarity index 100% rename from res/app_icons/blender.png rename to pype/resources/app_icons/blender.png diff --git a/res/app_icons/celaction_local.png b/pype/resources/app_icons/celaction_local.png similarity index 100% rename from res/app_icons/celaction_local.png rename to pype/resources/app_icons/celaction_local.png diff --git a/res/app_icons/celaction_remotel.png b/pype/resources/app_icons/celaction_remotel.png similarity index 100% rename from res/app_icons/celaction_remotel.png rename to pype/resources/app_icons/celaction_remotel.png diff --git a/res/app_icons/clockify-white.png b/pype/resources/app_icons/clockify-white.png similarity index 100% rename from res/app_icons/clockify-white.png rename to pype/resources/app_icons/clockify-white.png diff --git a/res/app_icons/clockify.png b/pype/resources/app_icons/clockify.png similarity index 100% rename from res/app_icons/clockify.png rename to pype/resources/app_icons/clockify.png diff --git a/res/app_icons/djvView.png b/pype/resources/app_icons/djvView.png similarity index 100% rename from res/app_icons/djvView.png rename to pype/resources/app_icons/djvView.png diff --git a/res/app_icons/harmony.png b/pype/resources/app_icons/harmony.png similarity index 100% rename from res/app_icons/harmony.png rename to pype/resources/app_icons/harmony.png diff --git a/res/app_icons/houdini.png b/pype/resources/app_icons/houdini.png similarity index 100% rename from res/app_icons/houdini.png rename to pype/resources/app_icons/houdini.png diff --git a/res/app_icons/maya.png b/pype/resources/app_icons/maya.png similarity index 100% rename from res/app_icons/maya.png rename to pype/resources/app_icons/maya.png diff --git a/res/app_icons/nuke.png b/pype/resources/app_icons/nuke.png similarity index 100% rename from res/app_icons/nuke.png rename to pype/resources/app_icons/nuke.png diff --git a/res/app_icons/nukex.png b/pype/resources/app_icons/nukex.png similarity index 100% rename from res/app_icons/nukex.png rename to pype/resources/app_icons/nukex.png diff --git a/res/app_icons/photoshop.png b/pype/resources/app_icons/photoshop.png similarity index 100% rename from res/app_icons/photoshop.png rename to pype/resources/app_icons/photoshop.png diff --git a/res/app_icons/premiere.png b/pype/resources/app_icons/premiere.png similarity index 100% rename from res/app_icons/premiere.png rename to pype/resources/app_icons/premiere.png diff --git a/res/app_icons/python.png b/pype/resources/app_icons/python.png similarity index 100% rename from res/app_icons/python.png rename to pype/resources/app_icons/python.png diff --git a/res/app_icons/resolve.png b/pype/resources/app_icons/resolve.png similarity index 100% rename from res/app_icons/resolve.png rename to pype/resources/app_icons/resolve.png diff --git a/res/app_icons/storyboardpro.png b/pype/resources/app_icons/storyboardpro.png similarity index 100% rename from res/app_icons/storyboardpro.png rename to pype/resources/app_icons/storyboardpro.png diff --git a/res/app_icons/ue4.png b/pype/resources/app_icons/ue4.png similarity index 100% rename from res/app_icons/ue4.png rename to pype/resources/app_icons/ue4.png diff --git a/res/ftrack/action_icons/ActionAskWhereIRun.svg b/pype/resources/ftrack/action_icons/ActionAskWhereIRun.svg similarity index 100% rename from res/ftrack/action_icons/ActionAskWhereIRun.svg rename to pype/resources/ftrack/action_icons/ActionAskWhereIRun.svg diff --git a/res/ftrack/action_icons/AssetsRemover.svg b/pype/resources/ftrack/action_icons/AssetsRemover.svg similarity index 100% rename from res/ftrack/action_icons/AssetsRemover.svg rename to pype/resources/ftrack/action_icons/AssetsRemover.svg diff --git a/res/ftrack/action_icons/ComponentOpen.svg b/pype/resources/ftrack/action_icons/ComponentOpen.svg similarity index 100% rename from res/ftrack/action_icons/ComponentOpen.svg rename to pype/resources/ftrack/action_icons/ComponentOpen.svg diff --git a/res/ftrack/action_icons/CreateFolders.svg b/pype/resources/ftrack/action_icons/CreateFolders.svg similarity index 100% rename from res/ftrack/action_icons/CreateFolders.svg rename to pype/resources/ftrack/action_icons/CreateFolders.svg diff --git a/res/ftrack/action_icons/CreateProjectFolders.svg b/pype/resources/ftrack/action_icons/CreateProjectFolders.svg similarity index 100% rename from res/ftrack/action_icons/CreateProjectFolders.svg rename to pype/resources/ftrack/action_icons/CreateProjectFolders.svg diff --git a/res/ftrack/action_icons/DeleteAsset.svg b/pype/resources/ftrack/action_icons/DeleteAsset.svg similarity index 100% rename from res/ftrack/action_icons/DeleteAsset.svg rename to pype/resources/ftrack/action_icons/DeleteAsset.svg diff --git a/res/ftrack/action_icons/Delivery.svg b/pype/resources/ftrack/action_icons/Delivery.svg similarity index 100% rename from res/ftrack/action_icons/Delivery.svg rename to pype/resources/ftrack/action_icons/Delivery.svg diff --git a/res/ftrack/action_icons/MultipleNotes.svg b/pype/resources/ftrack/action_icons/MultipleNotes.svg similarity index 100% rename from res/ftrack/action_icons/MultipleNotes.svg rename to pype/resources/ftrack/action_icons/MultipleNotes.svg diff --git a/res/ftrack/action_icons/PrepareProject.svg b/pype/resources/ftrack/action_icons/PrepareProject.svg similarity index 100% rename from res/ftrack/action_icons/PrepareProject.svg rename to pype/resources/ftrack/action_icons/PrepareProject.svg diff --git a/res/ftrack/action_icons/PypeAdmin.svg b/pype/resources/ftrack/action_icons/PypeAdmin.svg similarity index 100% rename from res/ftrack/action_icons/PypeAdmin.svg rename to pype/resources/ftrack/action_icons/PypeAdmin.svg diff --git a/res/ftrack/action_icons/PypeDoctor.svg b/pype/resources/ftrack/action_icons/PypeDoctor.svg similarity index 100% rename from res/ftrack/action_icons/PypeDoctor.svg rename to pype/resources/ftrack/action_icons/PypeDoctor.svg diff --git a/res/ftrack/action_icons/PypeUpdate.svg b/pype/resources/ftrack/action_icons/PypeUpdate.svg similarity index 100% rename from res/ftrack/action_icons/PypeUpdate.svg rename to pype/resources/ftrack/action_icons/PypeUpdate.svg diff --git a/res/ftrack/action_icons/RV.png b/pype/resources/ftrack/action_icons/RV.png similarity index 100% rename from res/ftrack/action_icons/RV.png rename to pype/resources/ftrack/action_icons/RV.png diff --git a/res/ftrack/action_icons/SeedProject.svg b/pype/resources/ftrack/action_icons/SeedProject.svg similarity index 100% rename from res/ftrack/action_icons/SeedProject.svg rename to pype/resources/ftrack/action_icons/SeedProject.svg diff --git a/res/ftrack/action_icons/SyncHierarchicalAttrs.svg b/pype/resources/ftrack/action_icons/SyncHierarchicalAttrs.svg similarity index 100% rename from res/ftrack/action_icons/SyncHierarchicalAttrs.svg rename to pype/resources/ftrack/action_icons/SyncHierarchicalAttrs.svg diff --git a/res/ftrack/action_icons/SyncToAvalon.svg b/pype/resources/ftrack/action_icons/SyncToAvalon.svg similarity index 100% rename from res/ftrack/action_icons/SyncToAvalon.svg rename to pype/resources/ftrack/action_icons/SyncToAvalon.svg diff --git a/res/ftrack/action_icons/TestAction.svg b/pype/resources/ftrack/action_icons/TestAction.svg similarity index 100% rename from res/ftrack/action_icons/TestAction.svg rename to pype/resources/ftrack/action_icons/TestAction.svg diff --git a/res/ftrack/action_icons/Thumbnail.svg b/pype/resources/ftrack/action_icons/Thumbnail.svg similarity index 100% rename from res/ftrack/action_icons/Thumbnail.svg rename to pype/resources/ftrack/action_icons/Thumbnail.svg diff --git a/res/ftrack/sign_in_message.html b/pype/resources/ftrack/sign_in_message.html similarity index 100% rename from res/ftrack/sign_in_message.html rename to pype/resources/ftrack/sign_in_message.html diff --git a/pype/resources/circle_green.png b/pype/resources/icons/circle_green.png similarity index 100% rename from pype/resources/circle_green.png rename to pype/resources/icons/circle_green.png diff --git a/pype/resources/circle_orange.png b/pype/resources/icons/circle_orange.png similarity index 100% rename from pype/resources/circle_orange.png rename to pype/resources/icons/circle_orange.png diff --git a/pype/resources/circle_red.png b/pype/resources/icons/circle_red.png similarity index 100% rename from pype/resources/circle_red.png rename to pype/resources/icons/circle_red.png diff --git a/res/icons/folder-favorite.png b/pype/resources/icons/folder-favorite.png similarity index 100% rename from res/icons/folder-favorite.png rename to pype/resources/icons/folder-favorite.png diff --git a/res/icons/folder-favorite2.png b/pype/resources/icons/folder-favorite2.png similarity index 100% rename from res/icons/folder-favorite2.png rename to pype/resources/icons/folder-favorite2.png diff --git a/res/icons/folder-favorite3.png b/pype/resources/icons/folder-favorite3.png similarity index 100% rename from res/icons/folder-favorite3.png rename to pype/resources/icons/folder-favorite3.png diff --git a/res/icons/inventory.png b/pype/resources/icons/inventory.png similarity index 100% rename from res/icons/inventory.png rename to pype/resources/icons/inventory.png diff --git a/res/icons/loader.png b/pype/resources/icons/loader.png similarity index 100% rename from res/icons/loader.png rename to pype/resources/icons/loader.png diff --git a/res/icons/lookmanager.png b/pype/resources/icons/lookmanager.png similarity index 100% rename from res/icons/lookmanager.png rename to pype/resources/icons/lookmanager.png diff --git a/pype/resources/icon.png b/pype/resources/icons/pype_icon.png similarity index 100% rename from pype/resources/icon.png rename to pype/resources/icons/pype_icon.png diff --git a/pype/resources/icon_dev.png b/pype/resources/icons/pype_icon_dev.png similarity index 100% rename from pype/resources/icon_dev.png rename to pype/resources/icons/pype_icon_dev.png diff --git a/pype/resources/splash.png b/pype/resources/icons/pype_splash.png similarity index 100% rename from pype/resources/splash.png rename to pype/resources/icons/pype_splash.png diff --git a/pype/resources/splash_dev.png b/pype/resources/icons/pype_splash_dev.png similarity index 100% rename from pype/resources/splash_dev.png rename to pype/resources/icons/pype_splash_dev.png diff --git a/res/icons/workfiles.png b/pype/resources/icons/workfiles.png similarity index 100% rename from res/icons/workfiles.png rename to pype/resources/icons/workfiles.png diff --git a/pype/resources/working.svg b/pype/resources/icons/working.svg similarity index 100% rename from pype/resources/working.svg rename to pype/resources/icons/working.svg diff --git a/res/workspace.mel b/pype/resources/maya/workspace.mel similarity index 100% rename from res/workspace.mel rename to pype/resources/maya/workspace.mel diff --git a/pype/tools/pyblish_pype/constants.py b/pype/tools/pyblish_pype/constants.py index 5395d1fd0a..03536fb829 100644 --- a/pype/tools/pyblish_pype/constants.py +++ b/pype/tools/pyblish_pype/constants.py @@ -1,5 +1,7 @@ from Qt import QtCore +EXPANDER_WIDTH = 20 + def flags(*args, **kwargs): type_name = kwargs.pop("type_name", "Flags") diff --git a/pype/tools/pyblish_pype/control.py b/pype/tools/pyblish_pype/control.py index 5138b5cc4c..77badf71b6 100644 --- a/pype/tools/pyblish_pype/control.py +++ b/pype/tools/pyblish_pype/control.py @@ -183,7 +183,18 @@ class Controller(QtCore.QObject): plugins = pyblish.api.discover() targets = pyblish.logic.registered_targets() or ["default"] - self.plugins = pyblish.logic.plugins_by_targets(plugins, targets) + plugins_by_targets = pyblish.logic.plugins_by_targets(plugins, targets) + + _plugins = [] + for plugin in plugins_by_targets: + # Skip plugin if is not optional and not active + if ( + not getattr(plugin, "optional", False) + and not getattr(plugin, "active", True) + ): + continue + _plugins.append(plugin) + self.plugins = _plugins def on_published(self): if self.is_running: diff --git a/pype/tools/pyblish_pype/delegate.py b/pype/tools/pyblish_pype/delegate.py index e88835b81a..cb9123bf3a 100644 --- a/pype/tools/pyblish_pype/delegate.py +++ b/pype/tools/pyblish_pype/delegate.py @@ -5,7 +5,7 @@ from Qt import QtWidgets, QtGui, QtCore from . import model from .awesome import tags as awesome from .constants import ( - PluginStates, InstanceStates, PluginActionStates, Roles + PluginStates, InstanceStates, PluginActionStates, Roles, EXPANDER_WIDTH ) colors = { @@ -14,12 +14,16 @@ colors = { "ok": QtGui.QColor("#77AE24"), "active": QtGui.QColor("#99CEEE"), "idle": QtCore.Qt.white, - "font": QtGui.QColor("#DDD"), "inactive": QtGui.QColor("#888"), "hover": QtGui.QColor(255, 255, 255, 10), "selected": QtGui.QColor(255, 255, 255, 20), "outline": QtGui.QColor("#333"), - "group": QtGui.QColor("#333") + "group": QtGui.QColor("#333"), + "group-hover": QtGui.QColor("#3c3c3c"), + "group-selected-hover": QtGui.QColor("#555555"), + "expander-bg": QtGui.QColor("#222"), + "expander-hover": QtGui.QColor("#2d6c9f"), + "expander-selected-hover": QtGui.QColor("#3784c5") } scale_factors = {"darwin": 1.5} @@ -279,14 +283,169 @@ class InstanceItemDelegate(QtWidgets.QStyledItemDelegate): return QtCore.QSize(option.rect.width(), 20) -class OverviewGroupSection(QtWidgets.QStyledItemDelegate): - """Generic delegate for section header""" +class InstanceDelegate(QtWidgets.QStyledItemDelegate): + """Generic delegate for instance header""" - item_class = None + radius = 8.0 def __init__(self, parent): - super(OverviewGroupSection, self).__init__(parent) - self.item_delegate = self.item_class(parent) + super(InstanceDelegate, self).__init__(parent) + self.item_delegate = InstanceItemDelegate(parent) + + def paint(self, painter, option, index): + if index.data(Roles.TypeRole) in ( + model.InstanceType, model.PluginType + ): + self.item_delegate.paint(painter, option, index) + return + + self.group_item_paint(painter, option, index) + + def group_item_paint(self, painter, option, index): + """Paint text + _ + My label + """ + body_rect = QtCore.QRectF(option.rect) + bg_rect = QtCore.QRectF( + body_rect.left(), body_rect.top() + 1, + body_rect.width() - 5, body_rect.height() - 2 + ) + + expander_rect = QtCore.QRectF(bg_rect) + expander_rect.setWidth(EXPANDER_WIDTH) + + remainder_rect = QtCore.QRectF( + expander_rect.x() + expander_rect.width(), + expander_rect.y(), + bg_rect.width() - expander_rect.width(), + expander_rect.height() + ) + + width = float(expander_rect.width()) + height = float(expander_rect.height()) + + x_pos = expander_rect.x() + y_pos = expander_rect.y() + + x_radius = min(self.radius, width / 2) + y_radius = min(self.radius, height / 2) + x_radius2 = x_radius * 2 + y_radius2 = y_radius * 2 + + expander_path = QtGui.QPainterPath() + expander_path.moveTo(x_pos, y_pos + y_radius) + expander_path.arcTo( + x_pos, y_pos, + x_radius2, y_radius2, + 180.0, -90.0 + ) + expander_path.lineTo(x_pos + width, y_pos) + expander_path.lineTo(x_pos + width, y_pos + height) + expander_path.lineTo(x_pos + x_radius, y_pos + height) + expander_path.arcTo( + x_pos, y_pos + height - y_radius2, + x_radius2, y_radius2, + 270.0, -90.0 + ) + expander_path.closeSubpath() + + width = float(remainder_rect.width()) + height = float(remainder_rect.height()) + x_pos = remainder_rect.x() + y_pos = remainder_rect.y() + + x_radius = min(self.radius, width / 2) + y_radius = min(self.radius, height / 2) + x_radius2 = x_radius * 2 + y_radius2 = y_radius * 2 + + remainder_path = QtGui.QPainterPath() + remainder_path.moveTo(x_pos + width, y_pos + height - y_radius) + remainder_path.arcTo( + x_pos + width - x_radius2, y_pos + height - y_radius2, + x_radius2, y_radius2, + 0.0, -90.0 + ) + remainder_path.lineTo(x_pos, y_pos + height) + remainder_path.lineTo(x_pos, y_pos) + remainder_path.lineTo(x_pos + width - x_radius, y_pos) + remainder_path.arcTo( + x_pos + width - x_radius2, y_pos, + x_radius2, y_radius2, + 90.0, -90.0 + ) + remainder_path.closeSubpath() + + painter.fillPath(expander_path, colors["expander-bg"]) + painter.fillPath(remainder_path, colors["group"]) + + mouse_pos = option.widget.mapFromGlobal(QtGui.QCursor.pos()) + selected = option.state & QtWidgets.QStyle.State_Selected + hovered = option.state & QtWidgets.QStyle.State_MouseOver + + if selected and hovered: + if expander_rect.contains(mouse_pos): + painter.fillPath( + expander_path, colors["expander-selected-hover"] + ) + else: + painter.fillPath( + remainder_path, colors["group-selected-hover"] + ) + + elif hovered: + if expander_rect.contains(mouse_pos): + painter.fillPath(expander_path, colors["expander-hover"]) + else: + painter.fillPath(remainder_path, colors["group-hover"]) + + text_height = font_metrics["awesome6"].height() + adjust_value = (expander_rect.height() - text_height) / 2 + expander_rect.adjust( + adjust_value + 1.5, adjust_value - 0.5, + -adjust_value + 1.5, -adjust_value - 0.5 + ) + + offset = (remainder_rect.height() - font_metrics["h5"].height()) / 2 + label_rect = QtCore.QRectF(remainder_rect.adjusted( + 5, offset - 1, 0, 0 + )) + + expander_icon = icons["plus-sign"] + + expanded = self.parent().isExpanded(index) + if expanded: + expander_icon = icons["minus-sign"] + label = index.data(QtCore.Qt.DisplayRole) + label = font_metrics["h5"].elidedText( + label, QtCore.Qt.ElideRight, label_rect.width() + ) + + # Maintain reference to state, so we can restore it once we're done + painter.save() + + painter.setFont(fonts["awesome6"]) + painter.setPen(QtGui.QPen(colors["idle"])) + painter.drawText(expander_rect, QtCore.Qt.AlignCenter, expander_icon) + + # Draw label + painter.setFont(fonts["h5"]) + painter.drawText(label_rect, label) + + # Ok, we're done, tidy up. + painter.restore() + + def sizeHint(self, option, index): + return QtCore.QSize(option.rect.width(), 20) + + +class PluginDelegate(QtWidgets.QStyledItemDelegate): + """Generic delegate for plugin header""" + + def __init__(self, parent): + super(PluginDelegate, self).__init__(parent) + self.item_delegate = PluginItemDelegate(parent) def paint(self, painter, option, index): if index.data(Roles.TypeRole) in ( @@ -310,7 +469,14 @@ class OverviewGroupSection(QtWidgets.QStyledItemDelegate): radius = 8.0 bg_path = QtGui.QPainterPath() bg_path.addRoundedRect(bg_rect, radius, radius) - painter.fillPath(bg_path, colors["group"]) + hovered = option.state & QtWidgets.QStyle.State_MouseOver + selected = option.state & QtWidgets.QStyle.State_Selected + if hovered and selected: + painter.fillPath(bg_path, colors["group-selected-hover"]) + elif hovered: + painter.fillPath(bg_path, colors["group-hover"]) + else: + painter.fillPath(bg_path, colors["group"]) expander_rect = QtCore.QRectF(bg_rect) expander_rect.setWidth(expander_rect.height()) @@ -343,18 +509,12 @@ class OverviewGroupSection(QtWidgets.QStyledItemDelegate): painter.setFont(fonts["awesome6"]) painter.setPen(QtGui.QPen(colors["idle"])) - painter.drawText(expander_rect, expander_icon) + painter.drawText(expander_rect, QtCore.Qt.AlignCenter, expander_icon) # Draw label painter.setFont(fonts["h5"]) painter.drawText(label_rect, label) - if option.state & QtWidgets.QStyle.State_MouseOver: - painter.fillPath(bg_path, colors["hover"]) - - if option.state & QtWidgets.QStyle.State_Selected: - painter.fillPath(bg_path, colors["selected"]) - # Ok, we're done, tidy up. painter.restore() @@ -362,16 +522,6 @@ class OverviewGroupSection(QtWidgets.QStyledItemDelegate): return QtCore.QSize(option.rect.width(), 20) -class PluginDelegate(OverviewGroupSection): - """Generic delegate for model items in proxy tree view""" - item_class = PluginItemDelegate - - -class InstanceDelegate(OverviewGroupSection): - """Generic delegate for model items in proxy tree view""" - item_class = InstanceItemDelegate - - class ArtistDelegate(QtWidgets.QStyledItemDelegate): """Delegate used on Artist page""" diff --git a/pype/tools/pyblish_pype/model.py b/pype/tools/pyblish_pype/model.py index 203b512d12..9086003258 100644 --- a/pype/tools/pyblish_pype/model.py +++ b/pype/tools/pyblish_pype/model.py @@ -319,7 +319,7 @@ class PluginItem(QtGui.QStandardItem): return False self.plugin.active = value self.emitDataChanged() - return True + return elif role == Roles.PluginActionProgressRole: if isinstance(value, list): @@ -652,14 +652,14 @@ class InstanceItem(QtGui.QStandardItem): def setData(self, value, role=(QtCore.Qt.UserRole + 1)): if role == QtCore.Qt.CheckStateRole: if not self.data(Roles.IsEnabledRole): - return False + return self.instance.data["publish"] = value self.emitDataChanged() - return True + return if role == Roles.IsEnabledRole: if not self.instance.optional: - return False + return if role == Roles.PublishFlagsRole: if isinstance(value, list): @@ -692,12 +692,12 @@ class InstanceItem(QtGui.QStandardItem): self.instance._publish_states = value self.emitDataChanged() - return True + return if role == Roles.LogRecordsRole: self.instance._logs = value self.emitDataChanged() - return True + return return super(InstanceItem, self).setData(value, role) diff --git a/pype/tools/pyblish_pype/view.py b/pype/tools/pyblish_pype/view.py index 450f56421c..477303eae8 100644 --- a/pype/tools/pyblish_pype/view.py +++ b/pype/tools/pyblish_pype/view.py @@ -1,6 +1,6 @@ from Qt import QtCore, QtWidgets from . import model -from .constants import Roles +from .constants import Roles, EXPANDER_WIDTH # Imported when used widgets = None @@ -84,8 +84,6 @@ class OverviewView(QtWidgets.QTreeView): self.setRootIsDecorated(False) self.setIndentation(0) - self.clicked.connect(self.item_expand) - def event(self, event): if not event.type() == QtCore.QEvent.KeyPress: return super(OverviewView, self).event(event) @@ -113,6 +111,24 @@ class OverviewView(QtWidgets.QTreeView): def focusOutEvent(self, event): self.selectionModel().clear() + def mouseReleaseEvent(self, event): + if event.button() in (QtCore.Qt.LeftButton, QtCore.Qt.RightButton): + # Deselect all group labels + indexes = self.selectionModel().selectedIndexes() + for index in indexes: + if index.data(Roles.TypeRole) == model.GroupType: + self.selectionModel().select( + index, QtCore.QItemSelectionModel.Deselect + ) + + return super(OverviewView, self).mouseReleaseEvent(event) + + +class PluginView(OverviewView): + def __init__(self, *args, **kwargs): + super(PluginView, self).__init__(*args, **kwargs) + self.clicked.connect(self.item_expand) + def item_expand(self, index): if index.data(Roles.TypeRole) == model.GroupType: if self.isExpanded(index): @@ -125,23 +141,86 @@ class OverviewView(QtWidgets.QTreeView): indexes = self.selectionModel().selectedIndexes() if len(indexes) == 1: index = indexes[0] - # If instance or Plugin - if index.data(Roles.TypeRole) in ( - model.InstanceType, model.PluginType + pos_index = self.indexAt(event.pos()) + # If instance or Plugin and is selected + if ( + index == pos_index + and index.data(Roles.TypeRole) == model.PluginType ): if event.pos().x() < 20: self.toggled.emit(index, None) elif event.pos().x() > self.width() - 20: self.show_perspective.emit(index) - # Deselect all group labels - for index in indexes: - if index.data(Roles.TypeRole) == model.GroupType: - self.selectionModel().select( - index, QtCore.QItemSelectionModel.Deselect - ) + return super(PluginView, self).mouseReleaseEvent(event) - return super(OverviewView, self).mouseReleaseEvent(event) + +class InstanceView(OverviewView): + def __init__(self, parent=None): + super(InstanceView, self).__init__(parent) + self.viewport().setMouseTracking(True) + + def mouseMoveEvent(self, event): + index = self.indexAt(event.pos()) + if index.data(Roles.TypeRole) == model.GroupType: + self.update(index) + super(InstanceView, self).mouseMoveEvent(event) + + def item_expand(self, index, expand=None): + if expand is None: + expand = not self.isExpanded(index) + + if expand: + self.expand(index) + else: + self.collapse(index) + + def group_toggle(self, index): + model = index.model() + + chilren_indexes_checked = [] + chilren_indexes_unchecked = [] + for idx in range(model.rowCount(index)): + child_index = model.index(idx, 0, index) + if not child_index.data(Roles.IsEnabledRole): + continue + + if child_index.data(QtCore.Qt.CheckStateRole): + chilren_indexes_checked.append(child_index) + else: + chilren_indexes_unchecked.append(child_index) + + if chilren_indexes_checked: + to_change_indexes = chilren_indexes_checked + new_state = False + else: + to_change_indexes = chilren_indexes_unchecked + new_state = True + + for index in to_change_indexes: + model.setData(index, new_state, QtCore.Qt.CheckStateRole) + self.toggled.emit(index, new_state) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + indexes = self.selectionModel().selectedIndexes() + if len(indexes) == 1: + index = indexes[0] + pos_index = self.indexAt(event.pos()) + if index == pos_index: + # If instance or Plugin + if index.data(Roles.TypeRole) == model.InstanceType: + if event.pos().x() < 20: + self.toggled.emit(index, None) + elif event.pos().x() > self.width() - 20: + self.show_perspective.emit(index) + else: + if event.pos().x() < EXPANDER_WIDTH: + self.item_expand(index) + else: + self.group_toggle(index) + self.item_expand(index, True) + return super(InstanceView, self).mouseReleaseEvent(event) class TerminalView(QtWidgets.QTreeView): diff --git a/pype/tools/pyblish_pype/window.py b/pype/tools/pyblish_pype/window.py index 3c7808496c..7d79e0e26c 100644 --- a/pype/tools/pyblish_pype/window.py +++ b/pype/tools/pyblish_pype/window.py @@ -160,14 +160,14 @@ class Window(QtWidgets.QDialog): # TODO add parent overview_page = QtWidgets.QWidget() - overview_instance_view = view.OverviewView(parent=overview_page) + overview_instance_view = view.InstanceView(parent=overview_page) overview_instance_delegate = delegate.InstanceDelegate( parent=overview_instance_view ) overview_instance_view.setItemDelegate(overview_instance_delegate) overview_instance_view.setModel(instance_model) - overview_plugin_view = view.OverviewView(parent=overview_page) + overview_plugin_view = view.PluginView(parent=overview_page) overview_plugin_delegate = delegate.PluginDelegate( parent=overview_plugin_view ) diff --git a/pype/tools/tray/modules_imports.json b/pype/tools/tray/modules_imports.json new file mode 100644 index 0000000000..e9526dcddb --- /dev/null +++ b/pype/tools/tray/modules_imports.json @@ -0,0 +1,58 @@ +[ + { + "title": "User settings", + "type": "module", + "import_path": "pype.modules.user", + "fromlist": ["pype", "modules"] + }, { + "title": "Ftrack", + "type": "module", + "import_path": "pype.modules.ftrack.tray", + "fromlist": ["pype", "modules", "ftrack"] + }, { + "title": "Muster", + "type": "module", + "import_path": "pype.modules.muster", + "fromlist": ["pype", "modules"] + }, { + "title": "Avalon", + "type": "module", + "import_path": "pype.modules.avalon_apps", + "fromlist": ["pype", "modules"] + }, { + "title": "Clockify", + "type": "module", + "import_path": "pype.modules.clockify", + "fromlist": ["pype", "modules"] + }, { + "title": "Standalone Publish", + "type": "module", + "import_path": "pype.modules.standalonepublish", + "fromlist": ["pype", "modules"] + }, { + "title": "Logging", + "type": "module", + "import_path": "pype.modules.logging.tray", + "fromlist": ["pype", "modules", "logging"] + }, { + "title": "Idle Manager", + "type": "module", + "import_path": "pype.modules.idle_manager", + "fromlist": ["pype","modules"] + }, { + "title": "Timers Manager", + "type": "module", + "import_path": "pype.modules.timers_manager", + "fromlist": ["pype","modules"] + }, { + "title": "Rest Api", + "type": "module", + "import_path": "pype.modules.rest_api", + "fromlist": ["pype","modules"] + }, { + "title": "Adobe Communicator", + "type": "module", + "import_path": "pype.modules.adobe_communicator", + "fromlist": ["pype", "modules"] + } +] diff --git a/pype/tools/tray/pype_tray.py b/pype/tools/tray/pype_tray.py index eec8f61cc4..9537b62581 100644 --- a/pype/tools/tray/pype_tray.py +++ b/pype/tools/tray/pype_tray.py @@ -3,8 +3,12 @@ import sys import platform from avalon import style from Qt import QtCore, QtGui, QtWidgets, QtSvg -from pype.resources import get_resource -from pype.api import config, Logger +from pype.api import config, Logger, resources +import pype.version +try: + import configparser +except Exception: + import ConfigParser as configparser class TrayManager: @@ -12,28 +16,43 @@ class TrayManager: Load submenus, actions, separators and modules into tray's context. """ - modules = {} - services = {} - services_submenu = None - - errors = [] - items = ( - config.get_presets(first_run=True) - .get('tray', {}) - .get('menu_items', []) - ) - available_sourcetypes = ['python', 'file'] + available_sourcetypes = ["python", "file"] def __init__(self, tray_widget, main_window): self.tray_widget = tray_widget self.main_window = main_window + self.log = Logger().get_logger(self.__class__.__name__) - self.icon_run = QtGui.QIcon(get_resource('circle_green.png')) - self.icon_stay = QtGui.QIcon(get_resource('circle_orange.png')) - self.icon_failed = QtGui.QIcon(get_resource('circle_red.png')) + self.modules = {} + self.services = {} + self.services_submenu = None - self.services_thread = None + self.errors = [] + + CURRENT_DIR = os.path.dirname(__file__) + self.modules_imports = config.load_json( + os.path.join(CURRENT_DIR, "modules_imports.json") + ) + presets = config.get_presets(first_run=True) + menu_items = presets["tray"]["menu_items"] + try: + self.modules_usage = menu_items["item_usage"] + except Exception: + self.modules_usage = {} + self.log.critical("Couldn't find modules usage data.") + + self.module_attributes = menu_items.get("attributes") or {} + + self.icon_run = QtGui.QIcon( + resources.get_resource("icons", "circle_green.png") + ) + self.icon_stay = QtGui.QIcon( + resources.get_resource("icons", "circle_orange.png") + ) + self.icon_failed = QtGui.QIcon( + resources.get_resource("icons", "circle_red.png") + ) def process_presets(self): """Add modules to tray by presets. @@ -46,42 +65,34 @@ class TrayManager: "item_usage": { "Statics Server": false } - }, { - "item_import": [{ - "title": "Ftrack", - "type": "module", - "import_path": "pype.ftrack.tray", - "fromlist": ["pype", "ftrack"] - }, { - "title": "Statics Server", - "type": "module", - "import_path": "pype.services.statics_server", - "fromlist": ["pype","services"] - }] } In this case `Statics Server` won't be used. """ - # Backwards compatible presets loading - if isinstance(self.items, list): - items = self.items - else: - items = [] - # Get booleans is module should be used - usages = self.items.get("item_usage") or {} - for item in self.items.get("item_import", []): - import_path = item.get("import_path") - title = item.get("title") - item_usage = usages.get(title) - if item_usage is None: - item_usage = usages.get(import_path, True) + items = [] + # Get booleans is module should be used + for item in self.modules_imports: + import_path = item.get("import_path") + title = item.get("title") - if item_usage: - items.append(item) - else: - if not title: - title = import_path - self.log.debug("{} - Module ignored".format(title)) + item_usage = self.modules_usage.get(title) + if item_usage is None: + item_usage = self.modules_usage.get(import_path, True) + + if not item_usage: + if not title: + title = import_path + self.log.info("{} - Module ignored".format(title)) + continue + + _attributes = self.module_attributes.get(title) + if _attributes is None: + _attributes = self.module_attributes.get(import_path) + + if _attributes: + item["attributes"] = _attributes + + items.append(item) if items: self.process_items(items, self.tray_widget.menu) @@ -94,6 +105,8 @@ class TrayManager: if items and self.services_submenu is not None: self.add_separator(self.tray_widget.menu) + self._add_version_item() + # Add Exit action to menu aExit = QtWidgets.QAction("&Exit", self.tray_widget) aExit.triggered.connect(self.tray_widget.exit) @@ -103,6 +116,34 @@ class TrayManager: self.connect_modules() self.start_modules() + def _add_version_item(self): + config_file_path = os.path.join( + os.environ["PYPE_SETUP_PATH"], "pypeapp", "config.ini" + ) + + default_config = {} + if os.path.exists(config_file_path): + config = configparser.ConfigParser() + config.read(config_file_path) + try: + default_config = config["CLIENT"] + except Exception: + pass + + subversion = default_config.get("subversion") + client_name = default_config.get("client_name") + + version_string = pype.version.__version__ + if subversion: + version_string += " ({})".format(subversion) + + if client_name: + version_string += ", {}".format(client_name) + + version_action = QtWidgets.QAction(version_string, self.tray_widget) + self.tray_widget.menu.addAction(version_action) + self.add_separator(self.tray_widget.menu) + def process_items(self, items, parent_menu): """ Loop through items and add them to parent_menu. @@ -158,11 +199,29 @@ class TrayManager: import_path = item.get('import_path', None) title = item.get('title', import_path) fromlist = item.get('fromlist', []) + attributes = item.get("attributes", {}) try: module = __import__( "{}".format(import_path), fromlist=fromlist ) + klass = getattr(module, "CLASS_DEFINIION", None) + if not klass and attributes: + self.log.error(( + "There are defined attributes for module \"{}\" but" + "module does not have defined \"CLASS_DEFINIION\"." + ).format(import_path)) + + elif klass and attributes: + for key, value in attributes.items(): + if hasattr(klass, key): + setattr(klass, key, value) + else: + self.log.error(( + "Module \"{}\" does not have attribute \"{}\"." + " Check your settings please." + ).format(import_path, key)) + obj = module.tray_init(self.tray_widget, self.main_window) name = obj.__class__.__name__ if hasattr(obj, 'tray_menu'): @@ -179,7 +238,7 @@ class TrayManager: obj.set_qaction(action, self.icon_failed) self.modules[name] = obj self.log.info("{} - Module imported".format(title)) - except ImportError as ie: + except Exception as exc: if self.services_submenu is None: self.services_submenu = QtWidgets.QMenu( 'Services', self.tray_widget.menu @@ -188,7 +247,7 @@ class TrayManager: action.setIcon(self.icon_failed) self.services_submenu.addAction(action) self.log.warning( - "{} - Module import Error: {}".format(title, str(ie)), + "{} - Module import Error: {}".format(title, str(exc)), exc_info=True ) return False @@ -333,12 +392,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): :type parent: QtWidgets.QMainWindow """ def __init__(self, parent): - if os.getenv("PYPE_DEV"): - icon_file_name = "icon_dev.png" - else: - icon_file_name = "icon.png" - - self.icon = QtGui.QIcon(get_resource(icon_file_name)) + self.icon = QtGui.QIcon(resources.pype_icon_filepath()) QtWidgets.QSystemTrayIcon.__init__(self, self.icon, parent) @@ -402,7 +456,7 @@ class TrayMainWindow(QtWidgets.QMainWindow): self.trayIcon.show() def set_working_widget(self): - image_file = get_resource('working.svg') + image_file = resources.get_resource("icons", "working.svg") img_pix = QtGui.QPixmap(image_file) if image_file.endswith('.svg'): widget = QtSvg.QSvgWidget(image_file) @@ -492,11 +546,7 @@ class PypeTrayApplication(QtWidgets.QApplication): splash_widget.hide() def set_splash(self): - if os.getenv("PYPE_DEV"): - splash_file_name = "splash_dev.png" - else: - splash_file_name = "splash.png" - splash_pix = QtGui.QPixmap(get_resource(splash_file_name)) + splash_pix = QtGui.QPixmap(resources.pype_splash_filepath()) splash = QtWidgets.QSplashScreen(splash_pix) splash.setMask(splash_pix.mask()) splash.setEnabled(False) diff --git a/pype/version.py b/pype/version.py index 1c622223ba..7f6646a762 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.10.0" +__version__ = "2.11.0" diff --git a/pype/widgets/message_window.py b/pype/widgets/message_window.py index 3532d2df44..41c709b933 100644 --- a/pype/widgets/message_window.py +++ b/pype/widgets/message_window.py @@ -52,6 +52,19 @@ def message(title=None, message=None, level="info", parent=None): app = parent if not app: app = QtWidgets.QApplication(sys.argv) + ex = Window(app, title, message, level) ex.show() + + # Move widget to center of screen + try: + desktop_rect = QtWidgets.QApplication.desktop().availableGeometry(ex) + center = desktop_rect.center() + ex.move( + center.x() - (ex.width() * 0.5), + center.y() - (ex.height() * 0.5) + ) + except Exception: + # skip all possible issues that may happen feature is not crutial + log.warning("Couldn't center message.", exc_info=True) # sys.exit(app.exec_()) diff --git a/res/icons/Thumbs.db b/res/icons/Thumbs.db deleted file mode 100644 index fa56c871f6..0000000000 Binary files a/res/icons/Thumbs.db and /dev/null differ