diff --git a/README.md b/README.md index 7cf8c4c0b6..fe0ad70a36 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,20 @@ -The base studio *config* for [Avalon](https://getavalon.github.io/) +he base studio _config_ for [Avalon](https://getavalon.github.io/) Currently this config is dependent on our customised avalon instalation so it won't work with vanilla avalon core. We're working on open sourcing all of the necessary code though. You can still get inspiration or take our individual validators and scripts which should work just fine in other pipelines. - _This configuration acts as a starting point for all pype club clients wth avalon deployment._ - - ### Code convention Below are some of the standard practices applied to this repositories. -- **Etiquette: PEP8** - - All code is written in PEP8. It is recommended you use a linter as you work, flake8 and pylinter are both good options. -- **Etiquette: Napoleon docstrings** - - Any docstrings are made in Google Napoleon format. See [Napoleon](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) for details. -- **Etiquette: Semantic Versioning** - - This project follows [semantic versioning](http://semver.org). -- **Etiquette: Underscore means private** - - Anything prefixed with an underscore means that it is internal to wherever it is used. For example, a variable name is only ever used in the parent function or class. A module is not for use by the end-user. In contrast, anything without an underscore is public, but not necessarily part of the API. Members of the API resides in `api.py`. -- **API: Idempotence** - - A public function must be able to be called twice and produce the exact same result. This means no changing of state without restoring previous state when finishing. For example, if a function requires changing the current selection in Autodesk Maya, it must restore the previous selection prior to completing. +- **Etiquette: PEP8** + \- All code is written in PEP8. It is recommended you use a linter as you work, flake8 and pylinter are both good options. +- **Etiquette: Napoleon docstrings** + \- Any docstrings are made in Google Napoleon format. See [Napoleon](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) for details. +- **Etiquette: Semantic Versioning** + \- This project follows [semantic versioning](http://semver.org). +- **Etiquette: Underscore means private** + \- Anything prefixed with an underscore means that it is internal to wherever it is used. For example, a variable name is only ever used in the parent function or class. A module is not for use by the end-user. In contrast, anything without an underscore is public, but not necessarily part of the API. Members of the API resides in `api.py`. +- **API: Idempotence** + \- A public function must be able to be called twice and produce the exact same result. This means no changing of state without restoring previous state when finishing. For example, if a function requires changing the current selection in Autodesk Maya, it must restore the previous selection prior to completing. diff --git a/pype/__init__.py b/pype/__init__.py index 8bd31c060d..751faef320 100644 --- a/pype/__init__.py +++ b/pype/__init__.py @@ -9,10 +9,9 @@ from .lib import collect_container_metadata import logging log = logging.getLogger(__name__) -# do not delete these are mandatory +# # do not delete these are mandatory Anatomy = None Dataflow = None -Metadata = None Colorspace = None PACKAGE_DIR = os.path.dirname(__file__) diff --git a/pype/api.py b/pype/api.py index 747ad425f8..0acb80e383 100644 --- a/pype/api.py +++ b/pype/api.py @@ -15,12 +15,11 @@ from .action import ( RepairContextAction ) -from app.api import Logger +from pypeapp import Logger from . import ( Anatomy, Colorspace, - Metadata, Dataflow ) @@ -47,8 +46,6 @@ from .lib import ( get_data_hierarchical_attr ) -from .widgets.message_window import message - __all__ = [ # plugin classes "Extractor", @@ -88,10 +85,6 @@ __all__ = [ # preloaded templates "Anatomy", "Colorspace", - "Metadata", "Dataflow", - # QtWidgets - "message" - ] diff --git a/pype/aport/__init__.py b/pype/aport/__init__.py index 4efcb731c7..9e1bde0a15 100644 --- a/pype/aport/__init__.py +++ b/pype/aport/__init__.py @@ -3,11 +3,11 @@ import sys from avalon import api as avalon from pyblish import api as pyblish -from app import api as app +from pypeapp import execute, Logger from .. import api -log = api.Logger.getLogger(__name__, "aport") +log = Logger().get_logger(__name__, "aport") AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") @@ -83,7 +83,7 @@ def pico_server_launch(): "api" ] - app.forward( + execute( args, cwd=path ) diff --git a/pype/aport/api.py b/pype/aport/api.py index 4d202b6e7a..bac3e235df 100644 --- a/pype/aport/api.py +++ b/pype/aport/api.py @@ -15,11 +15,11 @@ from avalon import io import pyblish.api as pyblish -from app.api import forward +from pypeapp import execute from pype import api as pype -log = pype.Logger.getLogger(__name__, "aport") +log = pype.Logger().get_logger(__name__, "aport") SESSION = avalon.session @@ -56,7 +56,7 @@ def publish(json_data_path, gui): log.info("avalon.session is: \n{}".format(SESSION)) - pype_start = os.path.join(os.getenv('PYPE_SETUP_ROOT'), + pype_start = os.path.join(os.getenv('PYPE_ROOT'), "app", "pype-start.py") publish = "--publish-gui" if gui else "--publish" @@ -70,7 +70,7 @@ def publish(json_data_path, gui): log.debug(args) # start standalone pyblish qml - forward([ + execute([ sys.executable, "-u" ] + args, cwd=cwd diff --git a/pype/aport/original/api.py b/pype/aport/original/api.py index bc2a71a08c..b1fffed1dc 100644 --- a/pype/aport/original/api.py +++ b/pype/aport/original/api.py @@ -15,11 +15,11 @@ from avalon import io import pyblish.api as pyblish -from app.api import forward +from pypeapp import execute from pype import api as pype -log = pype.Logger.getLogger(__name__, "aport") +log = pype.Logger().get_logger(__name__, "aport") SESSION = avalon.session @@ -56,7 +56,7 @@ def publish(json_data_path, staging_dir=None): return_json_path = os.path.join(staging_dir, "return_data.json") log.info("avalon.session is: \n{}".format(SESSION)) - pype_start = os.path.join(os.getenv('PYPE_SETUP_ROOT'), + pype_start = os.path.join(os.getenv('PYPE_ROOT'), "app", "pype-start.py") args = [pype_start, "--publish", @@ -68,7 +68,7 @@ def publish(json_data_path, staging_dir=None): log.debug(args) # start standalone pyblish qml - forward([ + execute([ sys.executable, "-u" ] + args, cwd=cwd @@ -239,8 +239,8 @@ app.register_module(__name__) # remove all Handlers created by pico for name, handler in [(handler.get_name(), handler) - for handler in pype.Logger.logging.root.handlers[:]]: + for handler in Logger().logging.root.handlers[:]]: if "pype" not in str(name).lower(): print(name) print(handler) - pype.Logger.logging.root.removeHandler(handler) + Logger().logging.root.removeHandler(handler) diff --git a/pype/aport/original/pipeline.py b/pype/aport/original/pipeline.py index dee41aff34..1bfd9a8d1e 100644 --- a/pype/aport/original/pipeline.py +++ b/pype/aport/original/pipeline.py @@ -11,7 +11,7 @@ from avalon import io import pyblish.api as pyblish -from app.api import forward +from pypeapp import execute from pype import api as pype # remove all Handlers created by pico @@ -20,7 +20,7 @@ for name, handler in [(handler.get_name(), handler) if "pype" not in str(name).lower(): pype.Logger.logging.root.removeHandler(handler) -log = pype.Logger.getLogger(__name__, "aport") +log = pype.Logger().get_logger(__name__, "aport") SESSION = avalon.session @@ -55,7 +55,7 @@ def publish(json_data_path, staging_dir=None): return_json_path = os.path.join(staging_dir, "return_data.json") log.debug("avalon.session is: \n{}".format(SESSION)) - pype_start = os.path.join(os.getenv('PYPE_SETUP_ROOT'), + pype_start = os.path.join(os.getenv('PYPE_ROOT'), "app", "pype-start.py") args = [pype_start, "--publish", @@ -67,7 +67,7 @@ def publish(json_data_path, staging_dir=None): log.debug(args) # start standalone pyblish qml - forward([ + execute([ sys.executable, "-u" ] + args, cwd=os.getenv('AVALON_WORKDIR').replace("\\", "/") diff --git a/pype/aport/original/templates.py b/pype/aport/original/templates.py index 5be6e276ba..2db1d58004 100644 --- a/pype/aport/original/templates.py +++ b/pype/aport/original/templates.py @@ -1,6 +1,6 @@ from pype import api as pype -log = pype.Logger.getLogger(__name__, "aport") +log = pype.Logger().get_logger(__name__, "aport") def get_anatomy(**kwarg): diff --git a/pype/avalon_apps/__init__.py b/pype/avalon_apps/__init__.py new file mode 100644 index 0000000000..845f94a330 --- /dev/null +++ b/pype/avalon_apps/__init__.py @@ -0,0 +1,5 @@ +from .avalon_app import AvalonApps + + +def tray_init(tray_widget, main_widget): + return AvalonApps(main_widget, tray_widget) diff --git a/pype/avalon_apps/avalon_app.py b/pype/avalon_apps/avalon_app.py new file mode 100644 index 0000000000..547ecd2299 --- /dev/null +++ b/pype/avalon_apps/avalon_app.py @@ -0,0 +1,55 @@ +import os +import argparse +from Qt import QtGui, QtWidgets +from avalon.tools import libraryloader +from pypeapp import Logger +from avalon import io +from launcher import launcher_widget, lib as launcher_lib + + +class AvalonApps: + def __init__(self, main_parent=None, parent=None): + self.log = Logger().get_logger(__name__) + self.main_parent = main_parent + self.parent = parent + self.app_launcher = None + + # Definition of Tray menu + def tray_menu(self, parent_menu=None): + # Actions + if parent_menu is None: + if self.parent is None: + self.log.warning('Parent menu is not set') + return + elif self.parent.hasattr('menu'): + parent_menu = self.parent.menu + else: + self.log.warning('Parent menu is not set') + return + + icon = QtGui.QIcon(launcher_lib.resource("icon", "main.png")) + aShowLauncher = QtWidgets.QAction(icon, "&Launcher", parent_menu) + aLibraryLoader = QtWidgets.QAction("Library", parent_menu) + + aShowLauncher.triggered.connect(self.show_launcher) + aLibraryLoader.triggered.connect(self.show_library_loader) + + parent_menu.addAction(aShowLauncher) + parent_menu.addAction(aLibraryLoader) + + def show_launcher(self): + # if app_launcher don't exist create it/otherwise only show main window + if self.app_launcher is None: + root = os.path.realpath(os.environ["AVALON_PROJECTS"]) + io.install() + APP_PATH = launcher_lib.resource("qml", "main.qml") + self.app_launcher = launcher_widget.Launcher(root, APP_PATH) + self.app_launcher.window.show() + + def show_library_loader(self): + libraryloader.show( + parent=self.main_parent, + icon=self.parent.icon, + show_projects=True, + show_libraries=True + ) diff --git a/pype/clockify/clockify.py b/pype/clockify/clockify.py index a22933f700..0b84bf3953 100644 --- a/pype/clockify/clockify.py +++ b/pype/clockify/clockify.py @@ -1,6 +1,6 @@ import threading -from app import style -from app.vendor.Qt import QtWidgets +from pypeapp import style +from Qt import QtWidgets from pype.clockify import ClockifySettings, ClockifyAPI @@ -35,6 +35,28 @@ class ClockifyModule: self.set_menu_visibility() + def process_modules(self, modules): + if 'FtrackModule' in modules: + actions_path = os.path.sep.join([ + os.path.dirname(__file__), + 'ftrack_actions' + ]) + current = os.environ('FTRACK_ACTIONS_PATH', '') + if current: + current += os.pathsep + os.environ['FTRACK_ACTIONS_PATH'] = current + actions_path + + if 'AvalonApps' in modules: + from launcher import lib + actions_path = os.path.sep.join([ + os.path.dirname(__file__), + 'launcher_actions' + ]) + current = os.environ.get('AVALON_ACTIONS', '') + if current: + current += os.pathsep + os.environ['AVALON_ACTIONS'] = current + actions_path + def start_timer_check(self): self.bool_thread_check_running = True if self.thread_timer_check is None: diff --git a/pype/ftrack/actions/action_clockify_start.py b/pype/clockify/ftrack_actions/action_clockify_start.py similarity index 95% rename from pype/ftrack/actions/action_clockify_start.py rename to pype/clockify/ftrack_actions/action_clockify_start.py index b1c60a2525..e09d0b76e6 100644 --- a/pype/ftrack/actions/action_clockify_start.py +++ b/pype/clockify/ftrack_actions/action_clockify_start.py @@ -1,8 +1,9 @@ +import os import sys import argparse import logging -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction from pype.clockify import ClockifyAPI @@ -17,7 +18,9 @@ class StartClockify(BaseAction): #: Action description. description = 'Starts timer on clockify' #: roles that are allowed to register this action - icon = 'https://clockify.me/assets/images/clockify-logo.png' + icon = '{}/app_icons/clockify.png'.format( + os.environ.get('PYPE_STATICS_SERVER', '') + ) #: Clockify api clockapi = ClockifyAPI() diff --git a/pype/ftrack/actions/action_clockify_sync.py b/pype/clockify/ftrack_actions/action_clockify_sync.py similarity index 96% rename from pype/ftrack/actions/action_clockify_sync.py rename to pype/clockify/ftrack_actions/action_clockify_sync.py index 202bb7b912..695f7581c0 100644 --- a/pype/ftrack/actions/action_clockify_sync.py +++ b/pype/clockify/ftrack_actions/action_clockify_sync.py @@ -1,8 +1,9 @@ +import os import sys import argparse import logging import json -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction, MissingPermision from pype.clockify import ClockifyAPI @@ -21,7 +22,9 @@ class SyncClocify(BaseAction): #: roles that are allowed to register this action role_list = ['Pypeclub', 'Administrator'] #: icon - icon = 'https://clockify.me/assets/images/clockify-logo-white.svg' + icon = '{}/app_icons/clockify-white.png'.format( + os.environ.get('PYPE_STATICS_SERVER', '') + ) #: CLockifyApi clockapi = ClockifyAPI() diff --git a/pype/plugins/launcher/actions/ClockifyStart.py b/pype/clockify/launcher_actions/ClockifyStart.py similarity index 82% rename from pype/plugins/launcher/actions/ClockifyStart.py rename to pype/clockify/launcher_actions/ClockifyStart.py index 78a8b4e1b6..6a9ceaec73 100644 --- a/pype/plugins/launcher/actions/ClockifyStart.py +++ b/pype/clockify/launcher_actions/ClockifyStart.py @@ -1,11 +1,9 @@ from avalon import api, io from pype.api import Logger -try: - from pype.clockify import ClockifyAPI -except Exception: - pass +from pype.clockify import ClockifyAPI -log = Logger.getLogger(__name__, "clockify_start") + +log = Logger().get_logger(__name__, "clockify_start") class ClockifyStart(api.Action): @@ -14,13 +12,10 @@ class ClockifyStart(api.Action): label = "Clockify - Start Timer" icon = "clockify_icon" order = 500 - - exec("try: clockapi = ClockifyAPI()\nexcept: clockapi = None") + clockapi = ClockifyAPI() def is_compatible(self, session): """Return whether the action is compatible with the session""" - if self.clockapi is None: - return False if "AVALON_TASK" in session: return True return False diff --git a/pype/plugins/launcher/actions/ClockifySync.py b/pype/clockify/launcher_actions/ClockifySync.py similarity index 84% rename from pype/plugins/launcher/actions/ClockifySync.py rename to pype/clockify/launcher_actions/ClockifySync.py index c50fbc4b25..3bf389796f 100644 --- a/pype/plugins/launcher/actions/ClockifySync.py +++ b/pype/clockify/launcher_actions/ClockifySync.py @@ -1,10 +1,7 @@ from avalon import api, io -try: - from pype.clockify import ClockifyAPI -except Exception: - pass +from pype.clockify import ClockifyAPI from pype.api import Logger -log = Logger.getLogger(__name__, "clockify_sync") +log = Logger().get_logger(__name__, "clockify_sync") class ClockifySync(api.Action): @@ -13,16 +10,11 @@ class ClockifySync(api.Action): label = "Sync to Clockify" icon = "clockify_white_icon" order = 500 - exec( - "try:\n\tclockapi = ClockifyAPI()" - "\n\thave_permissions = clockapi.validate_workspace_perm()" - "\nexcept:\n\tclockapi = None" - ) + clockapi = ClockifyAPI() + have_permissions = clockapi.validate_workspace_perm() def is_compatible(self, session): """Return whether the action is compatible with the session""" - if self.clockapi is None: - return False return self.have_permissions def process(self, session, **kwargs): diff --git a/pype/clockify/widget_settings.py b/pype/clockify/widget_settings.py index 02fd4350e6..7142548fa6 100644 --- a/pype/clockify/widget_settings.py +++ b/pype/clockify/widget_settings.py @@ -1,6 +1,6 @@ import os -from app.vendor.Qt import QtCore, QtGui, QtWidgets -from app import style +from Qt import QtCore, QtGui, QtWidgets +from pypeapp import style class ClockifySettings(QtWidgets.QWidget): @@ -26,7 +26,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_ROOT') + pype_setup = os.getenv('PYPE_ROOT') items = [pype_setup, "app", "resources", "icon.png"] fname = os.path.sep.join(items) icon = QtGui.QIcon(fname) diff --git a/pype/ftrack/__init__.py b/pype/ftrack/__init__.py index bf18979e91..922de28e16 100644 --- a/pype/ftrack/__init__.py +++ b/pype/ftrack/__init__.py @@ -1,2 +1,7 @@ from .lib import * from .ftrack_server import * +from .ftrack_module import FtrackModule + + +def tray_init(tray_widget, main_widget): + return FtrackModule(main_widget, tray_widget) diff --git a/pype/ftrack/actions/action_application_loader.py b/pype/ftrack/actions/action_application_loader.py index 105aa064ca..1b0f48f9be 100644 --- a/pype/ftrack/actions/action_application_loader.py +++ b/pype/ftrack/actions/action_application_loader.py @@ -1,11 +1,12 @@ +import os import toml import time from pype.ftrack import AppAction from avalon import lib -from app.api import Logger +from pypeapp import Logger from pype import lib as pypelib -log = Logger.getLogger(__name__) +log = Logger().get_logger(__name__) def registerApp(app, session): @@ -37,6 +38,9 @@ def registerApp(app, session): description = apptoml.get('description', None) preactions = apptoml.get('preactions', []) + if icon: + icon = icon.format(os.environ.get('PYPE_STATICS_SERVER', '')) + # register action AppAction( session, label, name, executable, variant, @@ -65,4 +69,4 @@ def register(session): time.sleep(0.1) app_counter += 1 except Exception as e: - log.warning("'{0}' - not proper App ({1})".format(app['name'], e)) + log.exception("'{0}' - not proper App ({1})".format(app['name'], e)) diff --git a/pype/ftrack/actions/action_asset_delete.py b/pype/ftrack/actions/action_asset_delete.py index c47c8ac4ac..684b3862a8 100644 --- a/pype/ftrack/actions/action_asset_delete.py +++ b/pype/ftrack/actions/action_asset_delete.py @@ -1,7 +1,7 @@ import sys import argparse import logging -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction diff --git a/pype/ftrack/actions/action_client_review_sort.py b/pype/ftrack/actions/action_client_review_sort.py index 1e2f37ec74..b06a928007 100644 --- a/pype/ftrack/actions/action_client_review_sort.py +++ b/pype/ftrack/actions/action_client_review_sort.py @@ -2,7 +2,7 @@ import sys import argparse import logging -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction diff --git a/pype/ftrack/actions/action_component_open.py b/pype/ftrack/actions/action_component_open.py index 579d8ebe85..d3213c555a 100644 --- a/pype/ftrack/actions/action_component_open.py +++ b/pype/ftrack/actions/action_component_open.py @@ -1,9 +1,9 @@ +import os import sys import argparse import logging import subprocess -import os -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction @@ -15,9 +15,8 @@ class ComponentOpen(BaseAction): # Action label label = 'Open File' # Action icon - icon = ( - 'https://cdn4.iconfinder.com/data/icons/rcons-application/32/' - 'application_go_run-256.png' + icon = '{}/ftrack/action_icons/ComponentOpen.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) def discover(self, session, entities, event): diff --git a/pype/ftrack/actions/action_create_cust_attrs.py b/pype/ftrack/actions/action_create_cust_attrs.py index fb57221ccd..7dd8335ecc 100644 --- a/pype/ftrack/actions/action_create_cust_attrs.py +++ b/pype/ftrack/actions/action_create_cust_attrs.py @@ -2,10 +2,11 @@ import os import sys import argparse import json -import ftrack_api import arrow import logging +from pype.vendor import ftrack_api from pype.ftrack import BaseAction, get_ca_mongoid +from pypeapp import config """ This action creates/updates custom attributes. @@ -113,20 +114,13 @@ class CustomAttributes(BaseAction): description = 'Creates Avalon/Mongo ID for double check' #: roles that are allowed to register this action role_list = ['Pypeclub', 'Administrator'] - icon = ( - 'https://cdn4.iconfinder.com/data/icons/' - 'ios-web-user-interface-multi-circle-flat-vol-4/512/' - 'Bullet_list_menu_lines_points_items_options-512.png' + icon = '{}/ftrack/action_icons/CustomAttributes.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) def __init__(self, session): super().__init__(session) - templates = os.environ['PYPE_STUDIO_TEMPLATES'] - path_items = [ - templates, 'presets', 'ftrack', 'ftrack_custom_attributes.json' - ] - self.filepath = os.path.sep.join(path_items) self.types = {} self.object_type_ids = {} self.groups = {} @@ -230,22 +224,12 @@ class CustomAttributes(BaseAction): self.process_attribute(data) def custom_attributes_from_file(self, session, event): - try: - with open(self.filepath) as data_file: - json_dict = json.load(data_file) - except Exception as e: - msg = ( - 'Loading "Custom attribute file" Failed.' - ' Please check log for more information' - ) - self.log.warning("{} - {}".format(msg, str(e))) - self.show_message(event, msg) - return + presets = config.get_presets()['ftrack']['ftrack_custom_attributes'] - for cust_attr_name in json_dict: + for cust_attr_name in presets: try: data = {} - cust_attr = json_dict[cust_attr_name] + cust_attr = presets[cust_attr_name] # Get key, label, type data.update(self.get_required(cust_attr)) # Get hierachical/ entity_type/ object_id diff --git a/pype/ftrack/actions/action_create_folders.py b/pype/ftrack/actions/action_create_folders.py index f7620cd609..2a777911b4 100644 --- a/pype/ftrack/actions/action_create_folders.py +++ b/pype/ftrack/actions/action_create_folders.py @@ -3,13 +3,12 @@ import sys import logging import argparse import re -import json -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction -from pype import api as pype, lib as pypelib from avalon import lib as avalonlib from avalon.tools.libraryloader.io_nonsingleton import DbConnector +from pypeapp import config, Anatomy class CreateFolders(BaseAction): @@ -23,10 +22,10 @@ class CreateFolders(BaseAction): label = 'Create Folders' #: Action Icon. - icon = ( - 'https://cdn1.iconfinder.com/data/icons/hawcons/32/' - '698620-icon-105-folder-add-512.png' + icon = '{}/ftrack/action_icons/CreateFolders.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) + db = DbConnector() def discover(self, session, entities, event): @@ -130,12 +129,12 @@ class CreateFolders(BaseAction): template_publish = av_project['config']['template']['publish'] self.db.uninstall() except Exception: - anatomy = pype.Anatomy - template_work = anatomy.avalon.work - template_publish = anatomy.avalon.publish + templates = Anatomy().templates + template_work = templates["avalon"]["work"] + template_publish = templates["avalon"]["publish"] collected_paths = [] - presets = self.get_presets() + presets = config.get_presets()['tools']['sw_folders'] for entity in all_entities: if entity.entity_type.lower() == 'project': continue @@ -238,17 +237,6 @@ class CreateFolders(BaseAction): output.extend(self.get_notask_children(child)) return output - def get_presets(self): - fpath_items = [pypelib.get_presets_path(), 'tools', 'sw_folders.json'] - filepath = os.path.normpath(os.path.sep.join(fpath_items)) - presets = dict() - try: - with open(filepath) as data_file: - presets = json.load(data_file) - except Exception as e: - self.log.warning('Wasn\'t able to load presets') - return dict(presets) - def template_format(self, template, data): partial_data = PartialDict(data) diff --git a/pype/ftrack/actions/action_create_project_folders.py b/pype/ftrack/actions/action_create_project_folders.py index 15bd18cb5f..3ccdb08714 100644 --- a/pype/ftrack/actions/action_create_project_folders.py +++ b/pype/ftrack/actions/action_create_project_folders.py @@ -3,11 +3,10 @@ import sys import re import argparse import logging -import json -import ftrack_api -from pype import lib as pypelib +from pype.vendor import ftrack_api from pype.ftrack import BaseAction +from pypeapp import config class CreateProjectFolders(BaseAction): @@ -21,9 +20,8 @@ class CreateProjectFolders(BaseAction): description = 'Creates folder structure' #: roles that are allowed to register this action role_list = ['Pypeclub', 'Administrator'] - icon = ( - 'https://cdn2.iconfinder.com/data/icons/' - 'buttons-9/512/Button_Add-01.png' + icon = '{}/ftrack/action_icons/CreateProjectFolders.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) pattern_array = re.compile('\[.*\]') @@ -43,7 +41,7 @@ class CreateProjectFolders(BaseAction): else: project = entity['project'] - presets = self.load_presets() + presets = config.get_presets()['tools']['project_folder_structure'] try: # Get paths based on presets basic_paths = self.get_path_items(presets) @@ -143,28 +141,6 @@ class CreateProjectFolders(BaseAction): self.session.commit() return new_ent - def load_presets(self): - preset_items = [ - pypelib.get_presets_path(), - 'tools', - 'project_folder_structure.json' - ] - filepath = os.path.sep.join(preset_items) - - # Load folder structure template from presets - presets = dict() - try: - with open(filepath) as data_file: - presets = json.load(data_file) - except Exception as e: - msg = 'Unable to load Folder structure preset' - self.log.warning(msg) - return { - 'success': False, - 'message': msg - } - return presets - def get_path_items(self, in_dict): output = [] for key, value in in_dict.items(): diff --git a/pype/ftrack/actions/action_delete_asset.py b/pype/ftrack/actions/action_delete_asset.py index 7a4c15e9fb..96087f4c8e 100644 --- a/pype/ftrack/actions/action_delete_asset.py +++ b/pype/ftrack/actions/action_delete_asset.py @@ -1,8 +1,9 @@ +import os import sys import logging from bson.objectid import ObjectId import argparse -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction from avalon.tools.libraryloader.io_nonsingleton import DbConnector @@ -16,10 +17,8 @@ class DeleteAsset(BaseAction): label = 'Delete Asset/Subsets' #: Action description. description = 'Removes from Avalon with all childs and asset from Ftrack' - icon = ( - 'https://cdn4.iconfinder.com/data/icons/' - 'ios-web-user-interface-multi-circle-flat-vol-5/512/' - 'Delete_dustbin_empty_recycle_recycling_remove_trash-512.png' + icon = '{}/ftrack/action_icons/DeleteAsset.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) #: roles that are allowed to register this action role_list = ['Pypeclub', 'Administrator'] @@ -86,6 +85,12 @@ class DeleteAsset(BaseAction): 'type': 'asset', 'name': entity['name'] }) + + if av_entity is None: + return { + 'success': False, + 'message': 'Didn\'t found assets in avalon' + } asset_label = { 'type': 'label', diff --git a/pype/ftrack/actions/action_delete_asset_byname.py b/pype/ftrack/actions/action_delete_asset_byname.py index ee6f875ad3..fa966096a8 100644 --- a/pype/ftrack/actions/action_delete_asset_byname.py +++ b/pype/ftrack/actions/action_delete_asset_byname.py @@ -1,7 +1,8 @@ +import os import sys import logging import argparse -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction from avalon.tools.libraryloader.io_nonsingleton import DbConnector @@ -17,10 +18,8 @@ class AssetsRemover(BaseAction): description = 'Removes assets from Ftrack and Avalon db with all childs' #: roles that are allowed to register this action role_list = ['Pypeclub', 'Administrator'] - icon = ( - 'https://cdn4.iconfinder.com/data/icons/' - 'ios-web-user-interface-multi-circle-flat-vol-5/512/' - 'Clipboard_copy_delete_minus_paste_remove-512.png' + icon = '{}/ftrack/action_icons/AssetsRemover.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) #: Db db = DbConnector() diff --git a/pype/ftrack/actions/action_delete_unpublished.py b/pype/ftrack/actions/action_delete_unpublished.py index 018a70b423..377e118ffb 100644 --- a/pype/ftrack/actions/action_delete_unpublished.py +++ b/pype/ftrack/actions/action_delete_unpublished.py @@ -1,7 +1,7 @@ import sys import argparse import logging -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction diff --git a/pype/ftrack/actions/action_djvview.py b/pype/ftrack/actions/action_djvview.py index 631a686921..e0c0334e5f 100644 --- a/pype/ftrack/actions/action_djvview.py +++ b/pype/ftrack/actions/action_djvview.py @@ -1,17 +1,14 @@ import os import sys -import re import json import logging import subprocess from operator import itemgetter -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction -from app.api import Logger -from pype import lib as pypelib +from pypeapp import Logger, config - -log = Logger.getLogger(__name__) +log = Logger().get_logger(__name__) class DJVViewAction(BaseAction): @@ -19,16 +16,17 @@ class DJVViewAction(BaseAction): identifier = "djvview-launch-action" label = "DJV View" description = "DJV View Launcher" - icon = "http://a.fsdn.com/allura/p/djv/icon" + icon = '{}/app_icons/djvView.png'.format( + os.environ.get('PYPE_STATICS_SERVER', '') + ) type = 'Application' def __init__(self, session): '''Expects a ftrack_api.Session instance''' super().__init__(session) self.djv_path = None - self.config_data = None - self.load_config_data() + self.config_data = config.get_presets()['djv_view']['config'] self.set_djv_path() if self.djv_path is None: @@ -56,21 +54,6 @@ class DJVViewAction(BaseAction): return True return False - def load_config_data(self): - path_items = [pypelib.get_presets_path(), 'djv_view', 'config.json'] - filepath = os.path.sep.join(path_items) - - data = dict() - try: - with open(filepath) as data_file: - data = json.load(data_file) - except Exception as e: - log.warning( - 'Failed to load data from DJV presets file ({})'.format(e) - ) - - self.config_data = data - def set_djv_path(self): for path in self.config_data.get("djv_paths", []): if os.path.exists(path): @@ -234,6 +217,7 @@ class DJVViewAction(BaseAction): return True + def register(session): """Register hooks.""" if not isinstance(session, ftrack_api.session.Session): diff --git a/pype/ftrack/actions/action_job_killer.py b/pype/ftrack/actions/action_job_killer.py index 5a1311f82f..44acb24d55 100644 --- a/pype/ftrack/actions/action_job_killer.py +++ b/pype/ftrack/actions/action_job_killer.py @@ -1,9 +1,10 @@ +import os import sys import argparse import logging import json -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction @@ -18,9 +19,8 @@ class JobKiller(BaseAction): description = 'Killing selected running jobs' #: roles that are allowed to register this action role_list = ['Pypeclub', 'Administrator'] - icon = ( - 'https://cdn2.iconfinder.com/data/icons/new-year-resolutions/64/' - 'resolutions-23-512.png' + icon = '{}/ftrack/action_icons/JobKiller.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) def discover(self, session, entities, event): @@ -46,10 +46,7 @@ class JobKiller(BaseAction): desctiption = data['description'] except Exception: desctiption = '*No description*' - try: - user = job['user']['username'] - except Exception: - user = '*No user' + user = job['user']['username'] created = job['created_at'].strftime('%d.%m.%Y %H:%M:%S') label = '{} - {} - {}'.format( desctiption, created, user diff --git a/pype/ftrack/actions/action_multiple_notes.py b/pype/ftrack/actions/action_multiple_notes.py index c61f5b1e9c..338083fe47 100644 --- a/pype/ftrack/actions/action_multiple_notes.py +++ b/pype/ftrack/actions/action_multiple_notes.py @@ -2,8 +2,7 @@ import os import sys import argparse import logging -import json -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction @@ -17,9 +16,8 @@ class MultipleNotes(BaseAction): label = 'Multiple Notes' #: Action description. description = 'Add same note to multiple Asset Versions' - icon = ( - 'https://cdn2.iconfinder.com/data/icons/' - 'mixed-rounded-flat-icon/512/note_1-512.png' + icon = '{}/ftrack/action_icons/MultipleNotes.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) def discover(self, session, entities, event): diff --git a/pype/ftrack/actions/action_rv.py b/pype/ftrack/actions/action_rv.py index d5ff83e8c8..3832dffae4 100644 --- a/pype/ftrack/actions/action_rv.py +++ b/pype/ftrack/actions/action_rv.py @@ -3,14 +3,13 @@ import os import sys import json import subprocess -import ftrack_api +from pype.vendor import ftrack_api import logging import operator import re -from pype import lib as pypelib -from app.api import Logger +from pypeapp import Logger, config -log = Logger.getLogger(__name__) +log = Logger().get_logger(__name__) class RVAction(BaseAction): @@ -18,7 +17,9 @@ class RVAction(BaseAction): identifier = "rv.launch.action" label = "rv" description = "rv Launcher" - icon = "https://img.icons8.com/color/48/000000/circled-play.png" + icon = '{}/ftrack/action_icons/RV.png'.format( + os.environ.get('PYPE_STATICS_SERVER', '') + ) type = 'Application' def __init__(self, session): @@ -40,7 +41,7 @@ class RVAction(BaseAction): ) else: # if not, fallback to config file location - self.load_config_data() + self.config_data = config.get_presets()['djv_view']['config'] self.set_rv_path() if self.rv_path is None: @@ -61,21 +62,6 @@ class RVAction(BaseAction): return True return False - def load_config_data(self): - path_items = [pypelib.get_presets_path(), 'rv', 'config.json'] - filepath = os.path.sep.join(path_items) - - data = dict() - try: - with open(filepath) as data_file: - data = json.load(data_file) - except Exception as e: - log.warning( - 'Failed to load data from RV presets file ({})'.format(e) - ) - - self.config_data = data - def set_rv_path(self): self.rv_path = self.config_data.get("rv_path") diff --git a/pype/ftrack/actions/action_set_version.py b/pype/ftrack/actions/action_set_version.py index 3954733041..f6e745b3ec 100644 --- a/pype/ftrack/actions/action_set_version.py +++ b/pype/ftrack/actions/action_set_version.py @@ -1,7 +1,7 @@ import sys import argparse import logging -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction diff --git a/pype/ftrack/actions/action_start_timer.py b/pype/ftrack/actions/action_start_timer.py index d1f4aa3d09..d27908541e 100644 --- a/pype/ftrack/actions/action_start_timer.py +++ b/pype/ftrack/actions/action_start_timer.py @@ -1,4 +1,4 @@ -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction diff --git a/pype/ftrack/actions/action_sync_to_avalon_local.py b/pype/ftrack/actions/action_sync_to_avalon_local.py index 88a25ed3ac..54fd0b47f8 100644 --- a/pype/ftrack/actions/action_sync_to_avalon_local.py +++ b/pype/ftrack/actions/action_sync_to_avalon_local.py @@ -4,7 +4,7 @@ import argparse import logging import json -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction, lib as ftracklib @@ -50,9 +50,8 @@ class SyncToAvalon(BaseAction): #: Action description. description = 'Send data from Ftrack to Avalon' #: Action icon. - icon = ( - 'https://cdn1.iconfinder.com/data/icons/hawcons/32/' - '699650-icon-92-inbox-download-512.png' + icon = '{}/ftrack/action_icons/SyncToAvalon-local.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) #: roles that are allowed to register this action role_list = ['Pypeclub'] diff --git a/pype/ftrack/actions/action_test.py b/pype/ftrack/actions/action_test.py index ad97cba487..dcb9dd32d0 100644 --- a/pype/ftrack/actions/action_test.py +++ b/pype/ftrack/actions/action_test.py @@ -1,12 +1,12 @@ +import os import sys import argparse import logging import collections -import os import json import re -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction from avalon import io, inventory, schema @@ -27,9 +27,8 @@ class TestAction(BaseAction): priority = 10000 #: roles that are allowed to register this action role_list = ['Pypeclub'] - icon = ( - 'https://cdn4.iconfinder.com/data/icons/hospital-19/512/' - '8_hospital-512.png' + icon = '{}/ftrack/action_icons/TestAction.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) def discover(self, session, entities, event): diff --git a/pype/ftrack/actions/action_thumbToChildern.py b/pype/ftrack/actions/action_thumbToChildern.py index 2ff6faec48..4e7f1298f5 100644 --- a/pype/ftrack/actions/action_thumbToChildern.py +++ b/pype/ftrack/actions/action_thumbToChildern.py @@ -1,9 +1,10 @@ +import os import sys import argparse import logging import json -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction @@ -15,9 +16,8 @@ class ThumbToChildren(BaseAction): # Action label label = 'Thumbnail to Children' # Action icon - icon = ( - 'https://cdn3.iconfinder.com/data/icons/transfers/100/' - '239322-download_transfer-128.png' + icon = '{}/ftrack/action_icons/thumbToChildren.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) def discover(self, session, entities, event): diff --git a/pype/ftrack/actions/action_thumbToParent.py b/pype/ftrack/actions/action_thumbToParent.py index 98124aca70..632d2a50b2 100644 --- a/pype/ftrack/actions/action_thumbToParent.py +++ b/pype/ftrack/actions/action_thumbToParent.py @@ -1,8 +1,9 @@ +import os import sys import argparse import logging import json -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseAction @@ -14,9 +15,8 @@ class ThumbToParent(BaseAction): # Action label label = 'Thumbnail to Parent' # Action icon - icon = ( - "https://cdn3.iconfinder.com/data/icons/transfers/100/" - "239419-upload_transfer-512.png" + icon = '{}/ftrack/action_icons/thumbToParent.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) def discover(self, session, entities, event): diff --git a/pype/ftrack/credentials.py b/pype/ftrack/credentials.py index 89353ea984..836af73c61 100644 --- a/pype/ftrack/credentials.py +++ b/pype/ftrack/credentials.py @@ -1,6 +1,6 @@ import os import json -import ftrack_api +from pype.vendor import ftrack_api import appdirs @@ -77,7 +77,6 @@ def _check_credentials(username=None, apiKey=None): session = ftrack_api.Session() session.close() except Exception as e: - print(e) return False return True diff --git a/pype/ftrack/events/action_sync_to_avalon.py b/pype/ftrack/events/action_sync_to_avalon.py index 22358cd775..8a5be1c100 100644 --- a/pype/ftrack/events/action_sync_to_avalon.py +++ b/pype/ftrack/events/action_sync_to_avalon.py @@ -2,8 +2,8 @@ import os import sys import argparse import logging -import ftrack_api import json +from pype.vendor import ftrack_api from pype.ftrack import BaseAction, lib @@ -49,9 +49,8 @@ class Sync_To_Avalon(BaseAction): #: Action description. description = 'Send data from Ftrack to Avalon' #: Action icon. - icon = ( - 'https://cdn1.iconfinder.com/data/icons/hawcons/32/' - '699650-icon-92-inbox-download-512.png' + icon = '{}/ftrack/action_icons/SyncToAvalon.svg'.format( + os.environ.get('PYPE_STATICS_SERVER', '') ) def register(self): diff --git a/pype/ftrack/events/event_del_avalon_id_from_new.py b/pype/ftrack/events/event_del_avalon_id_from_new.py index 7659191637..f27a329429 100644 --- a/pype/ftrack/events/event_del_avalon_id_from_new.py +++ b/pype/ftrack/events/event_del_avalon_id_from_new.py @@ -1,4 +1,4 @@ -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseEvent, get_ca_mongoid from pype.ftrack.events.event_sync_to_avalon import Sync_to_Avalon diff --git a/pype/ftrack/events/event_next_task_update.py b/pype/ftrack/events/event_next_task_update.py index e677e53fb2..1ae06050bc 100644 --- a/pype/ftrack/events/event_next_task_update.py +++ b/pype/ftrack/events/event_next_task_update.py @@ -1,4 +1,4 @@ -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseEvent import operator diff --git a/pype/ftrack/events/event_radio_buttons.py b/pype/ftrack/events/event_radio_buttons.py index f96d90307d..769115f045 100644 --- a/pype/ftrack/events/event_radio_buttons.py +++ b/pype/ftrack/events/event_radio_buttons.py @@ -1,4 +1,4 @@ -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseEvent diff --git a/pype/ftrack/events/event_sync_to_avalon.py b/pype/ftrack/events/event_sync_to_avalon.py index 1deaa3d17e..9dd7355d5e 100644 --- a/pype/ftrack/events/event_sync_to_avalon.py +++ b/pype/ftrack/events/event_sync_to_avalon.py @@ -1,4 +1,4 @@ -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseEvent, lib diff --git a/pype/ftrack/events/event_test.py b/pype/ftrack/events/event_test.py index 46e16cbb95..f6746f2535 100644 --- a/pype/ftrack/events/event_test.py +++ b/pype/ftrack/events/event_test.py @@ -1,9 +1,8 @@ import os import sys import re -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseEvent -from app import api ignore_me = True diff --git a/pype/ftrack/events/event_thumbnail_updates.py b/pype/ftrack/events/event_thumbnail_updates.py index 50089e26b8..042f6cc600 100644 --- a/pype/ftrack/events/event_thumbnail_updates.py +++ b/pype/ftrack/events/event_thumbnail_updates.py @@ -1,4 +1,4 @@ -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseEvent diff --git a/pype/ftrack/events/event_version_to_task_statuses.py b/pype/ftrack/events/event_version_to_task_statuses.py index d1393e622e..8b14e025d3 100644 --- a/pype/ftrack/events/event_version_to_task_statuses.py +++ b/pype/ftrack/events/event_version_to_task_statuses.py @@ -1,4 +1,4 @@ -import ftrack_api +from pype.vendor import ftrack_api from pype.ftrack import BaseEvent diff --git a/pype/ftrack/ftrack_module.py b/pype/ftrack/ftrack_module.py new file mode 100644 index 0000000000..fdce0535e8 --- /dev/null +++ b/pype/ftrack/ftrack_module.py @@ -0,0 +1,329 @@ +import os +import json +import threading +import time +from Qt import QtCore, QtGui, QtWidgets + +from pype.vendor import ftrack_api +from pypeapp import style +from pype.ftrack import FtrackServer, credentials, login_dialog as login_dialog + +from pype import api as pype + + +log = pype.Logger().get_logger("FtrackModule", "ftrack") + + +class FtrackModule: + def __init__(self, main_parent=None, parent=None): + + self.parent = parent + self.widget_login = login_dialog.Login_Dialog_ui(self) + self.action_server = FtrackServer('action') + self.thread_action_server = None + self.thread_timer = None + + self.bool_logged = False + self.bool_action_server = False + self.bool_timer_event = False + + def show_login_widget(self): + self.widget_login.show() + + def validate(self): + validation = False + cred = credentials._get_credentials() + try: + if 'username' in cred and 'apiKey' in cred: + validation = credentials._check_credentials( + cred['username'], + cred['apiKey'] + ) + if validation is False: + self.show_login_widget() + else: + self.show_login_widget() + + except Exception as e: + log.error("We are unable to connect to Ftrack: {0}".format(e)) + + validation = credentials._check_credentials() + if validation is True: + log.info("Connected to Ftrack successfully") + self.loginChange() + else: + log.warning("Please sign in to Ftrack") + self.bool_logged = False + self.set_menu_visibility() + + return validation + + # Necessary - login_dialog works with this method after logging in + def loginChange(self): + self.bool_logged = True + self.set_menu_visibility() + self.start_action_server() + + def logout(self): + credentials._clear_credentials() + self.stop_action_server() + + log.info("Logged out of Ftrack") + self.bool_logged = False + self.set_menu_visibility() + + # Actions part + def start_action_server(self): + if self.thread_action_server is None: + self.thread_action_server = threading.Thread( + target=self.set_action_server + ) + self.thread_action_server.daemon = True + self.thread_action_server.start() + + log.info("Ftrack action server launched") + self.bool_action_server = True + self.set_menu_visibility() + + def set_action_server(self): + try: + self.action_server.run_server() + except Exception: + msg = 'Ftrack Action server crashed! Please try to start again.' + log.error(msg) + # TODO show message to user + self.bool_action_server = False + self.set_menu_visibility() + + def reset_action_server(self): + self.stop_action_server() + self.start_action_server() + + def stop_action_server(self): + try: + self.action_server.stop_session() + if self.thread_action_server is not None: + self.thread_action_server.join() + self.thread_action_server = None + + log.info("Ftrack action server stopped") + self.bool_action_server = False + self.set_menu_visibility() + except Exception as e: + log.error("During Killing action server: {0}".format(e)) + + # Definition of Tray menu + def tray_menu(self, parent_menu): + # Menu for Tray App + self.menu = QtWidgets.QMenu('Ftrack', parent_menu) + self.menu.setProperty('submenu', 'on') + + # Actions - server + self.smActionS = self.menu.addMenu("Action server") + + self.aRunActionS = QtWidgets.QAction( + "Run action server", self.smActionS + ) + self.aResetActionS = QtWidgets.QAction( + "Reset action server", self.smActionS + ) + self.aStopActionS = QtWidgets.QAction( + "Stop action server", self.smActionS + ) + + self.aRunActionS.triggered.connect(self.start_action_server) + self.aResetActionS.triggered.connect(self.reset_action_server) + self.aStopActionS.triggered.connect(self.stop_action_server) + + self.smActionS.addAction(self.aRunActionS) + self.smActionS.addAction(self.aResetActionS) + self.smActionS.addAction(self.aStopActionS) + + # Actions - basic + self.aLogin = QtWidgets.QAction("Login", self.menu) + self.aLogin.triggered.connect(self.validate) + self.aLogout = QtWidgets.QAction("Logout", self.menu) + self.aLogout.triggered.connect(self.logout) + + self.menu.addAction(self.aLogin) + self.menu.addAction(self.aLogout) + + self.bool_logged = False + self.set_menu_visibility() + + parent_menu.addMenu(self.menu) + + def tray_start(self): + self.validate() + + # Definition of visibility of each menu actions + def set_menu_visibility(self): + + self.smActionS.menuAction().setVisible(self.bool_logged) + self.aLogin.setVisible(not self.bool_logged) + self.aLogout.setVisible(self.bool_logged) + + if self.bool_logged is False: + if self.bool_timer_event is True: + self.stop_timer_thread() + return + + self.aRunActionS.setVisible(not self.bool_action_server) + self.aResetActionS.setVisible(self.bool_action_server) + self.aStopActionS.setVisible(self.bool_action_server) + + if self.bool_timer_event is False: + self.start_timer_thread() + + def start_timer_thread(self): + try: + if self.thread_timer is None: + self.thread_timer = FtrackEventsThread(self) + self.bool_timer_event = True + self.thread_timer.signal_timer_started.connect( + self.timer_started + ) + self.thread_timer.signal_timer_stopped.connect( + self.timer_stopped + ) + self.thread_timer.start() + except Exception: + pass + + def stop_timer_thread(self): + try: + if self.thread_timer is not None: + self.thread_timer.terminate() + self.thread_timer.wait() + self.thread_timer = None + + except Exception as e: + log.error("During Killing Timer event server: {0}".format(e)) + + def process_modules(self, modules): + if 'TimersManager' in modules: + self.timer_manager = modules['TimersManager'] + self.timer_manager.add_module(self) + + def start_timer_manager(self, data): + if self.thread_timer is not None: + self.thread_timer.ftrack_start_timer(data) + + def stop_timer_manager(self): + if self.thread_timer is not None: + self.thread_timer.ftrack_stop_timer() + + def timer_started(self, data): + if hasattr(self, 'timer_manager'): + self.timer_manager.start_timers(data) + + def timer_stopped(self): + if hasattr(self, 'timer_manager'): + self.timer_manager.stop_timers() + + +class FtrackEventsThread(QtCore.QThread): + # Senders + signal_timer_started = QtCore.Signal(object) + signal_timer_stopped = QtCore.Signal() + + def __init__(self, parent): + super(FtrackEventsThread, self).__init__() + cred = credentials._get_credentials() + self.username = cred['username'] + self.user = None + self.last_task = None + + def run(self): + self.timer_session = ftrack_api.Session(auto_connect_event_hub=True) + self.timer_session.event_hub.subscribe( + 'topic=ftrack.update and source.user.username={}'.format( + self.username + ), + self.event_handler) + + user_query = 'User where username is "{}"'.format(self.username) + self.user = self.timer_session.query(user_query).one() + + timer_query = 'Timer where user.username is "{}"'.format(self.username) + timer = self.timer_session.query(timer_query).first() + if timer is not None: + self.last_task = timer['context'] + self.signal_timer_started.emit( + self.get_data_from_task(self.last_task) + ) + + self.timer_session.event_hub.wait() + + def get_data_from_task(self, task_entity): + data = {} + data['task_name'] = task_entity['name'] + data['task_type'] = task_entity['type']['name'] + data['project_name'] = task_entity['project']['full_name'] + data['hierarchy'] = self.get_parents(task_entity['parent']) + + return data + + def get_parents(self, entity): + output = [] + if entity.entity_type.lower() == 'project': + return output + output.extend(self.get_parents(entity['parent'])) + output.append(entity['name']) + + return output + + def event_handler(self, event): + try: + if event['data']['entities'][0]['objectTypeId'] != 'timer': + return + except Exception: + return + + new = event['data']['entities'][0]['changes']['start']['new'] + old = event['data']['entities'][0]['changes']['start']['old'] + + if old is None and new is None: + return + + timer_query = 'Timer where user.username is "{}"'.format(self.username) + timer = self.timer_session.query(timer_query).first() + if timer is not None: + self.last_task = timer['context'] + + if old is None: + self.signal_timer_started.emit( + self.get_data_from_task(self.last_task) + ) + elif new is None: + self.signal_timer_stopped.emit() + + def ftrack_stop_timer(self): + try: + self.user.stop_timer() + self.timer_session.commit() + self.signal_timer_stopped.emit() + except Exception as e: + log.debug("Timer stop had issues: {}".format(e)) + + def ftrack_start_timer(self, input_data): + if self.user is None: + return + if ( + input_data['task_name'] == self.last_task['name'] and + input_data['hierarchy'][-1] == self.last_task['parent']['name'] + ): + return + task_query = ( + 'Task where name is "{task_name}"' + ' and parent.name is "{entity_name}"' + ' and project.full_name is "{project_name}"' + ).format(**input_data) + + task = self.timer_session.query(task_query).one() + self.last_task = task + self.user.start_timer(task) + self.timer_session.commit() + self.signal_timer_started.emit( + self.get_data_from_task(self.last_task) + ) diff --git a/pype/ftrack/ftrack_run.py b/pype/ftrack/ftrack_run.py deleted file mode 100644 index a722f8d3fe..0000000000 --- a/pype/ftrack/ftrack_run.py +++ /dev/null @@ -1,624 +0,0 @@ -import os -import json -import threading -import time -import ftrack_api -from app import style -from app.vendor.Qt import QtCore, QtGui, QtWidgets - -from pype.ftrack import credentials, login_dialog as login_dialog - -from pype.vendor.pynput import mouse, keyboard -from . import FtrackServer - -from pype import api as pype - - -# load data from templates -pype.load_data_from_templates() - -log = pype.Logger.getLogger(__name__, "ftrack") - - -class FtrackRunner: - def __init__(self, main_parent=None, parent=None): - - self.parent = parent - self.widget_login = login_dialog.Login_Dialog_ui(self) - self.widget_timer = StopTimer(self) - self.action_server = FtrackServer('action') - self.thread_action_server = None - self.thread_timer = None - self.thread_timer_coundown = None - - # self.signal_start_timer.connect(self.timerStart) - - self.bool_logged = False - self.bool_action_server = False - self.bool_timer_event = False - - def show_login_widget(self): - self.widget_login.show() - - def validate(self): - validation = False - cred = credentials._get_credentials() - try: - if 'username' in cred and 'apiKey' in cred: - validation = credentials._check_credentials( - cred['username'], - cred['apiKey'] - ) - if validation is False: - self.show_login_widget() - else: - self.show_login_widget() - - except Exception as e: - log.error("We are unable to connect to Ftrack: {0}".format(e)) - - validation = credentials._check_credentials() - if validation is True: - log.info("Connected to Ftrack successfully") - self.loginChange() - else: - log.warning("Please sign in to Ftrack") - self.bool_logged = False - self.set_menu_visibility() - - return validation - - # Necessary - login_dialog works with this method after logging in - def loginChange(self): - self.bool_logged = True - self.set_menu_visibility() - self.start_action_server() - - def logout(self): - credentials._clear_credentials() - self.stop_action_server() - - log.info("Logged out of Ftrack") - self.bool_logged = False - self.set_menu_visibility() - - # Actions part - def start_action_server(self): - if self.thread_action_server is None: - self.thread_action_server = threading.Thread( - target=self.set_action_server - ) - self.thread_action_server.daemon = True - self.thread_action_server.start() - - log.info("Ftrack action server launched") - self.bool_action_server = True - self.set_menu_visibility() - - def set_action_server(self): - try: - self.action_server.run_server() - except Exception: - msg = 'Ftrack Action server crashed! Please try to start again.' - log.error(msg) - # TODO show message to user - self.bool_action_server = False - self.set_menu_visibility() - - def reset_action_server(self): - self.stop_action_server() - self.start_action_server() - - def stop_action_server(self): - try: - self.action_server.stop_session() - if self.thread_action_server is not None: - self.thread_action_server.join() - self.thread_action_server = None - - log.info("Ftrack action server stopped") - self.bool_action_server = False - self.set_menu_visibility() - except Exception as e: - log.error("During Killing action server: {0}".format(e)) - - # Definition of Tray menu - def trayMenu(self, parent): - # Menu for Tray App - self.menu = QtWidgets.QMenu('Ftrack', parent) - self.menu.setProperty('submenu', 'on') - self.menu.setStyleSheet(style.load_stylesheet()) - - # Actions - server - self.smActionS = self.menu.addMenu("Action server") - - self.aRunActionS = QtWidgets.QAction( - "Run action server", self.smActionS - ) - self.aResetActionS = QtWidgets.QAction( - "Reset action server", self.smActionS - ) - self.aStopActionS = QtWidgets.QAction( - "Stop action server", self.smActionS - ) - - self.aRunActionS.triggered.connect(self.start_action_server) - self.aResetActionS.triggered.connect(self.reset_action_server) - self.aStopActionS.triggered.connect(self.stop_action_server) - - self.smActionS.addAction(self.aRunActionS) - self.smActionS.addAction(self.aResetActionS) - self.smActionS.addAction(self.aStopActionS) - - # Actions - basic - self.aLogin = QtWidgets.QAction("Login", self.menu) - self.aLogin.triggered.connect(self.validate) - self.aLogout = QtWidgets.QAction("Logout", self.menu) - self.aLogout.triggered.connect(self.logout) - - self.menu.addAction(self.aLogin) - self.menu.addAction(self.aLogout) - - self.bool_logged = False - self.set_menu_visibility() - - return self.menu - - # Definition of visibility of each menu actions - def set_menu_visibility(self): - - self.smActionS.menuAction().setVisible(self.bool_logged) - self.aLogin.setVisible(not self.bool_logged) - self.aLogout.setVisible(self.bool_logged) - - if self.bool_logged is False: - if self.bool_timer_event is True: - self.stop_timer_thread() - return - - self.aRunActionS.setVisible(not self.bool_action_server) - self.aResetActionS.setVisible(self.bool_action_server) - self.aStopActionS.setVisible(self.bool_action_server) - - if self.bool_timer_event is False: - self.start_timer_thread() - - def start_timer_thread(self): - try: - if self.thread_timer is None: - self.thread_timer = FtrackEventsThread(self) - self.bool_timer_event = True - self.thread_timer.signal_timer_started.connect( - self.timer_started - ) - self.thread_timer.signal_timer_stopped.connect( - self.timer_stopped - ) - self.thread_timer.start() - except Exception: - pass - - def stop_timer_thread(self): - try: - if self.thread_timer is not None: - self.thread_timer.terminate() - self.thread_timer.wait() - self.thread_timer = None - - except Exception as e: - log.error("During Killing Timer event server: {0}".format(e)) - - def start_countdown_thread(self): - if self.thread_timer_coundown is None: - self.thread_timer_coundown = CountdownThread(self) - self.thread_timer_coundown.signal_show_question.connect( - self.show_widget_timer - ) - self.thread_timer_coundown.signal_send_time.connect( - self.change_count_widget - ) - self.thread_timer_coundown.signal_stop_timer.connect( - self.timer_stop - ) - self.thread_timer_coundown.start() - - def stop_countdown_thread(self): - if self.thread_timer_coundown is not None: - self.thread_timer_coundown.runs = False - self.thread_timer_coundown.terminate() - self.thread_timer_coundown.wait() - self.thread_timer_coundown = None - - def show_widget_timer(self): - self.widget_timer.show() - self.widget_timer.setWindowState(QtCore.Qt.WindowMinimized) - self.widget_timer.setWindowState(QtCore.Qt.WindowActive) - # self.widget_timer.activateWindow() - - def change_count_widget(self, time): - str_time = str(time).replace(".0", "") - self.widget_timer.lbl_rest_time.setText(str_time) - - def timer_started(self): - self.start_countdown_thread() - - def timer_stopped(self): - self.stop_countdown_thread() - - def timer_stop(self): - if self.thread_timer is not None: - self.widget_timer.main_context = False - self.widget_timer.refresh_context() - self.thread_timer.signal_stop_timer.emit() - if self.thread_timer_coundown is not None: - self.stop_countdown_thread() - - def timer_restart(self): - if self.thread_timer is not None: - self.thread_timer.signal_restart_timer.emit() - - self.timer_started() - - def timer_continue(self): - if self.thread_timer_coundown is not None: - self.thread_timer_coundown.signal_continue_timer.emit() - - -class FtrackEventsThread(QtCore.QThread): - # Senders - signal_timer_started = QtCore.Signal() - signal_timer_stopped = QtCore.Signal() - # Listeners - signal_stop_timer = QtCore.Signal() - signal_restart_timer = QtCore.Signal() - - def __init__(self, parent): - super(FtrackEventsThread, self).__init__() - cred = credentials._get_credentials() - self.username = cred['username'] - self.signal_stop_timer.connect(self.ftrack_stop_timer) - self.signal_restart_timer.connect(self.ftrack_restart_timer) - self.user = None - self.last_task = None - - def run(self): - self.timer_session = ftrack_api.Session(auto_connect_event_hub=True) - self.timer_session.event_hub.subscribe( - 'topic=ftrack.update and source.user.username={}'.format( - self.username - ), - self.event_handler) - - user_query = 'User where username is "{}"'.format(self.username) - self.user = self.timer_session.query(user_query).one() - - timer_query = 'Timer where user.username is "{}"'.format(self.username) - timer = self.timer_session.query(timer_query).first() - if timer is not None: - self.last_task = timer['context'] - self.signal_timer_started.emit() - - self.timer_session.event_hub.wait() - - def event_handler(self, event): - try: - if event['data']['entities'][0]['objectTypeId'] != 'timer': - return - except Exception: - return - - new = event['data']['entities'][0]['changes']['start']['new'] - old = event['data']['entities'][0]['changes']['start']['old'] - - if old is None and new is None: - return - - timer_query = 'Timer where user.username is "{}"'.format(self.username) - timer = self.timer_session.query(timer_query).first() - if timer is not None: - self.last_task = timer['context'] - - if old is None: - self.signal_timer_started.emit() - elif new is None: - self.signal_timer_stopped.emit() - - def ftrack_stop_timer(self): - try: - self.user.stop_timer() - self.timer_session.commit() - except Exception as e: - log.debug("Timer stop had issues: {}".format(e)) - - def ftrack_restart_timer(self): - try: - if (self.last_task is not None) and (self.user is not None): - self.user.start_timer(self.last_task) - self.timer_session.commit() - except Exception as e: - log.debug("Timer stop had issues: {}".format(e)) - - -class CountdownThread(QtCore.QThread): - # Senders - signal_show_question = QtCore.Signal() - signal_send_time = QtCore.Signal(object) - signal_stop_timer = QtCore.Signal() - signal_stop_countdown = QtCore.Signal() - # Listeners - signal_reset_timer = QtCore.Signal() - signal_continue_timer = QtCore.Signal() - - def __init__(self, parent): - super(CountdownThread, self).__init__() - - self.runs = True - self.over_line = False - config_data = self.load_timer_values() - self.count_length = config_data['full_time']*60 - self.border_line = config_data['message_time']*60 + 1 - self.reset_count() - self.signal_reset_timer.connect(self.reset_count) - self.signal_continue_timer.connect(self.continue_timer) - - def continue_timer(self): - self.over_line = False - self.reset_count() - - def reset_count(self): - if self.over_line is True: - self.actual = self.border_line - else: - self.actual = self.count_length - - def stop(self): - self.runs = False - - def run(self): - thread_mouse = MouseThread(self) - thread_mouse.start() - thread_keyboard = KeyboardThread(self) - thread_keyboard.start() - while self.runs: - if self.actual == self.border_line: - self.signal_show_question.emit() - self.over_line = True - - if self.actual <= self.border_line: - self.signal_send_time.emit(self.actual) - - time.sleep(1) - self.actual -= 1 - - if self.actual == 0: - self.runs = False - self.signal_stop_timer.emit() - - thread_mouse.signal_stop.emit() - thread_mouse.terminate() - thread_mouse.wait() - thread_keyboard.signal_stop.emit() - thread_keyboard.terminate() - thread_keyboard.wait() - - def load_timer_values(self): - templates = os.environ['PYPE_STUDIO_TEMPLATES'] - path_items = [templates, 'presets', 'ftrack', 'ftrack_config.json'] - filepath = os.path.sep.join(path_items) - data = dict() - try: - with open(filepath) as data_file: - json_dict = json.load(data_file) - data = json_dict['timer'] - except Exception as e: - msg = ( - 'Loading "Ftrack Config file" Failed.' - ' Please check log for more information.' - ' Times are set to default.' - ) - log.warning("{} - {}".format(msg, str(e))) - - data = self.validate_timer_values(data) - - return data - - def validate_timer_values(self, data): - # default values - if 'full_time' not in data: - data['full_time'] = 15 - if 'message_time' not in data: - data['message_time'] = 0.5 - - # minimum values - if data['full_time'] < 2: - data['full_time'] = 2 - # message time is earlier that full time - if data['message_time'] > data['full_time']: - data['message_time'] = data['full_time'] - 0.5 - return data - - -class MouseThread(QtCore.QThread): - signal_stop = QtCore.Signal() - - def __init__(self, parent): - super(MouseThread, self).__init__() - self.parent = parent - self.signal_stop.connect(self.stop) - self.m_listener = None - - def stop(self): - if self.m_listener is not None: - self.m_listener.stop() - - def on_move(self, posx, posy): - self.parent.signal_reset_timer.emit() - - def run(self): - self.m_listener = mouse.Listener(on_move=self.on_move) - self.m_listener.start() - - -class KeyboardThread(QtCore.QThread): - signal_stop = QtCore.Signal() - - def __init__(self, parent): - super(KeyboardThread, self).__init__() - self.parent = parent - self.signal_stop.connect(self.stop) - self.k_listener = None - - def stop(self): - if self.k_listener is not None: - self.k_listener.stop() - - def on_press(self, key): - self.parent.signal_reset_timer.emit() - - def run(self): - self.k_listener = keyboard.Listener(on_press=self.on_press) - self.k_listener.start() - - -class StopTimer(QtWidgets.QWidget): - - SIZE_W = 300 - SIZE_H = 160 - - def __init__(self, parent=None): - - super(StopTimer, self).__init__() - - self.main_context = True - self.parent = parent - self.setWindowIcon(self.parent.parent.icon) - self.setWindowFlags( - QtCore.Qt.WindowCloseButtonHint | - QtCore.Qt.WindowMinimizeButtonHint - ) - - self._translate = QtCore.QCoreApplication.translate - - 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) - - 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)) - self.setStyleSheet(style.load_stylesheet()) - - self.setLayout(self._main()) - self.refresh_context() - self.setWindowTitle('Pype - Stop Ftrack timer') - - def _main(self): - self.main = QtWidgets.QVBoxLayout() - self.main.setObjectName('main') - - self.form = QtWidgets.QFormLayout() - self.form.setContentsMargins(10, 15, 10, 5) - self.form.setObjectName('form') - - msg_info = 'You didn\'t work for a long time.' - msg_question = 'Would you like to stop Ftrack timer?' - msg_stopped = ( - 'Your Ftrack timer was stopped. Do you want to start again?' - ) - - self.lbl_info = QtWidgets.QLabel(msg_info) - self.lbl_info.setFont(self.font) - self.lbl_info.setTextFormat(QtCore.Qt.RichText) - self.lbl_info.setObjectName("lbl_info") - self.lbl_info.setWordWrap(True) - - self.lbl_question = QtWidgets.QLabel(msg_question) - self.lbl_question.setFont(self.font) - self.lbl_question.setTextFormat(QtCore.Qt.RichText) - self.lbl_question.setObjectName("lbl_question") - self.lbl_question.setWordWrap(True) - - self.lbl_stopped = QtWidgets.QLabel(msg_stopped) - self.lbl_stopped.setFont(self.font) - self.lbl_stopped.setTextFormat(QtCore.Qt.RichText) - self.lbl_stopped.setObjectName("lbl_stopped") - self.lbl_stopped.setWordWrap(True) - - self.lbl_rest_time = QtWidgets.QLabel("") - self.lbl_rest_time.setFont(self.font) - self.lbl_rest_time.setTextFormat(QtCore.Qt.RichText) - self.lbl_rest_time.setObjectName("lbl_rest_time") - self.lbl_rest_time.setWordWrap(True) - self.lbl_rest_time.setAlignment(QtCore.Qt.AlignCenter) - - self.form.addRow(self.lbl_info) - self.form.addRow(self.lbl_question) - self.form.addRow(self.lbl_stopped) - self.form.addRow(self.lbl_rest_time) - - self.group_btn = QtWidgets.QHBoxLayout() - self.group_btn.addStretch(1) - self.group_btn.setObjectName("group_btn") - - self.btn_stop = QtWidgets.QPushButton("Stop timer") - self.btn_stop.setToolTip('Stop\'s Ftrack timer') - self.btn_stop.clicked.connect(self.stop_timer) - - self.btn_continue = QtWidgets.QPushButton("Continue") - self.btn_continue.setToolTip('Timer will continue') - self.btn_continue.clicked.connect(self.continue_timer) - - self.btn_close = QtWidgets.QPushButton("Close") - self.btn_close.setToolTip('Close window') - self.btn_close.clicked.connect(self.close_widget) - - self.btn_restart = QtWidgets.QPushButton("Start timer") - self.btn_restart.setToolTip('Timer will be started again') - self.btn_restart.clicked.connect(self.restart_timer) - - self.group_btn.addWidget(self.btn_continue) - self.group_btn.addWidget(self.btn_stop) - self.group_btn.addWidget(self.btn_restart) - self.group_btn.addWidget(self.btn_close) - - self.main.addLayout(self.form) - self.main.addLayout(self.group_btn) - - return self.main - - def refresh_context(self): - self.lbl_question.setVisible(self.main_context) - self.lbl_rest_time.setVisible(self.main_context) - self.lbl_stopped.setVisible(not self.main_context) - - self.btn_continue.setVisible(self.main_context) - self.btn_stop.setVisible(self.main_context) - self.btn_restart.setVisible(not self.main_context) - self.btn_close.setVisible(not self.main_context) - - def stop_timer(self): - self.parent.timer_stop() - self.close_widget() - - def restart_timer(self): - self.parent.timer_restart() - self.close_widget() - - def continue_timer(self): - self.parent.timer_continue() - self.close_widget() - - def closeEvent(self, event): - event.ignore() - if self.main_context is True: - self.continue_timer() - else: - self.close_widget() - - def close_widget(self): - self.main_context = True - self.refresh_context() - self.hide() diff --git a/pype/ftrack/ftrack_server/event_server.py b/pype/ftrack/ftrack_server/event_server.py index e824d1d899..2b3acad076 100644 --- a/pype/ftrack/ftrack_server/event_server.py +++ b/pype/ftrack/ftrack_server/event_server.py @@ -1,10 +1,10 @@ import sys from pype.ftrack import credentials, login_dialog as login_dialog from pype.ftrack.ftrack_server import FtrackServer -from app.vendor.Qt import QtWidgets +from Qt import QtWidgets from pype import api -log = api.Logger.getLogger(__name__, "ftrack-event-server") +log = api.Logger().get_logger(__name__, "ftrack-event-server") class EventServer: diff --git a/pype/ftrack/ftrack_server/event_server_cli.py b/pype/ftrack/ftrack_server/event_server_cli.py index a466bf5723..eee2c81eb5 100644 --- a/pype/ftrack/ftrack_server/event_server_cli.py +++ b/pype/ftrack/ftrack_server/event_server_cli.py @@ -1,9 +1,9 @@ import sys from pype.ftrack import credentials from pype.ftrack.ftrack_server import FtrackServer -from app import api +from pypeapp import Logger -log = api.Logger.getLogger(__name__, "ftrack-event-server-cli") +log = Logger().get_logger(__name__, "ftrack-event-server-cli") possible_yes = ['y', 'yes'] possible_no = ['n', 'no'] diff --git a/pype/ftrack/ftrack_server/ftrack_server.py b/pype/ftrack/ftrack_server/ftrack_server.py index 6c63dcf414..14dd3d11f7 100644 --- a/pype/ftrack/ftrack_server/ftrack_server.py +++ b/pype/ftrack/ftrack_server/ftrack_server.py @@ -2,12 +2,12 @@ import os import sys import types import importlib -import ftrack_api +from pype.vendor import ftrack_api import time import logging -from app.api import Logger +from pypeapp import Logger -log = Logger.getLogger(__name__) +log = Logger().get_logger(__name__) """ # Required - Needed for connection to Ftrack diff --git a/pype/ftrack/lib/avalon_sync.py b/pype/ftrack/lib/avalon_sync.py index 7ebd85d71d..030b0b5b6c 100644 --- a/pype/ftrack/lib/avalon_sync.py +++ b/pype/ftrack/lib/avalon_sync.py @@ -8,11 +8,11 @@ import avalon import avalon.api from avalon import schema from avalon.vendor import toml, jsonschema -from app.api import Logger +from pypeapp import Logger ValidationError = jsonschema.ValidationError -log = Logger.getLogger(__name__) +log = Logger().get_logger(__name__) def get_ca_mongoid(): diff --git a/pype/ftrack/lib/ftrack_app_handler.py b/pype/ftrack/lib/ftrack_app_handler.py index 96ec42ba2c..2d1d88f7d4 100644 --- a/pype/ftrack/lib/ftrack_app_handler.py +++ b/pype/ftrack/lib/ftrack_app_handler.py @@ -8,6 +8,8 @@ from pype import lib as pypelib from .avalon_sync import get_config_data from .ftrack_base_handler import BaseHandler +from pypeapp import Anatomy + class AppAction(BaseHandler): '''Custom Action base class @@ -177,7 +179,8 @@ class AppAction(BaseHandler): os.environ["AVALON_APP"] = self.identifier.split("_")[0] os.environ["AVALON_APP_NAME"] = self.identifier - anatomy = pype.Anatomy + anatomy = Anatomy() + hierarchy = "" parents = database[project_name].find_one({ "type": 'asset', @@ -190,7 +193,7 @@ class AppAction(BaseHandler): application = avalonlib.get_application(os.environ["AVALON_APP_NAME"]) data = { - "root": os.environ["AVALON_PROJECTS"], + "root": os.environ.get("PYPE_STUDIO_PROJECTS_MOUNT"), "project": { "name": entity['project']['full_name'], "code": entity['project']['name'] @@ -213,12 +216,10 @@ class AppAction(BaseHandler): except Exception: try: anatomy = anatomy.format(data) - work_template = os.path.join( - anatomy.work.root, - anatomy.work.folder - ) + work_template = anatomy["work"]["folder"] + except Exception as e: - self.log.error( + self.log.exception( "{0} Error in anatomy.format: {1}".format(__name__, e) ) os.environ["AVALON_WORKDIR"] = os.path.normpath(work_template) @@ -239,13 +240,22 @@ class AppAction(BaseHandler): tools_env = acre.get_tools(tools_attr) env = acre.compute(tools_env) env = acre.merge(env, current_env=dict(os.environ)) + env = acre.append(dict(os.environ), env) + + + # + # tools_env = acre.get_tools(tools) + # env = acre.compute(dict(tools_env)) + # env = acre.merge(env, dict(os.environ)) + # os.environ = acre.append(dict(os.environ), env) + # os.environ = acre.compute(os.environ) # Get path to execute - st_temp_path = os.environ['PYPE_STUDIO_TEMPLATES'] + st_temp_path = os.environ['PYPE_CONFIG'] os_plat = platform.system().lower() # Path to folder with launchers - path = os.path.join(st_temp_path, 'bin', os_plat) + path = os.path.join(st_temp_path, 'launchers', os_plat) # Full path to executable launcher execfile = None @@ -275,7 +285,7 @@ class AppAction(BaseHandler): try: fp = open(execfile) except PermissionError as p: - self.log.error('Access denied on {0} - {1}'.format( + self.log.exception('Access denied on {0} - {1}'.format( execfile, p)) return { 'success': False, @@ -344,6 +354,8 @@ class AppAction(BaseHandler): # Set origin avalon environments for key, value in env_origin.items(): + if value == None: + value = "" os.environ[key] = value return { diff --git a/pype/ftrack/lib/ftrack_base_handler.py b/pype/ftrack/lib/ftrack_base_handler.py index cc0776ca25..63d4ff0ce9 100644 --- a/pype/ftrack/lib/ftrack_base_handler.py +++ b/pype/ftrack/lib/ftrack_base_handler.py @@ -1,7 +1,8 @@ -import ftrack_api import functools import time from pype import api as pype +from pype.vendor import ftrack_api +from pype.vendor.ftrack_api import session as fa_session class MissingPermision(Exception): @@ -30,7 +31,7 @@ class BaseHandler(object): def __init__(self, session): '''Expects a ftrack_api.Session instance''' self._session = session - self.log = pype.Logger.getLogger(self.__class__.__name__) + self.log = pype.Logger().get_logger(self.__class__.__name__) # Using decorator self.register = self.register_decorator(self.register) @@ -71,7 +72,7 @@ class BaseHandler(object): self.type, label) ) except Exception as e: - self.log.error('{} "{}" - Registration failed ({})'.format( + self.log.exception('{} "{}" - Registration failed ({})'.format( self.type, label, str(e)) ) return wrapper_register @@ -94,7 +95,7 @@ class BaseHandler(object): return result except Exception as e: msg = '{} "{}": Failed ({})'.format(self.type, label, str(e)) - self.log.error(msg) + self.log.exception(msg) return { 'success': False, 'message': msg @@ -110,7 +111,6 @@ class BaseHandler(object): self.session.reset() def _preregister(self): - # Rolecheck if hasattr(self, "role_list") and len(self.role_list) > 0: username = self.session.api_user user = self.session.query( @@ -197,7 +197,9 @@ class BaseHandler(object): _entities = event['data'].get('entities_object', None) if ( _entities is None or - _entities[0].get('link', None) == ftrack_api.symbol.NOT_SET + _entities[0].get( + 'link', None + ) == fa_session.ftrack_api.symbol.NOT_SET ): _entities = self._get_entities(event) @@ -302,7 +304,7 @@ class BaseHandler(object): # Launch preactions for preaction in self.preactions: - event = ftrack_api.event.base.Event( + event = fa_session.ftrack_api.event.base.Event( topic='ftrack.action.launch', data=dict( actionIdentifier=preaction, @@ -314,7 +316,7 @@ class BaseHandler(object): ) session.event_hub.publish(event, on_error='ignore') # Relaunch this action - event = ftrack_api.event.base.Event( + event = fa_session.ftrack_api.event.base.Event( topic='ftrack.action.launch', data=dict( actionIdentifier=self.identifier, @@ -415,7 +417,7 @@ class BaseHandler(object): 'applicationId=ftrack.client.web and user.id="{0}"' ).format(user_id) self.session.event_hub.publish( - ftrack_api.event.base.Event( + fa_session.ftrack_api.event.base.Event( topic='ftrack.action.trigger-user-interface', data=dict( type='message', @@ -438,7 +440,7 @@ class BaseHandler(object): ).format(user_id) self.session.event_hub.publish( - ftrack_api.event.base.Event( + fa_session.ftrack_api.event.base.Event( topic='ftrack.action.trigger-user-interface', data=dict( type='widget', diff --git a/pype/ftrack/login_dialog.py b/pype/ftrack/login_dialog.py index 2828afe539..5520087032 100644 --- a/pype/ftrack/login_dialog.py +++ b/pype/ftrack/login_dialog.py @@ -1,7 +1,7 @@ import os import requests -from app.vendor.Qt import QtCore, QtGui, QtWidgets -from app import style +from Qt import QtCore, QtGui, QtWidgets +from pypeapp import style from . import credentials, login_tools @@ -28,7 +28,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_ROOT') + pype_setup = os.getenv('PYPE_ROOT') items = [pype_setup, "app", "resources", "icon.png"] fname = os.path.sep.join(items) icon = QtGui.QIcon(fname) diff --git a/pype/ftrack/login_tools.py b/pype/ftrack/login_tools.py index 592ec152ee..b259f2d2ed 100644 --- a/pype/ftrack/login_tools.py +++ b/pype/ftrack/login_tools.py @@ -5,7 +5,7 @@ import webbrowser import functools import pype import inspect -from app.vendor.Qt import QtCore +from Qt import QtCore class LoginServerHandler(BaseHTTPRequestHandler): diff --git a/pype/lib.py b/pype/lib.py index 43461582db..176ef45967 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -420,7 +420,7 @@ def get_avalon_project_template_schema(): def get_avalon_project_template(): - from app.api import Templates + from pypeapp import Anatomy """ Get avalon template @@ -428,11 +428,11 @@ def get_avalon_project_template(): Returns: dictionary with templates """ - template = Templates(type=["anatomy"]) + templates = Anatomy().templates proj_template = {} - proj_template['workfile'] = template.anatomy.avalon.workfile - proj_template['work'] = template.anatomy.avalon.work - proj_template['publish'] = template.anatomy.avalon.publish + proj_template['workfile'] = templates["avalon"]["workfile"] + proj_template['work'] = templates["avalon"]["work"] + proj_template['publish'] = templates["avalon"]["publish"] return proj_template @@ -467,7 +467,7 @@ def get_all_avalon_projects(): def get_presets_path(): - templates = os.environ['PYPE_STUDIO_TEMPLATES'] + templates = os.environ['PYPE_CONFIG'] path_items = [templates, 'presets'] filepath = os.path.sep.join(path_items) return filepath diff --git a/pype/maya/__init__.py b/pype/maya/__init__.py index 6b971c8bca..8bfc4c8ee5 100644 --- a/pype/maya/__init__.py +++ b/pype/maya/__init__.py @@ -2,14 +2,15 @@ import os import logging import weakref -from maya import utils, cmds, mel +from maya import utils, cmds from avalon import api as avalon, pipeline, maya from avalon.maya.pipeline import IS_HEADLESS +from avalon.tools import workfiles from pyblish import api as pyblish +from pypeapp import config from ..lib import ( - update_task_from_path, any_outdated ) from . import menu @@ -107,19 +108,39 @@ def on_init(_): # Force load objExport plug-in (requested by artists) cmds.loadPlugin("objExport", quiet=True) - # Force load objExport plug-in (requested by artists) - cmds.loadPlugin("spore", quiet=True) - from .customize import ( override_component_mask_commands, override_toolbox_ui ) safe_deferred(override_component_mask_commands) + launch_workfiles = True + try: + presets = config.get_presets() + launch_workfiles = presets['tools']['workfiles']['start_on_app_launch'] + except KeyError: + log.info( + "Workfiles app start on launch configuration was not found." + " Defaulting to False." + ) + launch_workfiles = False + + if launch_workfiles: + safe_deferred(launch_workfiles_app) + if not IS_HEADLESS: safe_deferred(override_toolbox_ui) +def launch_workfiles_app(*args): + workfiles.show( + os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="scene") + ) + ) + + def on_before_save(return_code, _): """Run validation for scene's FPS prior to saving""" return lib.validate_fps() diff --git a/pype/nuke/__init__.py b/pype/nuke/__init__.py index 106efee0e7..a2b1aeda6e 100644 --- a/pype/nuke/__init__.py +++ b/pype/nuke/__init__.py @@ -6,6 +6,7 @@ from pyblish import api as pyblish from .. import api from pype.nuke import menu +import logging from .lib import ( create_write_node @@ -13,14 +14,16 @@ from .lib import ( import nuke -# removing logger handler created in avalon_core -for name, handler in [(handler.get_name(), handler) - for handler in api.Logger.logging.root.handlers[:]]: - if "pype" not in str(name).lower(): - api.Logger.logging.root.removeHandler(handler) +from pypeapp import Logger + +# #removing logger handler created in avalon_core +# for name, handler in [(handler.get_name(), handler) +# for handler in Logger.logging.root.handlers[:]]: +# if "pype" not in str(name).lower(): +# Logger.logging.root.removeHandler(handler) -log = api.Logger.getLogger(__name__, "nuke") +log = Logger().get_logger(__name__, "nuke") AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") @@ -33,14 +36,13 @@ LOAD_PATH = os.path.join(PLUGINS_DIR, "nuke", "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "nuke", "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "nuke", "inventory") -self = sys.modules[__name__] -self.nLogger = None +# registering pyblish gui regarding settings in presets if os.getenv("PYBLISH_GUI", None): pyblish.register_gui(os.getenv("PYBLISH_GUI", None)) -class NukeHandler(api.Logger.logging.Handler): +class NukeHandler(logging.Handler): ''' Nuke Handler - emits logs into nuke's script editor. warning will emit nuke.warning() @@ -48,7 +50,7 @@ class NukeHandler(api.Logger.logging.Handler): ''' def __init__(self): - api.Logger.logging.Handler.__init__(self) + logging.Handler.__init__(self) self.set_name("Pype_Nuke_Handler") def emit(self, record): @@ -61,6 +63,7 @@ class NukeHandler(api.Logger.logging.Handler): "fatal", "error" ]: + msg = self.format(record) nuke.message(msg) @@ -68,12 +71,9 @@ class NukeHandler(api.Logger.logging.Handler): nuke_handler = NukeHandler() if nuke_handler.get_name() \ not in [handler.get_name() - for handler in api.Logger.logging.root.handlers[:]]: - api.Logger.logging.getLogger().addHandler(nuke_handler) - api.Logger.logging.getLogger().setLevel(api.Logger.logging.INFO) - -if not self.nLogger: - self.nLogger = api.Logger + for handler in logging.root.handlers[:]]: + logging.getLogger().addHandler(nuke_handler) + logging.getLogger().setLevel(logging.INFO) def reload_config(): @@ -106,8 +106,14 @@ def reload_config(): def install(): - api.set_avalon_workdir() - reload_config() + # api.set_avalon_workdir() + # reload_config() + + # import sys + + # for path in sys.path: + # if path.startswith("C:\\Users\\Public"): + # sys.path.remove(path) log.info("Registering Nuke plug-ins..") pyblish.register_plugin_path(PUBLISH_PATH) @@ -146,7 +152,7 @@ def uninstall(): def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node passthrough states on instance toggles.""" - self.log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( + log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( instance, old_value, new_value)) from avalon.nuke import ( diff --git a/pype/nuke/lib.py b/pype/nuke/lib.py index 06296ded31..20e7dfb210 100644 --- a/pype/nuke/lib.py +++ b/pype/nuke/lib.py @@ -13,7 +13,9 @@ from .templates import ( get_colorspace ) -log = pype.Logger.getLogger(__name__, "nuke") +from pypeapp import Logger +log = Logger().get_logger(__name__, "nuke") + self = sys.modules[__name__] self._project = None @@ -27,6 +29,53 @@ def onScriptLoad(): nuke.tcl('load movWriter') +def checkInventoryVersions(): + """ + Actiual version idetifier of Loaded containers + + Any time this function is run it will check all nodes and filter only Loader nodes for its version. It will get all versions from database + and check if the node is having actual version. If not then it will color it to red. + + """ + + + # get all Loader nodes by avalon attribute metadata + for each in nuke.allNodes(): + if each.Class() == 'Read': + container = avalon.nuke.parse_container(each) + + if container: + node = container["_tool"] + avalon_knob_data = get_avalon_knob_data(node) + + # get representation from io + representation = io.find_one({ + "type": "representation", + "_id": io.ObjectId(avalon_knob_data["representation"]) + }) + + # Get start frame from version data + version = io.find_one({ + "type": "version", + "_id": representation["parent"] + }) + + # get all versions in list + versions = io.find({ + "type": "version", + "parent": version["parent"] + }).distinct('name') + + max_version = max(versions) + + # check the available version and do match + # change color of node if not max verion + if version.get("name") not in [max_version]: + node["tile_color"].setValue(int("0xd84f20ff", 16)) + else: + node["tile_color"].setValue(int("0x4ecd25ff", 16)) + + def writes_version_sync(): try: rootVersion = pype.get_version_from_path(nuke.root().name()) @@ -56,7 +105,8 @@ def writes_version_sync(): node_new_file = node_file.replace(node_version, new_version) each['file'].setValue(node_new_file) except Exception as e: - log.debug("Write node: `{}` has no version in path: {}".format(each.name(), e)) + log.debug( + "Write node: `{}` has no version in path: {}".format(each.name(), e)) def version_up_script(): @@ -72,7 +122,7 @@ def get_render_path(node): data_preset = { "class": data['avalon']['family'], "preset": data['avalon']['families'] - } + } nuke_dataflow_writes = get_dataflow(**data_preset) nuke_colorspace_writes = get_colorspace(**data_preset) @@ -85,7 +135,8 @@ def get_render_path(node): }) anatomy_filled = format_anatomy(data) - return anatomy_filled.render.path.replace("\\", "/") + return anatomy_filled["render"]["path"].replace("\\", "/") + def format_anatomy(data): from .templates import ( @@ -93,28 +144,29 @@ def format_anatomy(data): ) anatomy = get_anatomy() - + log.info("__ anatomy.templates: {}".format(anatomy.templates)) # TODO: perhaps should be in try! - padding = anatomy.render.padding + padding = int(anatomy.templates['render']['padding']) version = data.get("version", None) if not version: file = script_name() data["version"] = pype.get_version_from_path(file) data.update({ + "root": api.Session["AVALON_PROJECTS"], "subset": data["avalon"]["subset"], "asset": data["avalon"]["asset"], "task": str(pype.get_task()).lower(), "family": data["avalon"]["family"], "project": {"name": pype.get_project_name(), "code": pype.get_project_code()}, - "representation": data["nuke_dataflow_writes"].file_type, + "representation": data["nuke_dataflow_writes"]["file_type"], "app": data["application"]["application_dir"], "hierarchy": pype.get_hierarchy(), "frame": "#" * padding, }) - - # log.info("format_anatomy:anatomy: {}".format(anatomy)) + log.info("__ data: {}".format(data)) + log.info("__ format_anatomy: {}".format(anatomy.format(data))) return anatomy.format(data) @@ -139,10 +191,8 @@ def create_write_node(name, data): except Exception as e: log.error("problem with resolving anatomy tepmlate: {}".format(e)) - log.debug("anatomy_filled.render: {}".format(anatomy_filled.render)) - _data = OrderedDict({ - "file": str(anatomy_filled.render.path).replace("\\", "/") + "file": str(anatomy_filled["render"]["path"]).replace("\\", "/") }) # adding dataflow template @@ -159,7 +209,7 @@ def create_write_node(name, data): log.debug(_data) _data["frame_range"] = data.get("frame_range", None) - + log.info("__ _data3: {}".format(_data)) instance = avalon.nuke.lib.add_write_node( name, **_data @@ -168,6 +218,7 @@ def create_write_node(name, data): add_rendering_knobs(instance) return instance + def add_rendering_knobs(node): if "render" not in node.knobs(): knob = nuke.Boolean_Knob("render", "Render") @@ -193,8 +244,8 @@ def set_viewers_colorspace(viewer): erased_viewers = [] for v in viewers: - v['viewerProcess'].setValue(str(viewer.viewerProcess)) - if str(viewer.viewerProcess) not in v['viewerProcess'].value(): + v['viewerProcess'].setValue(str(viewer["viewerProcess"])) + if str(viewer["viewerProcess"]) not in v['viewerProcess'].value(): copy_inputs = v.dependencies() copy_knobs = {k: v[k].value() for k in v.knobs() if k not in filter_knobs} @@ -216,7 +267,7 @@ def set_viewers_colorspace(viewer): nv[k].setValue(v) # set viewerProcess - nv['viewerProcess'].setValue(str(viewer.viewerProcess)) + nv['viewerProcess'].setValue(str(viewer["viewerProcess"])) if erased_viewers: log.warning( @@ -227,6 +278,17 @@ def set_viewers_colorspace(viewer): def set_root_colorspace(root_dict): assert isinstance(root_dict, dict), log.error( "set_root_colorspace(): argument should be dictionary") + + # first set OCIO + if nuke.root()["colorManagement"].value() not in str(root_dict["colorManagement"]): + nuke.root()["colorManagement"].setValue( + str(root_dict["colorManagement"])) + + # second set ocio version + if nuke.root()["OCIO_config"].value() not in str(root_dict["OCIO_config"]): + nuke.root()["OCIO_config"].setValue(str(root_dict["OCIO_config"])) + + # then set the rest for knob, value in root_dict.items(): if nuke.root()[knob].value() not in value: nuke.root()[knob].setValue(str(value)) @@ -242,20 +304,20 @@ def set_writes_colorspace(write_dict): def set_colorspace(): from pype import api as pype - nuke_colorspace = getattr(pype.Colorspace, "nuke", None) + nuke_colorspace = pype.Colorspace.get("nuke", None) try: - set_root_colorspace(nuke_colorspace.root) + set_root_colorspace(nuke_colorspace["root"]) except AttributeError: log.error( "set_colorspace(): missing `root` settings in template") try: - set_viewers_colorspace(nuke_colorspace.viewer) + set_viewers_colorspace(nuke_colorspace["viewer"]) except AttributeError: log.error( "set_colorspace(): missing `viewer` settings in template") try: - set_writes_colorspace(nuke_colorspace.write) + set_writes_colorspace(nuke_colorspace["write"]) except AttributeError: log.error( "set_colorspace(): missing `write` settings in template") @@ -320,7 +382,7 @@ def reset_resolution(): check_format = used_formats[-1] format_name = "{}_{}".format( project["name"], - int(used_formats[-1].name()[-1])+1 + int(used_formats[-1].name()[-1]) + 1 ) log.info( "Format exists: {}. " @@ -438,7 +500,7 @@ def get_additional_data(container): def get_write_node_template_attr(node): ''' Gets all defined data from presets - + ''' # get avalon data from node data = dict() @@ -446,7 +508,7 @@ def get_write_node_template_attr(node): data_preset = { "class": data['avalon']['family'], "preset": data['avalon']['families'] - } + } # get template data nuke_dataflow_writes = get_dataflow(**data_preset) diff --git a/pype/nuke/templates.py b/pype/nuke/templates.py index 16cb6062a2..b3de6970d0 100644 --- a/pype/nuke/templates.py +++ b/pype/nuke/templates.py @@ -1,6 +1,6 @@ from pype import api as pype -log = pype.Logger.getLogger(__name__, "nuke") +log = pype.Logger().get_logger(__name__, "nuke") def get_anatomy(**kwarg): @@ -15,10 +15,12 @@ def get_dataflow(**kwarg): assert any([host, cls]), log.error("nuke.templates.get_dataflow():" "Missing mandatory kwargs `host`, `cls`") - nuke_dataflow = getattr(pype.Dataflow, str(host), None) - nuke_dataflow_node = getattr(nuke_dataflow.nodes, str(cls), None) + nuke_dataflow = pype.Dataflow.get(str(host), None) + nuke_dataflow_nodes = nuke_dataflow.get('nodes', None) + nuke_dataflow_node = nuke_dataflow_nodes.get(str(cls), None) + if preset: - nuke_dataflow_node = getattr(nuke_dataflow_node, str(preset), None) + nuke_dataflow_node = nuke_dataflow_node.get(str(preset), None) log.info("Dataflow: {}".format(nuke_dataflow_node)) return nuke_dataflow_node @@ -32,10 +34,10 @@ def get_colorspace(**kwarg): assert any([host, cls]), log.error("nuke.templates.get_colorspace():" "Missing mandatory kwargs `host`, `cls`") - nuke_colorspace = getattr(pype.Colorspace, str(host), None) - nuke_colorspace_node = getattr(nuke_colorspace, str(cls), None) + nuke_colorspace = pype.Colorspace.get(str(host), None) + nuke_colorspace_node = nuke_colorspace.get(str(cls), None) if preset: - nuke_colorspace_node = getattr(nuke_colorspace_node, str(preset), None) + nuke_colorspace_node = nuke_colorspace_node.get(str(preset), None) log.info("Colorspace: {}".format(nuke_colorspace_node)) return nuke_colorspace_node diff --git a/pype/nukestudio/__init__.py b/pype/nukestudio/__init__.py new file mode 100644 index 0000000000..36f3453cf7 --- /dev/null +++ b/pype/nukestudio/__init__.py @@ -0,0 +1,117 @@ +import os +import sys +from avalon import api as avalon +from pyblish import api as pyblish + +from .. import api + +from .menu import install as menu_install + +from .lib import ( + show, + setup, + add_to_filemenu +) + + +from pypeapp import Logger + + +log = Logger().get_logger(__name__, "nukestudio") + +AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") + +PARENT_DIR = os.path.dirname(__file__) +PACKAGE_DIR = os.path.dirname(PARENT_DIR) +PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins") + +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "nukestudio", "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "nukestudio", "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "nukestudio", "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "nukestudio", "inventory") + + +if os.getenv("PYBLISH_GUI", None): + pyblish.register_gui(os.getenv("PYBLISH_GUI", None)) + + +def reload_config(): + """Attempt to reload pipeline at run-time. + + CAUTION: This is primarily for development and debugging purposes. + + """ + + import importlib + + for module in ( + "pypeapp", + "{}.api".format(AVALON_CONFIG), + "{}.templates".format(AVALON_CONFIG), + "{}.nukestudio.inventory".format(AVALON_CONFIG), + "{}.nukestudio.lib".format(AVALON_CONFIG), + "{}.nukestudio.menu".format(AVALON_CONFIG), + ): + log.info("Reloading module: {}...".format(module)) + try: + module = importlib.import_module(module) + reload(module) + except Exception as e: + log.warning("Cannot reload module: {}".format(e)) + importlib.reload(module) + + +def install(config): + + # api.set_avalon_workdir() + # reload_config() + + # import sys + # for path in sys.path: + # if path.startswith("C:\\Users\\Public"): + # sys.path.remove(path) + + log.info("Registering NukeStudio plug-ins..") + pyblish.register_host("nukestudio") + pyblish.register_plugin_path(PUBLISH_PATH) + avalon.register_plugin_path(avalon.Loader, LOAD_PATH) + avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + + # Disable all families except for the ones we explicitly want to see + family_states = [ + "write", + "review" + ] + + avalon.data["familiesStateDefault"] = False + avalon.data["familiesStateToggled"] = family_states + + menu_install() + + # load data from templates + api.load_data_from_templates() + + +def uninstall(): + log.info("Deregistering NukeStudio plug-ins..") + pyblish.deregister_host("nukestudio") + pyblish.deregister_plugin_path(PUBLISH_PATH) + avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) + avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + + # reset data from templates + api.reset_data_from_templates() + + +def ls(): + """List available containers. + + This function is used by the Container Manager in Nuke. You'll + need to implement a for-loop that then *yields* one Container at + a time. + + See the `container.json` schema for details on how it should look, + and the Maya equivalent, which is in `avalon.maya.pipeline` + """ + return diff --git a/pype/nukestudio/lib.py b/pype/nukestudio/lib.py new file mode 100644 index 0000000000..fba8572235 --- /dev/null +++ b/pype/nukestudio/lib.py @@ -0,0 +1,205 @@ +# Standard library +import os +import sys + +# Pyblish libraries +import pyblish.api + +# Host libraries +import hiero + +from PySide2 import (QtWidgets, QtGui) + + +cached_process = None + + +self = sys.modules[__name__] +self._has_been_setup = False +self._has_menu = False +self._registered_gui = None + + +def setup(console=False, port=None, menu=True): + """Setup integration + + Registers Pyblish for Hiero plug-ins and appends an item to the File-menu + + Arguments: + console (bool): Display console with GUI + port (int, optional): Port from which to start looking for an + available port to connect with Pyblish QML, default + provided by Pyblish Integration. + menu (bool, optional): Display file menu in Hiero. + """ + + if self._has_been_setup: + teardown() + + add_submission() + + if menu: + add_to_filemenu() + self._has_menu = True + + self._has_been_setup = True + print("pyblish: Loaded successfully.") + + +def show(): + """Try showing the most desirable GUI + This function cycles through the currently registered + graphical user interfaces, if any, and presents it to + the user. + """ + + return (_discover_gui() or _show_no_gui)() + + +def _discover_gui(): + """Return the most desirable of the currently registered GUIs""" + + # Prefer last registered + guis = reversed(pyblish.api.registered_guis()) + + for gui in list(guis) + ["pyblish_lite"]: + try: + gui = __import__(gui).show + except (ImportError, AttributeError): + continue + else: + return gui + + +def teardown(): + """Remove integration""" + if not self._has_been_setup: + return + + if self._has_menu: + remove_from_filemenu() + self._has_menu = False + + self._has_been_setup = False + print("pyblish: Integration torn down successfully") + + +def remove_from_filemenu(): + raise NotImplementedError("Implement me please.") + + +def add_to_filemenu(): + PublishAction() + + +class PyblishSubmission(hiero.exporters.FnSubmission.Submission): + + def __init__(self): + hiero.exporters.FnSubmission.Submission.__init__(self) + + def addToQueue(self): + # Add submission to Hiero module for retrieval in plugins. + hiero.submission = self + show() + + +def add_submission(): + registry = hiero.core.taskRegistry + registry.addSubmission("Pyblish", PyblishSubmission) + + +class PublishAction(QtWidgets.QAction): + def __init__(self): + QtWidgets.QAction.__init__(self, "Publish", None) + self.triggered.connect(self.publish) + + for interest in ["kShowContextMenu/kTimeline", + "kShowContextMenukBin", + "kShowContextMenu/kSpreadsheet"]: + hiero.core.events.registerInterest(interest, self.eventHandler) + + self.setShortcut("Ctrl+Alt+P") + + def publish(self): + # Removing "submission" attribute from hiero module, to prevent tasks + # from getting picked up when not using the "Export" dialog. + if hasattr(hiero, "submission"): + del hiero.submission + show() + + def eventHandler(self, event): + # Add the Menu to the right-click menu + event.menu.addAction(self) + + +def _show_no_gui(): + """Popup with information about how to register a new GUI + In the event of no GUI being registered or available, + this information dialog will appear to guide the user + through how to get set up with one. + """ + + messagebox = QtWidgets.QMessageBox() + messagebox.setIcon(messagebox.Warning) + messagebox.setWindowIcon(QtGui.QIcon(os.path.join( + os.path.dirname(pyblish.__file__), + "icons", + "logo-32x32.svg")) + ) + + spacer = QtWidgets.QWidget() + spacer.setMinimumSize(400, 0) + spacer.setSizePolicy(QtWidgets.QSizePolicy.Minimum, + QtWidgets.QSizePolicy.Expanding) + + layout = messagebox.layout() + layout.addWidget(spacer, layout.rowCount(), 0, 1, layout.columnCount()) + + messagebox.setWindowTitle("Uh oh") + messagebox.setText("No registered GUI found.") + + if not pyblish.api.registered_guis(): + messagebox.setInformativeText( + "In order to show you a GUI, one must first be registered. " + "Press \"Show details...\" below for information on how to " + "do that.") + + messagebox.setDetailedText( + "Pyblish supports one or more graphical user interfaces " + "to be registered at once, the next acting as a fallback to " + "the previous." + "\n" + "\n" + "For example, to use Pyblish Lite, first install it:" + "\n" + "\n" + "$ pip install pyblish-lite" + "\n" + "\n" + "Then register it, like so:" + "\n" + "\n" + ">>> import pyblish.api\n" + ">>> pyblish.api.register_gui(\"pyblish_lite\")" + "\n" + "\n" + "The next time you try running this, Lite will appear." + "\n" + "See http://api.pyblish.com/register_gui.html for " + "more information.") + + else: + messagebox.setInformativeText( + "None of the registered graphical user interfaces " + "could be found." + "\n" + "\n" + "Press \"Show details\" for more information.") + + messagebox.setDetailedText( + "These interfaces are currently registered." + "\n" + "%s" % "\n".join(pyblish.api.registered_guis())) + + messagebox.setStandardButtons(messagebox.Ok) + messagebox.exec_() diff --git a/pype/nukestudio/menu.py b/pype/nukestudio/menu.py new file mode 100644 index 0000000000..b6e17aeab2 --- /dev/null +++ b/pype/nukestudio/menu.py @@ -0,0 +1,92 @@ +import os +from avalon.api import Session +from pprint import pprint + +import hiero.core + +try: + from PySide.QtGui import * +except Exception: + from PySide2.QtGui import * + from PySide2.QtWidgets import * + +from hiero.ui import findMenuAction + + +# +def install(): + # here is the best place to add menu + from avalon.tools import ( + creator, + publish, + workfiles, + cbloader, + cbsceneinventory, + contextmanager, + libraryloader + ) + + menu_name = os.environ['PYPE_STUDIO_NAME'] + # Grab Hiero's MenuBar + M = hiero.ui.menuBar() + + # Add a Menu to the MenuBar + file_action = None + + try: + check_made_menu = findMenuAction(menu_name) + except Exception: + pass + + if not check_made_menu: + menu = M.addMenu(menu_name) + else: + menu = check_made_menu.menu() + + actions = [{ + 'action': QAction('Set Context', None), + 'function': contextmanager.show, + 'icon': QIcon('icons:Position.png') + }, + { + 'action': QAction('Create...', None), + 'function': creator.show, + 'icon': QIcon('icons:ColorAdd.png') + }, + { + 'action': QAction('Load...', None), + 'function': cbloader.show, + 'icon': QIcon('icons:CopyRectangle.png') + }, + { + 'action': QAction('Publish...', None), + 'function': publish.show, + 'icon': QIcon('icons:Output.png') + }, + { + 'action': QAction('Manage...', None), + 'function': cbsceneinventory.show, + 'icon': QIcon('icons:ModifyMetaData.png') + }, + { + 'action': QAction('Library...', None), + 'function': libraryloader.show, + 'icon': QIcon('icons:ColorAdd.png') + }] + + + # Create menu items + for a in actions: + pprint(a) + # create action + for k in a.keys(): + if 'action' in k: + action = a[k] + elif 'function' in k: + action.triggered.connect(a[k]) + elif 'icon' in k: + action.setIcon(a[k]) + + # add action to menu + menu.addAction(action) + hiero.ui.registerAction(action) diff --git a/pype/plugins/aport/publish/collect_context.py b/pype/plugins/aport/publish/collect_context.py index 4e27cefd09..f43e78120c 100644 --- a/pype/plugins/aport/publish/collect_context.py +++ b/pype/plugins/aport/publish/collect_context.py @@ -63,8 +63,8 @@ class CollectContextDataFromAport(pyblish.api.ContextPlugin): pyblish.api.register_host(host) # get path to studio templates - templates_dir = os.getenv("PYPE_STUDIO_TEMPLATES", None) - assert templates_dir, "Missing `PYPE_STUDIO_TEMPLATES` in os.environ" + templates_dir = os.getenv("PYPE_CONFIG", None) + assert templates_dir, "Missing `PYPE_CONFIG` in os.environ" # get presets for host presets_dir = os.path.join(templates_dir, "presets", host) diff --git a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py index e166af2954..75d9b6db15 100644 --- a/pype/plugins/ftrack/publish/integrate_ftrack_instances.py +++ b/pype/plugins/ftrack/publish/integrate_ftrack_instances.py @@ -26,15 +26,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): 'render': 'render', 'nukescript': 'comp', 'review': 'mov'} - exclude = [] def process(self, instance): - for ex in self.exclude: - if ex in instance.data['families']: - return - self.log.debug('instance {}'.format(instance)) - assumed_data = instance.data["assumedTemplateData"] assumed_version = assumed_data["version"] version_number = int(assumed_version) @@ -60,8 +54,6 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): self.log.debug('dest ext: ' + ext) thumbnail = False - - if ext in ['.mov']: if not instance.data.get('startFrameReview'): instance.data['startFrameReview'] = instance.data['startFrame'] @@ -70,12 +62,13 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): location = ft_session.query( 'Location where name is "ftrack.server"').one() component_data = { - "name": "ftrackreview-mp4", # Default component name is "main". + # Default component name is "main". + "name": "ftrackreview-mp4", "metadata": {'ftr_meta': json.dumps({ 'frameIn': int(instance.data['startFrameReview']), 'frameOut': int(instance.data['startFrameReview']), 'frameRate': 25})} - } + } elif ext in [".jpg", ".jpeg"]: component_data = { "name": "thumbnail" # Default component name is "main". diff --git a/pype/plugins/global/load/open_file.py b/pype/plugins/global/load/open_file.py new file mode 100644 index 0000000000..9425eaab04 --- /dev/null +++ b/pype/plugins/global/load/open_file.py @@ -0,0 +1,58 @@ +import sys +import os +import subprocess + +from avalon import api + + +def open(filepath): + """Open file with system default executable""" + if sys.platform.startswith('darwin'): + subprocess.call(('open', filepath)) + elif os.name == 'nt': + os.startfile(filepath) + elif os.name == 'posix': + subprocess.call(('xdg-open', filepath)) + + +class Openfile(api.Loader): + """Open Image Sequence with system default""" + + families = ["write"] + representations = ["*"] + + label = "Open" + order = -10 + icon = "play-circle" + color = "orange" + + def load(self, context, name, namespace, data): + from avalon.vendor import clique + + directory = os.path.dirname(self.fname) + pattern = clique.PATTERNS["frames"] + + files = os.listdir(directory) + representation = context["representation"] + + ext = representation["name"] + path = representation["data"]["path"] + + if ext in ["#"]: + collections, remainder = clique.assemble(files, + patterns=[pattern], + minimum_items=1) + + seqeunce = collections[0] + + first_image = list(seqeunce)[0] + filepath = os.path.normpath(os.path.join(directory, first_image)) + else: + file = [f for f in files + if ext in f + if "#" not in f][0] + filepath = os.path.normpath(os.path.join(directory, file)) + + self.log.info("Opening : {}".format(filepath)) + + open(filepath) diff --git a/pype/plugins/global/load/open_imagesequence.py b/pype/plugins/global/load/open_imagesequence.py deleted file mode 100644 index a910625733..0000000000 --- a/pype/plugins/global/load/open_imagesequence.py +++ /dev/null @@ -1,49 +0,0 @@ -import sys -import os -import subprocess - -from avalon import api - - -def open(filepath): - """Open file with system default executable""" - if sys.platform.startswith('darwin'): - subprocess.call(('open', filepath)) - elif os.name == 'nt': - os.startfile(filepath) - elif os.name == 'posix': - subprocess.call(('xdg-open', filepath)) - - -class PlayImageSequence(api.Loader): - """Open Image Sequence with system default""" - - families = ["write"] - representations = ["*"] - - label = "Play sequence" - order = -10 - icon = "play-circle" - color = "orange" - - def load(self, context, name, namespace, data): - - directory = self.fname - from avalon.vendor import clique - - pattern = clique.PATTERNS["frames"] - files = os.listdir(directory) - collections, remainder = clique.assemble(files, - patterns=[pattern], - minimum_items=1) - - assert not remainder, ("There shouldn't have been a remainder for " - "'%s': %s" % (directory, remainder)) - - seqeunce = collections[0] - first_image = list(seqeunce)[0] - filepath = os.path.normpath(os.path.join(directory, first_image)) - - self.log.info("Opening : {}".format(filepath)) - - open(filepath) diff --git a/pype/plugins/global/publish/collect_assumed_destination.py b/pype/plugins/global/publish/collect_assumed_destination.py index 7de358b422..fa6a3d9423 100644 --- a/pype/plugins/global/publish/collect_assumed_destination.py +++ b/pype/plugins/global/publish/collect_assumed_destination.py @@ -4,15 +4,71 @@ import pyblish.api from avalon import io, api -class CollectAssumedDestination(pyblish.api.InstancePlugin): +class CollectAssumedDestination(pyblish.api.ContextPlugin): """Generate the assumed destination path where the file will be stored""" label = "Collect Assumed Destination" order = pyblish.api.CollectorOrder + 0.498 exclude_families = ["clip"] - def process(self, instance): - """Create a destination filepath based on the current data available + def process(self, context): + for instance in context: + self.process_item(instance) + + def process_item(self, instance): + if [ef for ef in self.exclude_families + if instance.data["family"] in ef]: + return + + self.create_destination_template(instance) + + template_data = instance.data["assumedTemplateData"] + + anatomy = instance.context.data['anatomy'] + # self.log.info(anatomy.anatomy()) + self.log.info(anatomy.templates) + # template = anatomy.publish.path + anatomy_filled = anatomy.format(template_data) + self.log.info(anatomy_filled) + mock_template = anatomy_filled["publish"]["path"] + + # For now assume resources end up in a "resources" folder in the + # published folder + mock_destination = os.path.join(os.path.dirname(mock_template), + "resources") + + # Clean the path + mock_destination = os.path.abspath(os.path.normpath(mock_destination)) + + # Define resource destination and transfers + resources = instance.data.get("resources", list()) + transfers = instance.data.get("transfers", list()) + for resource in resources: + + # Add destination to the resource + source_filename = os.path.basename(resource["source"]) + destination = os.path.join(mock_destination, source_filename) + + # Force forward slashes to fix issue with software unable + # to work correctly with backslashes in specific scenarios + # (e.g. escape characters in PLN-151 V-Ray UDIM) + destination = destination.replace("\\", "/") + + resource['destination'] = destination + + # Collect transfers for the individual files of the resource + # e.g. all individual files of a cache or UDIM textures. + files = resource['files'] + for fsrc in files: + fname = os.path.basename(fsrc) + fdest = os.path.join(mock_destination, fname) + transfers.append([fsrc, fdest]) + + instance.data["resources"] = resources + instance.data["transfers"] = transfers + + def create_destination_template(self, instance): + """Create a filepath based on the current data available Example template: {root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/ @@ -84,5 +140,5 @@ class CollectAssumedDestination(pyblish.api.InstancePlugin): # We take the parent folder of representation 'filepath' instance.data["assumedDestination"] = os.path.dirname( - (anatomy.format(template_data)).publish.path + (anatomy.format(template_data))["publish"]["path"] ) diff --git a/pype/plugins/global/publish/collect_presets.py b/pype/plugins/global/publish/collect_presets.py new file mode 100644 index 0000000000..8edf9797de --- /dev/null +++ b/pype/plugins/global/publish/collect_presets.py @@ -0,0 +1,14 @@ +from pyblish import api +from pypeapp import config + + +class CollectPresets(api.ContextPlugin): + """Collect Presets.""" + + order = api.CollectorOrder + label = "Collect Presets" + + def process(self, context): + context.data["presets"] = config.get_presets() + self.log.info(context.data["presets"]) + return diff --git a/pype/plugins/global/publish/collect_scene_version.py b/pype/plugins/global/publish/collect_scene_version.py index 06bc8e3a53..12075e2417 100644 --- a/pype/plugins/global/publish/collect_scene_version.py +++ b/pype/plugins/global/publish/collect_scene_version.py @@ -1,8 +1,8 @@ import os import pyblish.api -import os import pype.api as pype + class CollectSceneVersion(pyblish.api.ContextPlugin): """Finds version in the filename or passes the one found in the context Arguments: @@ -16,8 +16,10 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): filename = os.path.basename(context.data.get('currentFile')) - rootVersion = pype.get_version_from_path(filename) + if '' in filename: + return + rootVersion = pype.get_version_from_path(filename) context.data['version'] = rootVersion self.log.info('Scene Version: %s' % context.data('version')) diff --git a/pype/plugins/global/publish/collect_templates.py b/pype/plugins/global/publish/collect_templates.py index fb6de894bd..b59b20892b 100644 --- a/pype/plugins/global/publish/collect_templates.py +++ b/pype/plugins/global/publish/collect_templates.py @@ -1,5 +1,6 @@ import pype.api as pype +from pypeapp import Anatomy import pyblish.api @@ -11,6 +12,6 @@ class CollectTemplates(pyblish.api.ContextPlugin): label = "Collect Templates" def process(self, context): - pype.load_data_from_templates() - context.data['anatomy'] = pype.Anatomy + # pype.load_data_from_templates() + context.data['anatomy'] = Anatomy() self.log.info("Anatomy templates collected...") diff --git a/pype/plugins/global/publish/integrate.py b/pype/plugins/global/publish/integrate.py index 00096a95ee..fce9d26220 100644 --- a/pype/plugins/global/publish/integrate.py +++ b/pype/plugins/global/publish/integrate.py @@ -210,10 +210,10 @@ class IntegrateAsset(pyblish.api.InstancePlugin): src = os.path.join(stagingdir, fname) anatomy_filled = anatomy.format(template_data) - dst = anatomy_filled.publish.path + dst = anatomy_filled["publish"]["path"] instance.data["transfers"].append([src, dst]) - template = anatomy.publish.path + template = anatomy.templates["publish"]["path"] else: # Single file @@ -234,10 +234,10 @@ class IntegrateAsset(pyblish.api.InstancePlugin): src = os.path.join(stagingdir, fname) anatomy_filled = anatomy.format(template_data) - dst = anatomy_filled.publish.path + dst = anatomy_filled["publish"]["path"] instance.data["transfers"].append([src, dst]) - template = anatomy.publish.path + template = anatomy.templates["publish"]["path"] representation = { "schema": "pype:representation-2.0", diff --git a/pype/plugins/global/publish/integrate_rendered_frames.py b/pype/plugins/global/publish/integrate_rendered_frames.py index 8e7e2a59c4..e814e31640 100644 --- a/pype/plugins/global/publish/integrate_rendered_frames.py +++ b/pype/plugins/global/publish/integrate_rendered_frames.py @@ -195,7 +195,7 @@ class IntegrateFrames(pyblish.api.InstancePlugin): template_data["frame"] = src_collection.format( "{padding}") % i anatomy_filled = anatomy.format(template_data) - test_dest_files.append(anatomy_filled.render.path) + test_dest_files.append(anatomy_filled["render"]["path"]) dst_collections, remainder = clique.assemble(test_dest_files) dst_collection = dst_collections[0] @@ -223,7 +223,6 @@ class IntegrateFrames(pyblish.api.InstancePlugin): # template_data.pop("frame", None) - anatomy.pop("frame", None) fname = files @@ -239,15 +238,21 @@ class IntegrateFrames(pyblish.api.InstancePlugin): src = os.path.join(stagingdir, fname) anatomy_filled = anatomy.format(template_data) - dst = anatomy_filled.render.path + dst = anatomy_filled["render"]["path"] instance.data["transfers"].append([src, dst]) - template_data["frame"] = "#" * anatomy.render.padding + if ext[1:] not in ["jpeg", "jpg", "mov", "mp4", "wav"]: + template_data["frame"] = "#" * int(anatomy_filled["render"]["padding"]) + anatomy_filled = anatomy.format(template_data) - path_to_save = anatomy_filled.render.path - template = anatomy.render.fullpath - self.log.debug('ext[1:]: {}'.format(ext[1:])) + path_to_save = anatomy_filled["render"]["path"] + template = anatomy.templates["render"]["path"] + + self.log.debug("path_to_save: {}".format(path_to_save)) + + + representation = { "schema": "pype:representation-2.0", diff --git a/pype/plugins/global/publish/validate_templates.py b/pype/plugins/global/publish/validate_templates.py index 8f8eb45686..a2c7b24ac1 100644 --- a/pype/plugins/global/publish/validate_templates.py +++ b/pype/plugins/global/publish/validate_templates.py @@ -1,7 +1,5 @@ import pyblish.api -from app.api import ( - Templates -) +import os class ValidateTemplates(pyblish.api.ContextPlugin): """Check if all templates were filed""" @@ -14,27 +12,30 @@ class ValidateTemplates(pyblish.api.ContextPlugin): anatomy = context.data["anatomy"] if not anatomy: - raise RuntimeError("Did not find templates") + raise RuntimeError("Did not find anatomy") else: - data = { "project": {"name": "D001_projectsx", + data = { + "root": os.environ["PYPE_STUDIO_PROJECTS_PATH"], + "project": {"name": "D001_projectsx", "code": "prjX"}, - "representation": "exr", + "ext": "exr", "version": 3, "task": "animation", "asset": "sh001", "hierarchy": "ep101/sq01/sh010"} - anatomy = context.data["anatomy"].format(data) - self.log.info(anatomy.work.path) + anatomy_filled = anatomy.format(data) + self.log.info(anatomy_filled) - data = { "project": {"name": "D001_projectsy", + data = {"root": os.environ["PYPE_STUDIO_PROJECTS_PATH"], + "project": {"name": "D001_projectsy", "code": "prjY"}, - "representation": "abc", + "ext": "abc", "version": 1, "task": "lookdev", "asset": "bob", "hierarchy": "ep101/sq01/bob"} - anatomy = context.data["anatomy"].format(data) - self.log.info(anatomy.work.file) + anatomy_filled = context.data["anatomy"].format(data) + self.log.info(anatomy_filled["work"]["folder"]) diff --git a/pype/plugins/launcher/actions/Aport.py b/pype/plugins/launcher/actions/Aport.py index 16906f6ce7..3773b90256 100644 --- a/pype/plugins/launcher/actions/Aport.py +++ b/pype/plugins/launcher/actions/Aport.py @@ -9,7 +9,7 @@ import pype.api as pype from pype.api import Logger -log = Logger.getLogger(__name__, "aport") +log = Logger().get_logger(__name__, "aport") class Aport(api.Action): diff --git a/pype/plugins/launcher/actions/AssetCreator.py b/pype/plugins/launcher/actions/AssetCreator.py index ff06895ae0..9787aae002 100644 --- a/pype/plugins/launcher/actions/AssetCreator.py +++ b/pype/plugins/launcher/actions/AssetCreator.py @@ -1,13 +1,8 @@ -import os -import sys -import acre - from avalon import api, lib -from pype.tools import assetcreator from pype.api import Logger -log = Logger.getLogger(__name__, "asset_creator") +log = Logger().get_logger(__name__, "asset_creator") class AssetCreator(api.Action): @@ -19,9 +14,23 @@ class AssetCreator(api.Action): def is_compatible(self, session): """Return whether the action is compatible with the session""" - if "AVALON_PROJECT" in session: - return True - return False + compatible = True + + # Check required modules. + module_names = [ + "ftrack_api", "ftrack_api_old", "pype.tools.assetcreator" + ] + for name in module_names: + try: + __import__(name) + except ImportError: + compatible = False + + # Check session environment. + if "AVALON_PROJECT" not in session: + compatible = False + + return compatible def process(self, session, **kwargs): asset = '' diff --git a/pype/plugins/maya/load/load_alembic.py b/pype/plugins/maya/load/load_alembic.py index 9fd4aa2108..d3d85249c5 100644 --- a/pype/plugins/maya/load/load_alembic.py +++ b/pype/plugins/maya/load/load_alembic.py @@ -1,6 +1,6 @@ import pype.maya.plugin import os -import json +from pypeapp import config class AbcLoader(pype.maya.plugin.ReferenceLoader): @@ -36,14 +36,8 @@ class AbcLoader(pype.maya.plugin.ReferenceLoader): cmds.makeIdentity(groupName, apply=False, rotate=True, translate=True, scale=True) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) - + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr(groupName + ".useOutlinerColor", 1) diff --git a/pype/plugins/maya/load/load_ass.py b/pype/plugins/maya/load/load_ass.py index c268ce70c5..979d4b5767 100644 --- a/pype/plugins/maya/load/load_ass.py +++ b/pype/plugins/maya/load/load_ass.py @@ -2,7 +2,7 @@ from avalon import api import pype.maya.plugin import os import pymel.core as pm -import json +from pypeapp import config class AssProxyLoader(pype.maya.plugin.ReferenceLoader): @@ -50,13 +50,8 @@ class AssProxyLoader(pype.maya.plugin.ReferenceLoader): proxyShape.dso.set(path) proxyShape.aiOverrideShaders.set(0) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: @@ -165,13 +160,8 @@ class AssStandinLoader(api.Loader): label = "{}:{}".format(namespace, name) root = pm.group(name=label, empty=True) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get('ass') if c is not None: diff --git a/pype/plugins/maya/load/load_camera.py b/pype/plugins/maya/load/load_camera.py index 989e80e979..e9bf265b98 100644 --- a/pype/plugins/maya/load/load_camera.py +++ b/pype/plugins/maya/load/load_camera.py @@ -1,6 +1,6 @@ import pype.maya.plugin import os -import json +from pypeapp import config class CameraLoader(pype.maya.plugin.ReferenceLoader): @@ -35,13 +35,8 @@ class CameraLoader(pype.maya.plugin.ReferenceLoader): cameras = cmds.ls(nodes, type="camera") - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: diff --git a/pype/plugins/maya/load/load_fbx.py b/pype/plugins/maya/load/load_fbx.py index b580257334..14df300c3c 100644 --- a/pype/plugins/maya/load/load_fbx.py +++ b/pype/plugins/maya/load/load_fbx.py @@ -1,6 +1,6 @@ import pype.maya.plugin import os -import json +from pypeapp import config class FBXLoader(pype.maya.plugin.ReferenceLoader): @@ -36,13 +36,9 @@ class FBXLoader(pype.maya.plugin.ReferenceLoader): groupName="{}:{}".format(namespace, name)) groupName = "{}:{}".format(namespace, name) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: diff --git a/pype/plugins/maya/load/load_mayaascii.py b/pype/plugins/maya/load/load_mayaascii.py index 549d1dff4c..03a15b0524 100644 --- a/pype/plugins/maya/load/load_mayaascii.py +++ b/pype/plugins/maya/load/load_mayaascii.py @@ -1,5 +1,5 @@ import pype.maya.plugin -import json +from pypeapp import config import os @@ -36,13 +36,9 @@ class MayaAsciiLoader(pype.maya.plugin.ReferenceLoader): self[:] = nodes groupName = "{}:{}".format(namespace, name) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: diff --git a/pype/plugins/maya/load/load_model.py b/pype/plugins/maya/load/load_model.py index 16f3556de7..913d4d9e79 100644 --- a/pype/plugins/maya/load/load_model.py +++ b/pype/plugins/maya/load/load_model.py @@ -1,7 +1,8 @@ from avalon import api import pype.maya.plugin -import json import os +from pypeapp import config +reload(config) class ModelLoader(pype.maya.plugin.ReferenceLoader): @@ -21,18 +22,6 @@ class ModelLoader(pype.maya.plugin.ReferenceLoader): import maya.cmds as cmds from avalon import maya - try: - family = context["representation"]["context"]["family"] - except ValueError: - family = "model" - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) - with maya.maintained_selection(): groupName = "{}:{}".format(namespace, name) @@ -46,7 +35,9 @@ class ModelLoader(pype.maya.plugin.ReferenceLoader): cmds.makeIdentity(groupName, apply=False, rotate=True, translate=True, scale=True) - c = colors.get(family) + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] + c = colors.get('model') if c is not None: cmds.setAttr(groupName + ".useOutlinerColor", 1) cmds.setAttr(groupName + ".outlinerColor", @@ -89,14 +80,9 @@ class GpuCacheLoader(api.Loader): # Root group label = "{}:{}".format(namespace, name) root = cmds.group(name=label, empty=True) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get('model') if c is not None: cmds.setAttr(root + ".useOutlinerColor", 1) @@ -196,14 +182,8 @@ class AbcModelLoader(pype.maya.plugin.ReferenceLoader): cmds.makeIdentity(groupName, apply=False, rotate=True, translate=True, scale=True) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) - + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get('model') if c is not None: cmds.setAttr(groupName + ".useOutlinerColor", 1) diff --git a/pype/plugins/maya/load/load_rig.py b/pype/plugins/maya/load/load_rig.py index 1dcff45bb9..66b086c861 100644 --- a/pype/plugins/maya/load/load_rig.py +++ b/pype/plugins/maya/load/load_rig.py @@ -3,7 +3,7 @@ from maya import cmds import pype.maya.plugin from avalon import api, maya import os -import json +from pypeapp import config class RigLoader(pype.maya.plugin.ReferenceLoader): @@ -39,13 +39,8 @@ class RigLoader(pype.maya.plugin.ReferenceLoader): cmds.makeIdentity(groupName, apply=False, rotate=True, translate=True, scale=True) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: diff --git a/pype/plugins/maya/load/load_vdb_to_redshift.py b/pype/plugins/maya/load/load_vdb_to_redshift.py index 169c3bf34a..ee7c301b1b 100644 --- a/pype/plugins/maya/load/load_vdb_to_redshift.py +++ b/pype/plugins/maya/load/load_vdb_to_redshift.py @@ -1,7 +1,6 @@ from avalon import api import os -import json - +from pypeapp import config class LoadVDBtoRedShift(api.Loader): """Load OpenVDB in a Redshift Volume Shape""" @@ -55,13 +54,9 @@ class LoadVDBtoRedShift(api.Loader): # Root group label = "{}:{}".format(namespace, name) root = cmds.group(name=label, empty=True) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: diff --git a/pype/plugins/maya/load/load_vdb_to_vray.py b/pype/plugins/maya/load/load_vdb_to_vray.py index 58d6d1b56e..3b15b71e3e 100644 --- a/pype/plugins/maya/load/load_vdb_to_vray.py +++ b/pype/plugins/maya/load/load_vdb_to_vray.py @@ -1,5 +1,5 @@ from avalon import api -import json +from pypeapp import config import os @@ -47,13 +47,9 @@ class LoadVDBtoVRay(api.Loader): # Root group label = "{}:{}".format(namespace, name) root = cmds.group(name=label, empty=True) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: diff --git a/pype/plugins/maya/load/load_vrayproxy.py b/pype/plugins/maya/load/load_vrayproxy.py index a3a114440a..9b07dc7e30 100644 --- a/pype/plugins/maya/load/load_vrayproxy.py +++ b/pype/plugins/maya/load/load_vrayproxy.py @@ -1,6 +1,6 @@ from avalon.maya import lib from avalon import api -import json +from pypeapp import config import os import maya.cmds as cmds @@ -26,14 +26,6 @@ class VRayProxyLoader(api.Loader): except ValueError: family = "vrayproxy" - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) - asset_name = context['asset']["name"] namespace = namespace or lib.unique_namespace( asset_name + "_", @@ -54,6 +46,9 @@ class VRayProxyLoader(api.Loader): if not nodes: return + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] + c = colors.get(family) if c is not None: cmds.setAttr("{0}_{1}.useOutlinerColor".format(name, "GRP"), 1) diff --git a/pype/plugins/maya/load/load_yeti_cache.py b/pype/plugins/maya/load/load_yeti_cache.py index b19bed1393..dc976c0c98 100644 --- a/pype/plugins/maya/load/load_yeti_cache.py +++ b/pype/plugins/maya/load/load_yeti_cache.py @@ -9,6 +9,7 @@ from maya import cmds from avalon import api from avalon.maya import lib as avalon_lib, pipeline from pype.maya import lib +from pypeapp import config class YetiCacheLoader(api.Loader): @@ -54,13 +55,9 @@ class YetiCacheLoader(api.Loader): group_name = "{}:{}".format(namespace, name) group_node = cmds.group(nodes, name=group_name) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get(family) if c is not None: diff --git a/pype/plugins/maya/load/load_yeti_rig.py b/pype/plugins/maya/load/load_yeti_rig.py index c821c6ca02..eb75ff6bdc 100644 --- a/pype/plugins/maya/load/load_yeti_rig.py +++ b/pype/plugins/maya/load/load_yeti_rig.py @@ -1,6 +1,6 @@ import pype.maya.plugin import os -import json +from pypeapp import config class YetiRigLoader(pype.maya.plugin.ReferenceLoader): @@ -27,13 +27,9 @@ class YetiRigLoader(pype.maya.plugin.ReferenceLoader): groupName="{}:{}".format(namespace, name)) groupName = "{}:{}".format(namespace, name) - preset_file = os.path.join( - os.environ.get('PYPE_STUDIO_TEMPLATES'), - 'presets', 'tools', - 'family_colors.json' - ) - with open(preset_file, 'r') as cfile: - colors = json.load(cfile) + + presets = config.get_presets(project=os.environ['AVALON_PROJECT']) + colors = presets['plugins']['maya']['load']['colors'] c = colors.get('yetiRig') if c is not None: diff --git a/pype/plugins/maya/publish/collect_renderlayers.py b/pype/plugins/maya/publish/collect_renderlayers.py index d2c64e2117..e494a90878 100644 --- a/pype/plugins/maya/publish/collect_renderlayers.py +++ b/pype/plugins/maya/publish/collect_renderlayers.py @@ -21,11 +21,14 @@ class CollectMayaRenderlayers(pyblish.api.ContextPlugin): # Get render globals node try: render_globals = cmds.ls("renderglobalsMain")[0] + for instance in context: + self.log.debug(instance.name) + if instance.data['family'] == 'workfile': + instance.data['publish'] = True except IndexError: self.log.info("Skipping renderlayer collection, no " "renderGlobalsDefault found..") return - # Get all valid renderlayers # This is how Maya populates the renderlayer display rlm_attribute = "renderLayerManager.renderLayerId" @@ -51,7 +54,7 @@ class CollectMayaRenderlayers(pyblish.api.ContextPlugin): continue if layer.endswith("defaultRenderLayer"): - layername = "masterLayer" + continue else: # Remove Maya render setup prefix `rs_` layername = layer.split("rs_", 1)[-1] diff --git a/pype/plugins/maya/publish/submit_maya_deadline.py b/pype/plugins/maya/publish/submit_maya_deadline.py index 0a97a9b98f..db3ea85034 100644 --- a/pype/plugins/maya/publish/submit_maya_deadline.py +++ b/pype/plugins/maya/publish/submit_maya_deadline.py @@ -280,7 +280,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): clean_path = clean_path.replace('python2', 'python3') clean_path = clean_path.replace( os.path.normpath(environment['PYPE_STUDIO_CORE_MOUNT']), - os.path.normpath(environment['PYPE_STUDIO_CORE'])) + os.path.normpath(environment['PYPE_STUDIO_CORE_PATH'])) clean_environment[key] = clean_path environment = clean_environment diff --git a/pype/plugins/nuke/_publish_unused/test_instances.py b/pype/plugins/nuke/_publish_unused/test_instances.py new file mode 100644 index 0000000000..e3fcc4b8f1 --- /dev/null +++ b/pype/plugins/nuke/_publish_unused/test_instances.py @@ -0,0 +1,24 @@ +import pyblish.api + + +class IncrementTestPlugin(pyblish.api.ContextPlugin): + """Increment current script version.""" + + order = pyblish.api.CollectorOrder + 0.5 + label = "Test Plugin" + hosts = ['nuke'] + + def process(self, context): + instances = context[:] + + prerender_check = list() + families_check = list() + for instance in instances: + if ("prerender" in str(instance)): + prerender_check.append(instance) + if instance.data.get("families", None): + families_check.append(True) + + if len(prerender_check) != len(families_check): + self.log.info(prerender_check) + self.log.info(families_check) diff --git a/pype/plugins/nuke/create/create_read.py b/pype/plugins/nuke/create/create_read.py index 8597737167..e033bc63b0 100644 --- a/pype/plugins/nuke/create/create_read.py +++ b/pype/plugins/nuke/create/create_read.py @@ -6,7 +6,7 @@ from pype import api as pype import nuke -log = pype.Logger.getLogger(__name__, "nuke") +log = pype.Logger().get_logger(__name__, "nuke") class CrateRead(avalon.nuke.Creator): diff --git a/pype/plugins/nuke/create/create_write.py b/pype/plugins/nuke/create/create_write.py index 208fbf4aa0..b3c9117641 100644 --- a/pype/plugins/nuke/create/create_write.py +++ b/pype/plugins/nuke/create/create_write.py @@ -5,11 +5,12 @@ from pype.nuke import ( create_write_node ) from pype import api as pype +# from pypeapp import Logger import nuke -log = pype.Logger.getLogger(__name__, "nuke") +log = pype.Logger().get_logger(__name__, "nuke") def subset_to_families(subset, family, families): diff --git a/pype/plugins/nuke/load/actions.py b/pype/plugins/nuke/load/actions.py index 449567987a..917e7e71b0 100644 --- a/pype/plugins/nuke/load/actions.py +++ b/pype/plugins/nuke/load/actions.py @@ -5,7 +5,7 @@ from avalon import api from pype.api import Logger -log = Logger.getLogger(__name__, "nuke") +log = Logger().get_logger(__name__, "nuke") class SetFrameRangeLoader(api.Loader): diff --git a/pype/plugins/nuke/load/load_script_precomp.py b/pype/plugins/nuke/load/load_script_precomp.py new file mode 100644 index 0000000000..6fd76edd03 --- /dev/null +++ b/pype/plugins/nuke/load/load_script_precomp.py @@ -0,0 +1,170 @@ +from avalon import api, style, io +from pype.nuke.lib import get_avalon_knob_data +import nuke +import os +from pype.api import Logger +log = Logger().get_logger(__name__, "nuke") + + + +class LinkAsGroup(api.Loader): + """Copy the published file to be pasted at the desired location""" + + representations = ["nk"] + families = ["*"] + + label = "Load Precomp" + order = 10 + icon = "file" + color = style.colors.dark + + def load(self, context, name, namespace, data): + + from avalon.nuke import containerise + # for k, v in context.items(): + # log.info("key: `{}`, value: {}\n".format(k, v)) + version = context['version'] + version_data = version.get("data", {}) + + vname = version.get("name", None) + first = version_data.get("startFrame", None) + last = version_data.get("endFrame", None) + + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + file = self.fname.replace("\\", "/") + self.log.info("file: {}\n".format(self.fname)) + + precomp_name = context["representation"]["context"]["subset"] + + # Set global in point to start frame (if in version.data) + start = context["version"]["data"].get("startFrame", None) + + # add additional metadata from the version to imprint to Avalon knob + add_keys = ["startFrame", "endFrame", "handles", + "source", "author", "fps"] + + data_imprint = { + "start_frame": start, + "fstart": first, + "fend": last, + "version": vname + } + for k in add_keys: + data_imprint.update({k: context["version"]['data'][k]}) + data_imprint.update({"objectName": precomp_name}) + + # group context is set to precomp, so back up one level. + nuke.endGroup() + + # P = nuke.nodes.LiveGroup("file {}".format(file)) + P = nuke.createNode( + "Precomp", + "file {}".format(file)) + + # Set colorspace defined in version data + colorspace = context["version"]["data"].get("colorspace", None) + self.log.info("colorspace: {}\n".format(colorspace)) + + + # ['version', 'file', 'reading', 'output', 'useOutput'] + + P["name"].setValue("{}_{}".format(name, namespace)) + P["useOutput"].setValue(True) + + with P: + # iterate trough all nodes in group node and find pype writes + writes = [n.name() for n in nuke.allNodes() + if n.Class() == "Write" + if get_avalon_knob_data(n)] + + # create panel for selecting output + panel_choices = " ".join(writes) + panel_label = "Select write node for output" + p = nuke.Panel("Select Write Node") + p.addEnumerationPulldown( + panel_label, panel_choices) + p.show() + P["output"].setValue(p.value(panel_label)) + + P["tile_color"].setValue(0xff0ff0ff) + + return containerise( + node=P, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__, + data=data_imprint) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + """Update the Loader's path + + Nuke automatically tries to reset some variables when changing + the loader's path to a new file. These automatic changes are to its + inputs: + + """ + + from avalon.nuke import ( + update_container + ) + + node = nuke.toNode(container['objectName']) + + root = api.get_representation_path(representation).replace("\\","/") + + # Get start frame from version data + version = io.find_one({ + "type": "version", + "_id": representation["parent"] + }) + + # get all versions in list + versions = io.find({ + "type": "version", + "parent": version["parent"] + }).distinct('name') + + max_version = max(versions) + + updated_dict = {} + updated_dict.update({ + "representation": str(representation["_id"]), + "endFrame": version["data"].get("endFrame"), + "version": version.get("name"), + "colorspace": version["data"].get("colorspace"), + "source": version["data"].get("source"), + "handles": version["data"].get("handles"), + "fps": version["data"].get("fps"), + "author": version["data"].get("author"), + "outputDir": version["data"].get("outputDir"), + }) + + # Update the imprinted representation + update_container( + node, + updated_dict + ) + + node["file"].setValue(root) + + # change color of node + if version.get("name") not in [max_version]: + node["tile_color"].setValue(int("0xd84f20ff", 16)) + else: + node["tile_color"].setValue(int("0xff0ff0ff", 16)) + + log.info("udated to version: {}".format(version.get("name"))) + + + def remove(self, container): + from avalon.nuke import viewer_update_and_undo_stop + node = nuke.toNode(container['objectName']) + with viewer_update_and_undo_stop(): + nuke.delete(node) diff --git a/pype/plugins/nuke/load/load_sequence.py b/pype/plugins/nuke/load/load_sequence.py index ab3584427a..f03e0fc97e 100644 --- a/pype/plugins/nuke/load/load_sequence.py +++ b/pype/plugins/nuke/load/load_sequence.py @@ -8,7 +8,7 @@ import avalon.io as io import nuke from pype.api import Logger -log = Logger.getLogger(__name__, "nuke") +log = Logger().get_logger(__name__, "nuke") @contextlib.contextmanager @@ -128,11 +128,15 @@ class LoadSequence(api.Loader): # add additional metadata from the version to imprint to Avalon knob add_keys = ["startFrame", "endFrame", "handles", - "source", "colorspace", "author", "fps"] + "source", "colorspace", "author", "fps", "version"] data_imprint = {} for k in add_keys: - data_imprint.update({k: context["version"]['data'][k]}) + if k is 'version': + data_imprint.update({k: context["version"]['name']}) + else: + data_imprint.update({k: context["version"]['data'][k]}) + data_imprint.update({"objectName": read_name}) r["tile_color"].setValue(int("0x4ecd25ff", 16)) @@ -226,6 +230,7 @@ class LoadSequence(api.Loader): node, updated_dict ) + log.info("udated to version: {}".format(version.get("name"))) def remove(self, container): diff --git a/pype/plugins/nuke/publish/collect_instances.py b/pype/plugins/nuke/publish/collect_instances.py index 8a2bb06fff..e9db556a9f 100644 --- a/pype/plugins/nuke/publish/collect_instances.py +++ b/pype/plugins/nuke/publish/collect_instances.py @@ -17,6 +17,10 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): def process(self, context): asset_data = io.find_one({"type": "asset", "name": api.Session["AVALON_ASSET"]}) + + # add handles into context + context.data['handles'] = int(asset_data["data"].get("handles", 0)) + self.log.debug("asset_data: {}".format(asset_data["data"])) instances = [] # creating instances per write node @@ -51,7 +55,6 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): "family": avalon_knob_data["family"], "avalonKnob": avalon_knob_data, "publish": node.knob('publish').value(), - "handles": int(asset_data["data"].get("handles", 0)), "step": 1, "fps": int(nuke.root()['fps'].value()) diff --git a/pype/plugins/nuke/publish/collect_reads.py b/pype/plugins/nuke/publish/collect_reads.py index f5d3008b40..75ea2efa3a 100644 --- a/pype/plugins/nuke/publish/collect_reads.py +++ b/pype/plugins/nuke/publish/collect_reads.py @@ -6,7 +6,7 @@ import pyblish.api import logging from avalon import io, api -log = logging.getLogger(__name__) +log = logging.get_logger(__name__) @pyblish.api.log diff --git a/pype/plugins/nuke/publish/collect_writes.py b/pype/plugins/nuke/publish/collect_writes.py index 75733f34dd..68cd227280 100644 --- a/pype/plugins/nuke/publish/collect_writes.py +++ b/pype/plugins/nuke/publish/collect_writes.py @@ -1,12 +1,9 @@ import os import nuke import pyblish.api -import logging import pype.api as pype -log = logging.getLogger(__name__) - @pyblish.api.log class CollectNukeWrites(pyblish.api.ContextPlugin): @@ -38,10 +35,12 @@ class CollectNukeWrites(pyblish.api.ContextPlugin): output_type = "mov" # Get frame range + handles = instance.context.data.get('handles', 0) first_frame = int(nuke.root()["first_frame"].getValue()) last_frame = int(nuke.root()["last_frame"].getValue()) if node["use_limit"].getValue(): + handles = 0 first_frame = int(node["first"].getValue()) last_frame = int(node["last"].getValue()) @@ -79,6 +78,7 @@ class CollectNukeWrites(pyblish.api.ContextPlugin): "outputDir": output_dir, "ext": ext, "label": label, + "handles": handles, "startFrame": first_frame, "endFrame": last_frame, "outputType": output_type, diff --git a/pype/plugins/nuke/publish/increment_script_version.py b/pype/plugins/nuke/publish/increment_script_version.py index 77eab30a63..2e33e65528 100644 --- a/pype/plugins/nuke/publish/increment_script_version.py +++ b/pype/plugins/nuke/publish/increment_script_version.py @@ -16,7 +16,19 @@ class IncrementScriptVersion(pyblish.api.ContextPlugin): assert all(result["success"] for result in context.data["results"]), ( "Atomicity not held, aborting.") - from pype.lib import version_up - path = context.data["currentFile"] - nuke.scriptSaveAs(version_up(path)) - self.log.info('Incrementing script version') + instances = context[:] + + prerender_check = list() + families_check = list() + for instance in instances: + if ("prerender" in str(instance)) and instance.data.get("families", None): + prerender_check.append(instance) + if instance.data.get("families", None): + families_check.append(True) + + + if len(prerender_check) != len(families_check): + from pype.lib import version_up + path = context.data["currentFile"] + nuke.scriptSaveAs(version_up(path)) + self.log.info('Incrementing script version') diff --git a/pype/plugins/nuke/publish/validate_script.py b/pype/plugins/nuke/publish/validate_script.py index ad4a83b32f..08c91dab31 100644 --- a/pype/plugins/nuke/publish/validate_script.py +++ b/pype/plugins/nuke/publish/validate_script.py @@ -28,7 +28,7 @@ class ValidateScript(pyblish.api.InstancePlugin): ] # Value of these attributes can be found on parents - hierarchical_attributes = ["fps", "resolution_width", "resolution_height", "pixel_aspect"] + hierarchical_attributes = ["fps", "resolution_width", "resolution_height", "pixel_aspect", "handles"] missing_attributes = [] asset_attributes = {} diff --git a/pype/plugins/nukestudio/_unused/collect.py b/pype/plugins/nukestudio/_unused/collect.py new file mode 100644 index 0000000000..4e20202fe0 --- /dev/null +++ b/pype/plugins/nukestudio/_unused/collect.py @@ -0,0 +1,191 @@ +from pyblish import api + +class CollectFramerate(api.ContextPlugin): + """Collect framerate from selected sequence.""" + + order = api.CollectorOrder + label = "Collect Framerate" + hosts = ["nukestudio"] + + def process(self, context): + for item in context.data.get("selection", []): + context.data["framerate"] = item.sequence().framerate().toFloat() + return + + +class CollectTrackItems(api.ContextPlugin): + """Collect all tasks from submission.""" + + order = api.CollectorOrder + label = "Collect Track Items" + hosts = ["nukestudio"] + + def process(self, context): + import os + + submission = context.data.get("submission", None) + data = {} + + # Set handles + handles = 0 + if submission: + for task in submission.getLeafTasks(): + + if task._cutHandles: + handles = task._cutHandles + self.log.info("__ handles: '{}'".format(handles)) + + # Skip audio track items + media_type = "core.Hiero.Python.TrackItem.MediaType.kAudio" + if str(task._item.mediaType()) == media_type: + continue + + item = task._item + if item.name() not in data: + data[item.name()] = {"item": item, "tasks": [task]} + else: + data[item.name()]["tasks"].append(task) + + data[item.name()]["startFrame"] = task.outputRange()[0] + data[item.name()]["endFrame"] = task.outputRange()[1] + else: + for item in context.data.get("selection", []): + # Skip audio track items + # Try/Except is to handle items types, like EffectTrackItem + try: + media_type = "core.Hiero.Python.TrackItem.MediaType.kVideo" + if str(item.mediaType()) != media_type: + continue + except: + continue + + data[item.name()] = { + "item": item, + "tasks": [], + "startFrame": item.timelineIn(), + "endFrame": item.timelineOut() + } + + for key, value in data.items(): + + context.create_instance( + name=key, + subset="trackItem", + asset=value["item"].name(), + item=value["item"], + family="trackItem", + tasks=value["tasks"], + startFrame=value["startFrame"] + handles, + endFrame=value["endFrame"] - handles, + handles=handles + ) + context.create_instance( + name=key + "_review", + subset="reviewItem", + asset=value["item"].name(), + item=value["item"], + family="trackItem_review", + families=["output"], + handles=handles, + output_path=os.path.abspath( + os.path.join( + context.data["activeProject"].path(), + "..", + "workspace", + key + ".mov" + ) + ) + ) + + +class CollectTasks(api.ContextPlugin): + """Collect all tasks from submission.""" + + order = api.CollectorOrder + 0.01 + label = "Collect Tasks" + hosts = ["nukestudio"] + + def process(self, context): + import os + import re + + import hiero.exporters as he + import clique + + for parent in context: + if "trackItem" != parent.data["family"]: + continue + + for task in parent.data["tasks"]: + asset_type = None + + hiero_cls = he.FnSymLinkExporter.SymLinkExporter + if isinstance(task, hiero_cls): + asset_type = "img" + movie_formats = [".mov", ".R3D"] + ext = os.path.splitext(task.resolvedExportPath())[1] + if ext in movie_formats: + asset_type = "mov" + + hiero_cls = he.FnTranscodeExporter.TranscodeExporter + if isinstance(task, hiero_cls): + asset_type = "img" + if task.resolvedExportPath().endswith(".mov"): + asset_type = "mov" + + hiero_cls = he.FnNukeShotExporter.NukeShotExporter + if isinstance(task, hiero_cls): + asset_type = "scene" + + hiero_cls = he.FnAudioExportTask.AudioExportTask + if isinstance(task, hiero_cls): + asset_type = "audio" + + # Skip all non supported export types + if not asset_type: + continue + + resolved_path = task.resolvedExportPath() + + # Formatting the basename to not include frame padding or + # extension. + name = os.path.splitext(os.path.basename(resolved_path))[0] + name = name.replace(".", "") + name = name.replace("#", "") + name = re.sub(r"%.*d", "", name) + instance = context.create_instance(name=name, parent=parent) + + instance.data["task"] = task + instance.data["item"] = parent.data["item"] + + instance.data["family"] = "trackItem.task" + instance.data["families"] = [asset_type, "local", "task"] + + label = "{1}/{0} - {2} - local".format( + name, parent, asset_type + ) + instance.data["label"] = label + + instance.data["handles"] = parent.data["handles"] + + # Add collection or output + if asset_type == "img": + collection = None + + if "#" in resolved_path: + head = resolved_path.split("#")[0] + padding = resolved_path.count("#") + tail = resolved_path.split("#")[-1] + + collection = clique.Collection( + head=head, padding=padding, tail=tail + ) + + if "%" in resolved_path: + collection = clique.parse( + resolved_path, pattern="{head}{padding}{tail}" + ) + + instance.data["collection"] = collection + else: + instance.data["output_path"] = resolved_path diff --git a/pype/plugins/nukestudio/_unused/collect_submission.py b/pype/plugins/nukestudio/_unused/collect_submission.py new file mode 100644 index 0000000000..cd2b855524 --- /dev/null +++ b/pype/plugins/nukestudio/_unused/collect_submission.py @@ -0,0 +1,14 @@ +import pyblish.api + + +class CollectSubmission(pyblish.api.ContextPlugin): + """Collect submisson children.""" + + order = pyblish.api.CollectorOrder - 0.1 + + def process(self, context): + import hiero + + if hasattr(hiero, "submission"): + context.data["submission"] = hiero.submission + self.log.debug("__ submission: {}".format(context.data["submission"])) diff --git a/pype/plugins/nukestudio/_unused/extract_tasks.py b/pype/plugins/nukestudio/_unused/extract_tasks.py new file mode 100644 index 0000000000..29c1350cc9 --- /dev/null +++ b/pype/plugins/nukestudio/_unused/extract_tasks.py @@ -0,0 +1,124 @@ +from pyblish import api + + +class ExtractTasks(api.InstancePlugin): + """Extract tasks.""" + + order = api.ExtractorOrder + label = "Tasks" + hosts = ["nukestudio"] + families = ["trackItem.task"] + optional = True + + def filelink(self, src, dst): + import filecmp + import os + import shutil + + import filelink + + # Compare files to check whether they are the same. + if os.path.exists(dst) and filecmp.cmp(src, dst): + return + + # Remove existing destination file. + if os.path.exists(dst): + os.remove(dst) + + try: + filelink.create(src, dst, filelink.HARDLINK) + self.log.debug("Linking: \"{0}\" to \"{1}\"".format(src, dst)) + except WindowsError as e: + if e.winerror == 17: + self.log.warning( + "File linking failed due to: \"{0}\". " + "Resorting to copying instead.".format(e) + ) + shutil.copy(src, dst) + else: + raise e + + def process(self, instance): + import time + import os + + import hiero.core.nuke as nuke + import hiero.exporters as he + import clique + + task = instance.data["task"] + + hiero_cls = he.FnSymLinkExporter.SymLinkExporter + if isinstance(task, hiero_cls): + src = os.path.join( + task.filepath(), + task.fileName() + ) + # Filelink each image file + if "img" in instance.data["families"]: + collection = clique.parse(src + " []") + for f in os.listdir(os.path.dirname(src)): + f = os.path.join(os.path.dirname(src), f) + + frame_offset = task.outputRange()[0] - task.inputRange()[0] + input_range = ( + int(task.inputRange()[0]), int(task.inputRange()[1]) + 1 + ) + for index in range(*input_range): + dst = task.resolvedExportPath() % (index + frame_offset) + self.filelink(src % index, dst) + # Filelink movie file + if "mov" in instance.data["families"]: + dst = task.resolvedExportPath() + self.filelink(src, dst) + + hiero_cls = he.FnTranscodeExporter.TranscodeExporter + if isinstance(task, hiero_cls): + task.startTask() + while task.taskStep(): + time.sleep(1) + + script_path = task._scriptfile + log_path = script_path.replace(".nk", ".log") + log_file = open(log_path, "w") + process = nuke.executeNukeScript(script_path, log_file, True) + + self.poll(process) + + log_file.close() + + if not task._preset.properties()["keepNukeScript"]: + os.remove(script_path) + os.remove(log_path) + + hiero_cls = he.FnNukeShotExporter.NukeShotExporter + if isinstance(task, hiero_cls): + task.startTask() + while task.taskStep(): + time.sleep(1) + + hiero_cls = he.FnAudioExportTask.AudioExportTask + if isinstance(task, hiero_cls): + task.startTask() + while task.taskStep(): + time.sleep(1) + + # Fill collection with output + if "img" in instance.data["families"]: + collection = instance.data["collection"] + path = os.path.dirname(collection.format()) + for f in os.listdir(path): + file_path = os.path.join(path, f).replace("\\", "/") + if collection.match(file_path): + collection.add(file_path) + + def poll(self, process): + import time + + returnCode = process.poll() + + # if the return code hasn't been set, Nuke is still running + if returnCode is None: + time.sleep(1) + + self.poll(process) diff --git a/pype/plugins/nukestudio/_unused/validate_resolved_paths.py b/pype/plugins/nukestudio/_unused/validate_resolved_paths.py new file mode 100644 index 0000000000..f1f0b7bbc8 --- /dev/null +++ b/pype/plugins/nukestudio/_unused/validate_resolved_paths.py @@ -0,0 +1,27 @@ +from pyblish import api + +class ValidateResolvedPaths(api.ContextPlugin): + """Validate there are no overlapping resolved paths.""" + + order = api.ValidatorOrder + label = "Resolved Paths" + hosts = ["nukestudio"] + + def process(self, context): + import os + import collections + + paths = [] + for instance in context: + if "trackItem.task" == instance.data["family"]: + paths.append( + os.path.abspath(instance.data["task"].resolvedExportPath()) + ) + + duplicates = [] + for item, count in collections.Counter(paths).items(): + if count > 1: + duplicates.append(item) + + msg = "Duplicate output paths found: {0}".format(duplicates) + assert not duplicates, msg diff --git a/pype/plugins/nukestudio/_unused/validate_task.py b/pype/plugins/nukestudio/_unused/validate_task.py new file mode 100644 index 0000000000..ff8fa6b6e1 --- /dev/null +++ b/pype/plugins/nukestudio/_unused/validate_task.py @@ -0,0 +1,57 @@ +from pyblish import api + + +class ValidateOutputRange(api.InstancePlugin): + """Validate the output range of the task. + + This compares the output range and clip associated with the task, so see + whether there is a difference. This difference indicates that the user has + selected to export the clip length for the task which is very uncommon to + do. + """ + + order = api.ValidatorOrder + families = ["trackItem.task"] + label = "Output Range" + hosts = ["nukestudio"] + optional = True + + def process(self, instance): + + task = instance.data["task"] + item = instance.data["parent"] + + output_range = task.outputRange() + first_frame = int(item.data["item"].source().sourceIn()) + last_frame = int(item.data["item"].source().sourceOut()) + clip_duration = last_frame - first_frame + 1 + + difference = clip_duration - output_range[1] + failure_message = ( + 'Looks like you are rendering the clip length for the task ' + 'rather than the cut length. If this is intended, just uncheck ' + 'this validator after resetting, else adjust the export range in ' + 'the "Handles" section of the export dialog.' + ) + assert difference, failure_message + + +class ValidateImageSequence(api.InstancePlugin): + """Validate image sequence output path is setup correctly.""" + + order = api.ValidatorOrder + families = ["trackItem.task", "img"] + match = api.Subset + label = "Image Sequence" + hosts = ["nukestudio"] + optional = True + + def process(self, instance): + + resolved_path = instance.data["task"].resolvedExportPath() + + msg = ( + "Image sequence output is missing a padding. Please add \"####\" " + "or \"%04d\" to the output templates." + ) + assert "#" in resolved_path or "%" in resolved_path, msg diff --git a/pype/plugins/nukestudio/publish/collect_active_project.py b/pype/plugins/nukestudio/publish/collect_active_project.py new file mode 100644 index 0000000000..0ac6192e4a --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_active_project.py @@ -0,0 +1,13 @@ +import pyblish.api + + +class CollectActiveProject(pyblish.api.ContextPlugin): + """Inject the active project into context""" + + order = pyblish.api.CollectorOrder - 0.2 + + def process(self, context): + import hiero + + context.data["activeProject"] = hiero.ui.activeSequence().project() + self.log.info("activeProject: {}".format(context.data["activeProject"])) diff --git a/pype/plugins/nukestudio/publish/collect_clips.py b/pype/plugins/nukestudio/publish/collect_clips.py new file mode 100644 index 0000000000..69ec4814e9 --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_clips.py @@ -0,0 +1,43 @@ +from pyblish import api + + +class CollectClips(api.ContextPlugin): + """Collect all Track items selection.""" + + order = api.CollectorOrder + label = "Collect Clips" + hosts = ["nukestudio"] + + def process(self, context): + data = {} + for item in context.data.get("selection", []): + self.log.info("__ item: {}".format(item)) + # Skip audio track items + # Try/Except is to handle items types, like EffectTrackItem + try: + media_type = "core.Hiero.Python.TrackItem.MediaType.kVideo" + if str(item.mediaType()) != media_type: + continue + except: + continue + + data[item.name()] = { + "item": item, + "tasks": [], + "startFrame": item.timelineIn(), + "endFrame": item.timelineOut() + } + + for key, value in data.items(): + family = "clip" + context.create_instance( + name=key, + subset="{0}{1}".format(family, 'Default'), + asset=value["item"].name(), + item=value["item"], + family=family, + tasks=value["tasks"], + startFrame=value["startFrame"], + endFrame=value["endFrame"], + handles=0 + ) diff --git a/pype/plugins/nukestudio/publish/collect_colorspace.py b/pype/plugins/nukestudio/publish/collect_colorspace.py new file mode 100644 index 0000000000..2b629ba1f7 --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_colorspace.py @@ -0,0 +1,27 @@ +import pyblish.api + + +class CollectProjectColorspace(pyblish.api.ContextPlugin): + """get active project color settings""" + + order = pyblish.api.CollectorOrder + 0.1 + label = "Project's color settings" + def process(self, context): + import hiero + + project = context.data["activeProject"] + colorspace = {} + colorspace["useOCIOEnvironmentOverride"] = project.useOCIOEnvironmentOverride() + colorspace["lutSetting16Bit"] = project.lutSetting16Bit() + colorspace["lutSetting8Bit"] = project.lutSetting8Bit() + colorspace["lutSettingFloat"] = project.lutSettingFloat() + colorspace["lutSettingLog"] = project.lutSettingLog() + colorspace["lutSettingViewer"] = project.lutSettingViewer() + colorspace["lutSettingWorkingSpace"] = project.lutSettingWorkingSpace() + colorspace["lutUseOCIOForExport"] = project.lutUseOCIOForExport() + colorspace["ocioConfigName"] = project.ocioConfigName() + colorspace["ocioConfigPath"] = project.ocioConfigPath() + + context.data["colorspace"] = colorspace + + self.log.info("context.data[colorspace]: {}".format(context.data["colorspace"])) diff --git a/pype/plugins/nukestudio/publish/collect_current_file.py b/pype/plugins/nukestudio/publish/collect_current_file.py new file mode 100644 index 0000000000..010d4e15ab --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_current_file.py @@ -0,0 +1,14 @@ +import pyblish.api + + +class CollectCurrentFile(pyblish.api.ContextPlugin): + """Inject the current working file into context""" + + order = pyblish.api.CollectorOrder + + def process(self, context): + """Todo, inject the current working file""" + + project = context.data('activeProject') + context.set_data('currentFile', value=project.path()) + self.log.info("currentFile: {}".format(context.data["currentFile"])) diff --git a/pype/plugins/nukestudio/publish/collect_framerate.py b/pype/plugins/nukestudio/publish/collect_framerate.py new file mode 100644 index 0000000000..822be8fb9b --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_framerate.py @@ -0,0 +1,13 @@ +from pyblish import api + +class CollectFramerate(api.ContextPlugin): + """Collect framerate from selected sequence.""" + + order = api.CollectorOrder + label = "Collect Framerate" + hosts = ["nukestudio"] + + def process(self, context): + for item in context.data.get("selection", []): + context.data["framerate"] = item.sequence().framerate().toFloat() + return diff --git a/pype/plugins/nukestudio/publish/collect_hierarchy_context.py b/pype/plugins/nukestudio/publish/collect_hierarchy_context.py new file mode 100644 index 0000000000..b421d31f79 --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_hierarchy_context.py @@ -0,0 +1,72 @@ +import pyblish.api +from avalon import api + + +class CollectHierarchyContext(pyblish.api.ContextPlugin): + """Collecting hierarchy context from `parents` and `hierarchy` data + present in `clip` family instances coming from the request json data file + + It will add `hierarchical_context` into each instance for integrate + plugins to be able to create needed parents for the context if they + don't exist yet + """ + + label = "Collect Hierarchy Context" + order = pyblish.api.CollectorOrder + 0.1 + + def update_dict(self, ex_dict, new_dict): + for key in ex_dict: + if key in new_dict and isinstance(ex_dict[key], dict): + new_dict[key] = self.update_dict(ex_dict[key], new_dict[key]) + else: + new_dict[key] = ex_dict[key] + return new_dict + + def process(self, context): + json_data = context.data.get("jsonData", None) + temp_context = {} + for instance in json_data['instances']: + if instance['family'] in 'projectfile': + continue + + in_info = {} + name = instance['name'] + # suppose that all instances are Shots + in_info['entity_type'] = 'Shot' + + instance_pyblish = [ + i for i in context.data["instances"] if i.data['asset'] in name][0] + in_info['custom_attributes'] = { + 'fend': instance_pyblish.data['endFrame'], + 'fstart': instance_pyblish.data['startFrame'], + 'fps': instance_pyblish.data['fps'] + } + + in_info['tasks'] = instance['tasks'] + + parents = instance.get('parents', []) + + actual = {name: in_info} + + for parent in reversed(parents): + next_dict = {} + parent_name = parent["entityName"] + next_dict[parent_name] = {} + next_dict[parent_name]["entity_type"] = parent["entityType"] + next_dict[parent_name]["childs"] = actual + actual = next_dict + + temp_context = self.update_dict(temp_context, actual) + self.log.debug(temp_context) + + # TODO: 100% sure way of get project! Will be Name or Code? + project_name = api.Session["AVALON_PROJECT"] + final_context = {} + final_context[project_name] = {} + final_context[project_name]['entity_type'] = 'Project' + final_context[project_name]['childs'] = temp_context + + # adding hierarchy context to instance + context.data["hierarchyContext"] = final_context + self.log.debug("context.data[hierarchyContext] is: {}".format( + context.data["hierarchyContext"])) diff --git a/pype/plugins/nukestudio/publish/collect_host.py b/pype/plugins/nukestudio/publish/collect_host.py new file mode 100644 index 0000000000..caad4d344a --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_host.py @@ -0,0 +1,13 @@ +import pyblish.api + + +class CollectHost(pyblish.api.ContextPlugin): + """Inject the host into context""" + + order = pyblish.api.CollectorOrder + + def process(self, context): + import pyblish.api + + context.set_data("host", pyblish.api.current_host()) + self.log.info("current host: {}".format(pyblish.api.current_host())) diff --git a/pype/plugins/nukestudio/publish/collect_host_version.py b/pype/plugins/nukestudio/publish/collect_host_version.py new file mode 100644 index 0000000000..267d035f4d --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_host_version.py @@ -0,0 +1,12 @@ +import pyblish.api + + +class CollectHostVersion(pyblish.api.ContextPlugin): + """Inject the hosts version into context""" + + order = pyblish.api.CollectorOrder + + def process(self, context): + import nuke + + context.set_data('hostVersion', value=nuke.NUKE_VERSION_STRING) diff --git a/pype/plugins/nukestudio/publish/collect_metadata.py b/pype/plugins/nukestudio/publish/collect_metadata.py new file mode 100644 index 0000000000..23d36ba4a2 --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_metadata.py @@ -0,0 +1,30 @@ +from pyblish import api + + +class CollectClipMetadata(api.InstancePlugin): + """Collect Metadata from selected track items.""" + + order = api.CollectorOrder + 0.01 + label = "Collect Metadata" + hosts = ["nukestudio"] + + def process(self, instance): + item = instance.data["item"] + ti_metadata = self.metadata_to_string(dict(item.metadata())) + ms_metadata = self.metadata_to_string( + dict(item.source().mediaSource().metadata())) + + instance.data["clipMetadata"] = ti_metadata + instance.data["mediaSourceMetadata"] = ms_metadata + + self.log.info(instance.data["clipMetadata"]) + self.log.info(instance.data["mediaSourceMetadata"]) + return + + def metadata_to_string(self, metadata): + data = dict() + for k, v in metadata.items(): + if v not in ["-", ""]: + data[str(k)] = v + + return data diff --git a/pype/plugins/nukestudio/publish/collect_selection.py b/pype/plugins/nukestudio/publish/collect_selection.py new file mode 100644 index 0000000000..e22ea79a05 --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_selection.py @@ -0,0 +1,15 @@ +import pyblish.api + +import hiero + +class CollectSelection(pyblish.api.ContextPlugin): + """Inject the selection in the context.""" + + order = pyblish.api.CollectorOrder - 0.1 + label = "Selection" + + def process(self, context): + selection = getattr(hiero, "selection") + + self.log.debug("selection: {}".format(selection)) + context.data["selection"] = hiero.selection diff --git a/pype/plugins/nukestudio/publish/collect_subsets.py b/pype/plugins/nukestudio/publish/collect_subsets.py new file mode 100644 index 0000000000..b27a718f49 --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_subsets.py @@ -0,0 +1,45 @@ +from pyblish import api + + +class CollectClipSubsets(api.InstancePlugin): + """Collect Subsets from selected Clips, Tags, Preset.""" + + order = api.CollectorOrder + 0.01 + label = "Collect Subsets" + hosts = ["nukestudio"] + families = ['clip'] + + def process(self, instance): + tags = instance.data.get('tags', None) + presets = instance.context.data['presets'][ + instance.context.data['host']] + if tags: + self.log.info(tags) + + if presets: + self.log.info(presets) + + # get presets and tags + # iterate tags and get task family + # iterate tags and get host family + # iterate tags and get handles family + + instance = instance.context.create_instance(instance_name) + + instance.data.update({ + "subset": subset_name, + "stagingDir": staging_dir, + "task": task, + "representation": ext[1:], + "host": host, + "asset": asset_name, + "label": label, + "name": name, + # "hierarchy": hierarchy, + # "parents": parents, + "family": family, + "families": [families, 'ftrack'], + "publish": True, + # "files": files_list + }) + instances.append(instance) diff --git a/pype/plugins/nukestudio/publish/collect_tags.py b/pype/plugins/nukestudio/publish/collect_tags.py new file mode 100644 index 0000000000..9ae34d415f --- /dev/null +++ b/pype/plugins/nukestudio/publish/collect_tags.py @@ -0,0 +1,30 @@ +from pyblish import api + + +class CollectClipTags(api.InstancePlugin): + """Collect Tags from selected track items.""" + + order = api.CollectorOrder + label = "Collect Tags" + hosts = ["nukestudio"] + families = ['clip'] + + def process(self, instance): + tags = instance.data["item"].tags() + + tags_d = [] + if tags: + for t in tags: + tag_data = { + "name": t.name(), + "object": t, + "metadata": t.metadata(), + "inTime": t.inTime(), + "outTime": t.outTime(), + } + tags_d.append(tag_data) + + instance.data["tags"] = tags_d + + self.log.info(instance.data["tags"]) + return diff --git a/pype/plugins/nukestudio/publish/extract_review.py b/pype/plugins/nukestudio/publish/extract_review.py new file mode 100644 index 0000000000..537988e0ad --- /dev/null +++ b/pype/plugins/nukestudio/publish/extract_review.py @@ -0,0 +1,101 @@ +from pyblish import api + + +class ExtractReview(api.InstancePlugin): + """Extracts movie for review""" + + order = api.ExtractorOrder + label = "NukeStudio Review" + optional = True + hosts = ["nukestudio"] + families = ["review"] + + def process(self, instance): + import os + import time + + import hiero.core + from hiero.exporters.FnExportUtil import writeSequenceAudioWithHandles + + nukeWriter = hiero.core.nuke.ScriptWriter() + + item = instance.data["item"] + + handles = instance.data["handles"] + + sequence = item.parent().parent() + + output_path = os.path.abspath( + os.path.join( + instance.context.data["currentFile"], "..", "workspace" + ) + ) + + # Generate audio + audio_file = os.path.join( + output_path, "{0}.wav".format(instance.data["name"]) + ) + + writeSequenceAudioWithHandles( + audio_file, + sequence, + item.timelineIn(), + item.timelineOut(), + handles, + handles + ) + + # Generate Nuke script + root_node = hiero.core.nuke.RootNode( + item.timelineIn() - handles, + item.timelineOut() + handles, + fps=sequence.framerate() + ) + + root_node.addProjectSettings(instance.context.data["colorspace"]) + + nukeWriter.addNode(root_node) + + item.addToNukeScript( + script=nukeWriter, + includeRetimes=True, + retimeMethod="Frame", + startHandle=handles, + endHandle=handles + ) + + movie_path = os.path.join( + output_path, "{0}.mov".format(instance.data["name"]) + ) + write_node = hiero.core.nuke.WriteNode(movie_path.replace("\\", "/")) + self.log.info("__ write_node: {0}".format(write_node)) + write_node.setKnob("file_type", "mov") + write_node.setKnob("colorspace", instance.context.data["colorspace"]["lutSettingFloat"]) + write_node.setKnob("meta_codec", "ap4h") + write_node.setKnob("mov64_codec", "ap4h") + write_node.setKnob("mov64_bitrate", 400000) + write_node.setKnob("mov64_bitrate_tolerance", 40000000) + write_node.setKnob("mov64_quality_min", 2) + write_node.setKnob("mov64_quality_max", 31) + write_node.setKnob("mov64_gop_size", 12) + write_node.setKnob("mov64_b_frames", 0) + write_node.setKnob("raw", True ) + write_node.setKnob("mov64_audiofile", audio_file.replace("\\", "/")) + write_node.setKnob("mov32_fps", sequence.framerate()) + nukeWriter.addNode(write_node) + + nukescript_path = movie_path.replace(".mov", ".nk") + nukeWriter.writeToDisk(nukescript_path) + + process = hiero.core.nuke.executeNukeScript( + nukescript_path, + open(movie_path.replace(".mov", ".log"), "w") + ) + + while process.poll() is None: + time.sleep(0.5) + + assert os.path.exists(movie_path), "Creating review failed." + + instance.data["output_path"] = movie_path + instance.data["review_family"] = "mov" diff --git a/pype/plugins/nukestudio/publish/integrate_assumed_destination.py b/pype/plugins/nukestudio/publish/integrate_assumed_destination.py new file mode 100644 index 0000000000..c1936994e4 --- /dev/null +++ b/pype/plugins/nukestudio/publish/integrate_assumed_destination.py @@ -0,0 +1,132 @@ +import pyblish.api +import os + +from avalon import io, api + + +class IntegrateAssumedDestination(pyblish.api.InstancePlugin): + """Generate the assumed destination path where the file will be stored""" + + label = "Integrate Assumed Destination" + order = pyblish.api.IntegratorOrder - 0.05 + families = ["clip", "projectfile"] + + def process(self, instance): + + self.create_destination_template(instance) + + template_data = instance.data["assumedTemplateData"] + # template = instance.data["template"] + + anatomy = instance.context.data['anatomy'] + # template = anatomy.publish.path + anatomy_filled = anatomy.format(template_data) + mock_template = anatomy_filled.publish.path + + # For now assume resources end up in a "resources" folder in the + # published folder + mock_destination = os.path.join(os.path.dirname(mock_template), + "resources") + + # Clean the path + mock_destination = os.path.abspath(os.path.normpath(mock_destination)) + + # Define resource destination and transfers + resources = instance.data.get("resources", list()) + transfers = instance.data.get("transfers", list()) + for resource in resources: + + # Add destination to the resource + source_filename = os.path.basename(resource["source"]) + destination = os.path.join(mock_destination, source_filename) + + # Force forward slashes to fix issue with software unable + # to work correctly with backslashes in specific scenarios + # (e.g. escape characters in PLN-151 V-Ray UDIM) + destination = destination.replace("\\", "/") + + resource['destination'] = destination + + # Collect transfers for the individual files of the resource + # e.g. all individual files of a cache or UDIM textures. + files = resource['files'] + for fsrc in files: + fname = os.path.basename(fsrc) + fdest = os.path.join(mock_destination, fname) + transfers.append([fsrc, fdest]) + + instance.data["resources"] = resources + instance.data["transfers"] = transfers + + def create_destination_template(self, instance): + """Create a filepath based on the current data available + + Example template: + {root}/{project}/{silo}/{asset}/publish/{subset}/v{version:0>3}/ + {subset}.{representation} + Args: + instance: the instance to publish + + Returns: + file path (str) + """ + + # get all the stuff from the database + subset_name = instance.data["subset"] + self.log.info(subset_name) + asset_name = instance.data["asset"] + project_name = api.Session["AVALON_PROJECT"] + + project = io.find_one({"type": "project", + "name": project_name}, + projection={"config": True, "data": True}) + + template = project["config"]["template"]["publish"] + # anatomy = instance.context.data['anatomy'] + + asset = io.find_one({"type": "asset", + "name": asset_name, + "parent": project["_id"]}) + + assert asset, ("No asset found by the name '{}' " + "in project '{}'".format(asset_name, project_name)) + silo = asset['silo'] + + subset = io.find_one({"type": "subset", + "name": subset_name, + "parent": asset["_id"]}) + + # assume there is no version yet, we start at `1` + version = None + version_number = 1 + if subset is not None: + version = io.find_one({"type": "version", + "parent": subset["_id"]}, + sort=[("name", -1)]) + + # if there is a subset there ought to be version + if version is not None: + version_number += version["name"] + + if instance.data.get('version'): + version_number = int(instance.data.get('version')) + + hierarchy = asset['data']['parents'] + if hierarchy: + # hierarchy = os.path.sep.join(hierarchy) + hierarchy = os.path.join(*hierarchy) + + template_data = {"root": api.Session["AVALON_PROJECTS"], + "project": {"name": project_name, + "code": project['data']['code']}, + "silo": silo, + "family": instance.data['family'], + "asset": asset_name, + "subset": subset_name, + "version": version_number, + "hierarchy": hierarchy, + "representation": "TEMP"} + + instance.data["assumedTemplateData"] = template_data + self.log.info(template_data) + instance.data["template"] = template diff --git a/pype/plugins/nukestudio/publish/integrate_ftrack_component_overwrite.py b/pype/plugins/nukestudio/publish/integrate_ftrack_component_overwrite.py new file mode 100644 index 0000000000..047fd8462c --- /dev/null +++ b/pype/plugins/nukestudio/publish/integrate_ftrack_component_overwrite.py @@ -0,0 +1,21 @@ +import pyblish.api + + +class IntegrateFtrackComponentOverwrite(pyblish.api.InstancePlugin): + """ + Set `component_overwrite` to True on all instances `ftrackComponentsList` + """ + + order = pyblish.api.IntegratorOrder + 0.49 + label = 'Overwrite ftrack created versions' + families = ["clip"] + optional = True + active = False + + def process(self, instance): + component_list = instance.data['ftrackComponentsList'] + + for cl in component_list: + cl['component_overwrite'] = True + self.log.debug('Component {} overwriting'.format( + cl['component_data']['name'])) diff --git a/pype/plugins/nukestudio/publish/integrate_hierarchy_avalon.py b/pype/plugins/nukestudio/publish/integrate_hierarchy_avalon.py new file mode 100644 index 0000000000..0f7fdb20d3 --- /dev/null +++ b/pype/plugins/nukestudio/publish/integrate_hierarchy_avalon.py @@ -0,0 +1,140 @@ +import pyblish.api +from avalon import io + + +class IntegrateHierarchyToAvalon(pyblish.api.ContextPlugin): + """ + Create entities in ftrack based on collected data from premiere + + """ + + order = pyblish.api.IntegratorOrder - 0.1 + label = 'Integrate Hierarchy To Avalon' + families = ['clip'] + + def process(self, context): + if "hierarchyContext" not in context.data: + return + + self.db = io + if not self.db.Session: + self.db.install() + + input_data = context.data["hierarchyContext"] + self.import_to_avalon(input_data) + + def import_to_avalon(self, input_data, parent=None): + + for name in input_data: + self.log.info('input_data[name]: {}'.format(input_data[name])) + entity_data = input_data[name] + entity_type = entity_data['entity_type'] + + data = {} + # Process project + if entity_type.lower() == 'project': + entity = self.db.find_one({'type': 'project'}) + # TODO: should be in validator? + assert (entity is not None), "Didn't find project in DB" + + # get data from already existing project + for key, value in entity.get('data', {}).items(): + data[key] = value + + self.av_project = entity + # Raise error if project or parent are not set + elif self.av_project is None or parent is None: + raise AssertionError( + "Collected items are not in right order!" + ) + # Else process assset + else: + entity = self.db.find_one({'type': 'asset', 'name': name}) + # Create entity if doesn't exist + if entity is None: + if self.av_project['_id'] == parent['_id']: + silo = None + elif parent['silo'] is None: + silo = parent['name'] + else: + silo = parent['silo'] + entity = self.create_avalon_asset(name, silo) + self.log.info('entity: {}'.format(entity)) + self.log.info('data: {}'.format(entity.get('data', {}))) + self.log.info('____1____') + data['entityType'] = entity_type + # TASKS + tasks = entity_data.get('tasks', []) + if tasks is not None or len(tasks) > 0: + data['tasks'] = tasks + parents = [] + visualParent = None + data = input_data[name] + if self.av_project['_id'] != parent['_id']: + visualParent = parent['_id'] + parents.extend(parent.get('data', {}).get('parents', [])) + parents.append(parent['name']) + data['visualParent'] = visualParent + data['parents'] = parents + + self.db.update_many( + {'_id': entity['_id']}, + {'$set': { + 'data': data, + }}) + + entity = self.db.find_one({'type': 'asset', 'name': name}) + self.log.info('entity: {}'.format(entity)) + self.log.info('data: {}'.format(entity.get('data', {}))) + self.log.info('____2____') + + # Else get data from already existing + else: + self.log.info('entity: {}'.format(entity)) + self.log.info('data: {}'.format(entity.get('data', {}))) + self.log.info('________') + for key, value in entity.get('data', {}).items(): + data[key] = value + + data['entityType'] = entity_type + # TASKS + tasks = entity_data.get('tasks', []) + if tasks is not None or len(tasks) > 0: + data['tasks'] = tasks + parents = [] + visualParent = None + # do not store project's id as visualParent (silo asset) + + if self.av_project['_id'] != parent['_id']: + visualParent = parent['_id'] + parents.extend(parent.get('data', {}).get('parents', [])) + parents.append(parent['name']) + data['visualParent'] = visualParent + data['parents'] = parents + + # CUSTOM ATTRIBUTES + for k, val in entity_data.get('custom_attributes', {}).items(): + data[k] = val + + # Update entity data with input data + self.db.update_many( + {'_id': entity['_id']}, + {'$set': { + 'data': data, + }}) + + if 'childs' in entity_data: + self.import_to_avalon(entity_data['childs'], entity) + + def create_avalon_asset(self, name, silo): + item = { + 'schema': 'avalon-core:asset-2.0', + 'name': name, + 'silo': silo, + 'parent': self.av_project['_id'], + 'type': 'asset', + 'data': {} + } + entity_id = self.db.insert_one(item).inserted_id + + return self.db.find_one({'_id': entity_id}) diff --git a/pype/plugins/nukestudio/publish/integrate_hierarchy_ftrack.py b/pype/plugins/nukestudio/publish/integrate_hierarchy_ftrack.py new file mode 100644 index 0000000000..d6d03e9722 --- /dev/null +++ b/pype/plugins/nukestudio/publish/integrate_hierarchy_ftrack.py @@ -0,0 +1,155 @@ +import pyblish.api + + +class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): + """ + Create entities in ftrack based on collected data from premiere + Example of entry data: + { + "ProjectXS": { + "entity_type": "Project", + "custom_attributes": { + "fps": 24,... + }, + "tasks": [ + "Compositing", + "Lighting",... *task must exist as task type in project schema* + ], + "childs": { + "sq01": { + "entity_type": "Sequence", + ... + } + } + } + } + """ + + order = pyblish.api.IntegratorOrder + label = 'Integrate Hierarchy To Ftrack' + families = ["clip"] + optional = False + + def process(self, context): + self.context = context + if "hierarchyContext" not in context.data: + return + + self.ft_project = None + self.session = context.data["ftrackSession"] + + input_data = context.data["hierarchyContext"] + + # adding ftrack types from presets + ftrack_types = context.data['ftrackTypes'] + + self.import_to_ftrack(input_data, ftrack_types) + + def import_to_ftrack(self, input_data, ftrack_types, parent=None): + for entity_name in input_data: + entity_data = input_data[entity_name] + entity_type = entity_data['entity_type'].capitalize() + + if entity_type.lower() == 'project': + query = 'Project where full_name is "{}"'.format(entity_name) + entity = self.session.query(query).one() + self.ft_project = entity + self.task_types = self.get_all_task_types(entity) + + elif self.ft_project is None or parent is None: + raise AssertionError( + "Collected items are not in right order!" + ) + + # try to find if entity already exists + else: + query = '{} where name is "{}" and parent_id is "{}"'.format( + entity_type, entity_name, parent['id'] + ) + try: + entity = self.session.query(query).one() + except Exception: + entity = None + + # Create entity if not exists + if entity is None: + entity = self.create_entity( + name=entity_name, + type=entity_type, + parent=parent + ) + # self.log.info('entity: {}'.format(dict(entity))) + # CUSTOM ATTRIBUTES + custom_attributes = entity_data.get('custom_attributes', []) + instances = [ + i for i in self.context.data["instances"] if i.data['asset'] in entity['name']] + for key in custom_attributes: + assert (key in entity['custom_attributes']), ( + 'Missing custom attribute') + + entity['custom_attributes'][key] = custom_attributes[key] + for instance in instances: + instance.data['ftrackShotId'] = entity['id'] + + self.session.commit() + + # TASKS + tasks = entity_data.get('tasks', []) + existing_tasks = [] + tasks_to_create = [] + for child in entity['children']: + if child.entity_type.lower() == 'task': + existing_tasks.append(child['name']) + # existing_tasks.append(child['type']['name']) + + for task in tasks: + if task in existing_tasks: + print("Task {} already exists".format(task)) + continue + tasks_to_create.append(task) + + for task in tasks_to_create: + self.create_task( + name=task, + task_type=ftrack_types[task], + parent=entity + ) + self.session.commit() + + if 'childs' in entity_data: + self.import_to_ftrack( + entity_data['childs'], ftrack_types, entity) + + def get_all_task_types(self, project): + tasks = {} + proj_template = project['project_schema'] + temp_task_types = proj_template['_task_type_schema']['types'] + + for type in temp_task_types: + if type['name'] not in tasks: + tasks[type['name']] = type + + return tasks + + def create_task(self, name, task_type, parent): + task = self.session.create('Task', { + 'name': name, + 'parent': parent + }) + # TODO not secured!!! - check if task_type exists + self.log.info(task_type) + self.log.info(self.task_types) + task['type'] = self.task_types[task_type] + + self.session.commit() + + return task + + def create_entity(self, name, type, parent): + entity = self.session.create(type, { + 'name': name, + 'parent': parent + }) + self.session.commit() + + return entity diff --git a/pype/plugins/nukestudio/publish/validate_names.py b/pype/plugins/nukestudio/publish/validate_names.py new file mode 100644 index 0000000000..52382e545d --- /dev/null +++ b/pype/plugins/nukestudio/publish/validate_names.py @@ -0,0 +1,42 @@ +from pyblish import api + + +class ValidateNames(api.InstancePlugin): + """Validate sequence, video track and track item names. + + When creating output directories with the name of an item, ending with a + whitespace will fail the extraction. + Exact matching to optimize processing. + """ + + order = api.ValidatorOrder + families = ["clip"] + match = api.Exact + label = "Names" + hosts = ["nukestudio"] + + def process(self, instance): + + item = instance.data["item"] + + msg = "Track item \"{0}\" ends with a whitespace." + assert not item.name().endswith(" "), msg.format(item.name()) + + msg = "Video track \"{0}\" ends with a whitespace." + msg = msg.format(item.parent().name()) + assert not item.parent().name().endswith(" "), msg + + msg = "Sequence \"{0}\" ends with a whitespace." + msg = msg.format(item.parent().parent().name()) + assert not item.parent().parent().name().endswith(" "), msg + + +class ValidateNamesFtrack(ValidateNames): + """Validate sequence, video track and track item names. + + Because we are matching the families exactly, we need this plugin to + accommodate for the ftrack family addition. + """ + + order = api.ValidatorOrder + families = ["clip", "ftrack"] diff --git a/pype/plugins/nukestudio/publish/validate_projectroot.py b/pype/plugins/nukestudio/publish/validate_projectroot.py new file mode 100644 index 0000000000..b9b851e0d1 --- /dev/null +++ b/pype/plugins/nukestudio/publish/validate_projectroot.py @@ -0,0 +1,52 @@ +from pyblish import api + + +class RepairProjectRoot(api.Action): + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + import os + + workspace = os.path.join( + os.path.dirname(context.data["currentFile"]), + "workspace" + ).replace("\\", "/") + + if not os.path.exists(workspace): + os.makedirs(workspace) + + context.data["activeProject"].setProjectRoot(workspace) + + # Need to manually fix the tasks "_projectRoot" attribute, because + # setting the project root is not enough. + submission = context.data.get("submission", None) + if submission: + for task in submission.getLeafTasks(): + task._projectRoot = workspace + + +class ValidateProjectRoot(api.ContextPlugin): + """Validate the project root to the workspace directory.""" + + order = api.ValidatorOrder + label = "Project Root" + hosts = ["nukestudio"] + actions = [RepairProjectRoot] + + def process(self, context): + import os + + workspace = os.path.join( + os.path.dirname(context.data["currentFile"]), + "workspace" + ).replace("\\", "/") + project_root = context.data["activeProject"].projectRoot() + + failure_message = ( + 'The project root needs to be "{0}", its currently: "{1}"' + ).format(workspace, project_root) + + assert project_root == workspace, failure_message diff --git a/pype/plugins/nukestudio/publish/validate_track_item.py b/pype/plugins/nukestudio/publish/validate_track_item.py new file mode 100644 index 0000000000..600bf58938 --- /dev/null +++ b/pype/plugins/nukestudio/publish/validate_track_item.py @@ -0,0 +1,46 @@ +from pyblish import api + +class ValidateClip(api.InstancePlugin): + """Validate the track item to the sequence. + + Exact matching to optimize processing. + """ + + order = api.ValidatorOrder + families = ["clip"] + match = api.Exact + label = "Validate Track Item" + hosts = ["nukestudio"] + optional = True + + def process(self, instance): + + item = instance.data["item"] + self.log.info("__ item: {}".format(item)) + media_source = item.source().mediaSource() + self.log.info("__ media_source: {}".format(media_source)) + + msg = ( + 'A setting does not match between track item "{0}" and sequence ' + '"{1}".'.format(item.name(), item.sequence().name()) + + '\n\nSetting: "{0}".''\n\nTrack item: "{1}".\n\nSequence: "{2}".' + ) + + # Validate format settings. + fmt = item.sequence().format() + assert fmt.width() == media_source.width(), msg.format( + "width", fmt.width(), media_source.width() + ) + assert fmt.height() == media_source.height(), msg.format( + "height", fmt.height(), media_source.height() + ) + assert fmt.pixelAspect() == media_source.pixelAspect(), msg.format( + "pixelAspect", fmt.pixelAspect(), media_source.pixelAspect() + ) + + # Validate framerate setting. + sequence = item.sequence() + source_framerate = media_source.metadata()["foundry.source.framerate"] + assert sequence.framerate() == source_framerate, msg.format( + "framerate", source_framerate, sequence.framerate() + ) diff --git a/pype/plugins/nukestudio/publish/validate_viewer_lut.py b/pype/plugins/nukestudio/publish/validate_viewer_lut.py new file mode 100644 index 0000000000..08c084880d --- /dev/null +++ b/pype/plugins/nukestudio/publish/validate_viewer_lut.py @@ -0,0 +1,21 @@ +from pyblish import api + + +class ValidateViewerLut(api.ContextPlugin): + """Validate viewer lut in NukeStudio is the same as in Nuke.""" + + order = api.ValidatorOrder + label = "Viewer LUT" + hosts = ["nukestudio"] + optional = True + + def process(self, context): + import nuke + import hiero + + # nuke_lut = nuke.ViewerProcess.node()["current"].value() + nukestudio_lut = context.data["activeProject"].lutSettingViewer() + self.log.info("__ nukestudio_lut: {}".format(nukestudio_lut)) + + msg = "Viewer LUT can only be RGB" + assert "RGB" in nukestudio_lut, msg diff --git a/pype/plugins/standalonepublish/publish/collect_context.py b/pype/plugins/standalonepublish/publish/collect_context.py new file mode 100644 index 0000000000..6ac2dca936 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/collect_context.py @@ -0,0 +1,87 @@ +import os +import pyblish.api +from avalon import ( + io, + api as avalon +) +import json +import logging +import clique + + +log = logging.getLogger("collector") + + +class CollectContextDataSAPublish(pyblish.api.ContextPlugin): + """ + Collecting temp json data sent from a host context + and path for returning json data back to hostself. + + Setting avalon session into correct context + + Args: + context (obj): pyblish context session + + """ + + label = "Collect Context - SA Publish" + order = pyblish.api.CollectorOrder - 0.49 + + def process(self, context): + # get json paths from os and load them + io.install() + input_json_path = os.environ.get("SAPUBLISH_INPATH") + output_json_path = os.environ.get("SAPUBLISH_OUTPATH") + + context.data["stagingDir"] = os.path.dirname(input_json_path) + context.data["returnJsonPath"] = output_json_path + + with open(input_json_path, "r") as f: + in_data = json.load(f) + + project_name = in_data['project'] + asset_name = in_data['asset'] + family = in_data['family'] + subset = in_data['subset'] + + project = io.find_one({'type': 'project'}) + asset = io.find_one({ + 'type': 'asset', + 'name': asset_name + }) + context.data['project'] = project + context.data['asset'] = asset + + instance = context.create_instance(subset) + + instance.data.update({ + "subset": subset, + "asset": asset_name, + "label": subset, + "name": subset, + "family": family, + "families": [family, 'ftrack'], + }) + self.log.info("collected instance: {}".format(instance.data)) + + instance.data["files"] = list() + instance.data['destination_list'] = list() + instance.data['representations'] = list() + + for component in in_data['representations']: + # instance.add(node) + component['destination'] = component['files'] + collections, remainder = clique.assemble(component['files']) + if collections: + self.log.debug(collections) + instance.data['startFrame'] = component['startFrame'] + instance.data['endFrame'] = component['endFrame'] + instance.data['frameRate'] = component['frameRate'] + + instance.data["files"].append(component) + instance.data["representations"].append(component) + + # "is_thumbnail": component['thumbnail'], + # "is_preview": component['preview'] + + self.log.info(in_data) diff --git a/pype/plugins/standalonepublish/publish/collect_ftrack_api.py b/pype/plugins/standalonepublish/publish/collect_ftrack_api.py new file mode 100644 index 0000000000..6df998350c --- /dev/null +++ b/pype/plugins/standalonepublish/publish/collect_ftrack_api.py @@ -0,0 +1,40 @@ +import os +import pyblish.api + +try: + import ftrack_api_old as ftrack_api +except Exception: + import ftrack_api + + +class CollectFtrackApi(pyblish.api.ContextPlugin): + """ Collects an ftrack session and the current task id. """ + + order = pyblish.api.CollectorOrder + label = "Collect Ftrack Api" + + def process(self, context): + + # Collect session + session = ftrack_api.Session() + context.data["ftrackSession"] = session + + # Collect task + + project = os.environ.get('AVALON_PROJECT', '') + asset = os.environ.get('AVALON_ASSET', '') + task = os.environ.get('AVALON_TASK', None) + + if task: + result = session.query('Task where\ + project.full_name is "{0}" and\ + name is "{1}" and\ + parent.name is "{2}"'.format(project, task, asset)).one() + context.data["ftrackTask"] = result + else: + result = session.query('TypedContext where\ + project.full_name is "{0}" and\ + name is "{1}"'.format(project, asset)).one() + context.data["ftrackEntity"] = result + + self.log.info(result) diff --git a/pype/plugins/standalonepublish/publish/collect_templates.py b/pype/plugins/standalonepublish/publish/collect_templates.py new file mode 100644 index 0000000000..b59b20892b --- /dev/null +++ b/pype/plugins/standalonepublish/publish/collect_templates.py @@ -0,0 +1,17 @@ + +import pype.api as pype +from pypeapp import Anatomy + +import pyblish.api + + +class CollectTemplates(pyblish.api.ContextPlugin): + """Inject the current working file into context""" + + order = pyblish.api.CollectorOrder + label = "Collect Templates" + + def process(self, context): + # pype.load_data_from_templates() + context.data['anatomy'] = Anatomy() + self.log.info("Anatomy templates collected...") diff --git a/pype/plugins/standalonepublish/publish/collect_time.py b/pype/plugins/standalonepublish/publish/collect_time.py new file mode 100644 index 0000000000..e0adc7dfc3 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/collect_time.py @@ -0,0 +1,12 @@ +import pyblish.api +from avalon import api + + +class CollectTime(pyblish.api.ContextPlugin): + """Store global time at the time of publish""" + + label = "Collect Current Time" + order = pyblish.api.CollectorOrder + + def process(self, context): + context.data["time"] = api.time() diff --git a/pype/plugins/standalonepublish/publish/integrate.py b/pype/plugins/standalonepublish/publish/integrate.py new file mode 100644 index 0000000000..b6771a52e0 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/integrate.py @@ -0,0 +1,448 @@ +import os +import logging +import shutil + +import errno +import pyblish.api +from avalon import api, io +from avalon.vendor import filelink +import clique + + +log = logging.getLogger(__name__) + + +class IntegrateAsset(pyblish.api.InstancePlugin): + """Resolve any dependency issies + + This plug-in resolves any paths which, if not updated might break + the published file. + + The order of families is important, when working with lookdev you want to + first publish the texture, update the texture paths in the nodes and then + publish the shading network. Same goes for file dependent assets. + """ + + label = "Integrate Asset" + order = pyblish.api.IntegratorOrder + families = ["animation", + "camera", + "look", + "mayaAscii", + "model", + "pointcache", + "vdbcache", + "setdress", + "assembly", + "layout", + "rig", + "vrayproxy", + "yetiRig", + "yeticache", + "nukescript", + # "review", + "workfile", + "scene", + "ass"] + exclude_families = ["clip"] + + def process(self, instance): + + if [ef for ef in self.exclude_families + if instance.data["family"] in ef]: + return + + self.register(instance) + + self.log.info("Integrating Asset in to the database ...") + self.integrate(instance) + + def register(self, instance): + # Required environment variables + PROJECT = api.Session["AVALON_PROJECT"] + ASSET = instance.data.get("asset") or api.Session["AVALON_ASSET"] + LOCATION = api.Session["AVALON_LOCATION"] + + context = instance.context + # Atomicity + # + # Guarantee atomic publishes - each asset contains + # an identical set of members. + # __ + # / o + # / \ + # | o | + # \ / + # o __/ + # + assert all(result["success"] for result in context.data["results"]), ( + "Atomicity not held, aborting.") + + # Assemble + # + # | + # v + # ---> <---- + # ^ + # | + # + # stagingdir = instance.data.get("stagingDir") + # assert stagingdir, ("Incomplete instance \"%s\": " + # "Missing reference to staging area." % instance) + + # extra check if stagingDir actually exists and is available + + # self.log.debug("Establishing staging directory @ %s" % stagingdir) + + # Ensure at least one file is set up for transfer in staging dir. + files = instance.data.get("files", []) + assert files, "Instance has no files to transfer" + assert isinstance(files, (list, tuple)), ( + "Instance 'files' must be a list, got: {0}".format(files) + ) + + project = io.find_one({"type": "project"}) + + asset = io.find_one({"type": "asset", + "name": ASSET, + "parent": project["_id"]}) + + assert all([project, asset]), ("Could not find current project or " + "asset '%s'" % ASSET) + + subset = self.get_subset(asset, instance) + + # get next version + latest_version = io.find_one({"type": "version", + "parent": subset["_id"]}, + {"name": True}, + sort=[("name", -1)]) + + next_version = 1 + if latest_version is not None: + next_version += latest_version["name"] + + self.log.info("Verifying version from assumed destination") + + # assumed_data = instance.data["assumedTemplateData"] + # assumed_version = assumed_data["version"] + # if assumed_version != next_version: + # raise AttributeError("Assumed version 'v{0:03d}' does not match" + # "next version in database " + # "('v{1:03d}')".format(assumed_version, + # next_version)) + + self.log.debug("Next version: v{0:03d}".format(next_version)) + + version_data = self.create_version_data(context, instance) + version = self.create_version(subset=subset, + version_number=next_version, + locations=[LOCATION], + data=version_data) + + self.log.debug("Creating version ...") + version_id = io.insert_one(version).inserted_id + instance.data['version'] = version['name'] + # Write to disk + # _ + # | | + # _| |_ + # ____\ / + # |\ \ / \ + # \ \ v \ + # \ \________. + # \|________| + # + root = api.registered_root() + hierarchy = "" + parents = io.find_one({ + "type": 'asset', + "name": ASSET + })['data']['parents'] + if parents and len(parents) > 0: + # hierarchy = os.path.sep.join(hierarchy) + hierarchy = os.path.join(*parents) + + template_data = {"root": root, + "project": {"name": PROJECT, + "code": project['data']['code']}, + "silo": asset['silo'], + "asset": ASSET, + "family": instance.data['family'], + "subset": subset["name"], + "version": int(version["name"]), + "hierarchy": hierarchy} + + template_publish = project["config"]["template"]["publish"] + anatomy = instance.context.data['anatomy'] + + # Find the representations to transfer amongst the files + # Each should be a single representation (as such, a single extension) + representations = [] + destination_list = [] + if 'transfers' not in instance.data: + instance.data['transfers'] = [] + + for idx, repre in enumerate(instance.data["representations"]): + + # Collection + # _______ + # |______|\ + # | |\| + # | || + # | || + # | || + # |_______| + # + + files = repre['files'] + + if len(files) > 1: + src_collections, remainder = clique.assemble(files) + self.log.debug("dst_collections: {}".format(str(src_collections))) + src_collection = src_collections[0] + # Assert that each member has identical suffix + src_head = src_collection.format("{head}") + src_tail = ext = src_collection.format("{tail}") + + test_dest_files = list() + for i in [1, 2]: + template_data["representation"] = src_tail[1:] + template_data["frame"] = src_collection.format( + "{padding}") % i + anatomy_filled = anatomy.format(template_data) + test_dest_files.append(anatomy_filled["publish"]["path"]) + + dst_collections, remainder = clique.assemble(test_dest_files) + dst_collection = dst_collections[0] + dst_head = dst_collection.format("{head}") + dst_tail = dst_collection.format("{tail}") + + instance.data["representations"][idx]['published_path'] = dst_collection.format() + + for i in src_collection.indexes: + src_padding = src_collection.format("{padding}") % i + src_file_name = "{0}{1}{2}".format( + src_head, src_padding, src_tail) + dst_padding = dst_collection.format("{padding}") % i + dst = "{0}{1}{2}".format(dst_head, dst_padding, dst_tail) + + # src = os.path.join(stagingdir, src_file_name) + src = src_file_name + self.log.debug("source: {}".format(src)) + + instance.data["transfers"].append([src, dst]) + + else: + # Single file + # _______ + # | |\ + # | | + # | | + # | | + # |_______| + # + fname = files[0] + # assert not os.path.isabs(fname), ( + # "Given file name is a full path" + # ) + # _, ext = os.path.splitext(fname) + + template_data["representation"] = repre['representation'] + + # src = os.path.join(stagingdir, fname) + src = fname + anatomy_filled = anatomy.format(template_data) + dst = anatomy_filled["publish"]["path"] + + instance.data["transfers"].append([src, dst]) + template = anatomy.templates["publish"]["path"] + instance.data["representations"][idx]['published_path'] = dst + + representation = { + "schema": "pype:representation-2.0", + "type": "representation", + "parent": version_id, + "name": repre['representation'], + "data": {'path': dst, 'template': template}, + "dependencies": instance.data.get("dependencies", "").split(), + + # Imprint shortcut to context + # for performance reasons. + "context": { + "root": root, + "project": {"name": PROJECT, + "code": project['data']['code']}, + # 'task': api.Session["AVALON_TASK"], + "silo": asset['silo'], + "asset": ASSET, + "family": instance.data['family'], + "subset": subset["name"], + "version": version["name"], + "hierarchy": hierarchy, + # "representation": repre['representation'] + } + } + + destination_list.append(dst) + instance.data['destination_list'] = destination_list + representations.append(representation) + + self.log.info("Registering {} items".format(len(representations))) + + io.insert_many(representations) + + def integrate(self, instance): + """Move the files + + Through `instance.data["transfers"]` + + Args: + instance: the instance to integrate + """ + + transfers = instance.data.get("transfers", list()) + + for src, dest in transfers: + self.log.info("Copying file .. {} -> {}".format(src, dest)) + self.copy_file(src, dest) + + # Produce hardlinked copies + # Note: hardlink can only be produced between two files on the same + # server/disk and editing one of the two will edit both files at once. + # As such it is recommended to only make hardlinks between static files + # to ensure publishes remain safe and non-edited. + hardlinks = instance.data.get("hardlinks", list()) + for src, dest in hardlinks: + self.log.info("Hardlinking file .. {} -> {}".format(src, dest)) + self.hardlink_file(src, dest) + + def copy_file(self, src, dst): + """ Copy given source to destination + + Arguments: + src (str): the source file which needs to be copied + dst (str): the destination of the sourc file + Returns: + None + """ + + dirname = os.path.dirname(dst) + try: + os.makedirs(dirname) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + self.log.critical("An unexpected error occurred.") + raise + + shutil.copy(src, dst) + + def hardlink_file(self, src, dst): + + dirname = os.path.dirname(dst) + try: + os.makedirs(dirname) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + self.log.critical("An unexpected error occurred.") + raise + + filelink.create(src, dst, filelink.HARDLINK) + + def get_subset(self, asset, instance): + + subset = io.find_one({"type": "subset", + "parent": asset["_id"], + "name": instance.data["subset"]}) + + if subset is None: + subset_name = instance.data["subset"] + self.log.info("Subset '%s' not found, creating.." % subset_name) + + _id = io.insert_one({ + "schema": "avalon-core:subset-2.0", + "type": "subset", + "name": subset_name, + "data": {}, + "parent": asset["_id"] + }).inserted_id + + subset = io.find_one({"_id": _id}) + + return subset + + def create_version(self, subset, version_number, locations, data=None): + """ Copy given source to destination + + Args: + subset (dict): the registered subset of the asset + version_number (int): the version number + locations (list): the currently registered locations + + Returns: + dict: collection of data to create a version + """ + # Imprint currently registered location + version_locations = [location for location in locations if + location is not None] + + return {"schema": "avalon-core:version-2.0", + "type": "version", + "parent": subset["_id"], + "name": version_number, + "locations": version_locations, + "data": data} + + def create_version_data(self, context, instance): + """Create the data collection for the version + + Args: + context: the current context + instance: the current instance being published + + Returns: + dict: the required information with instance.data as key + """ + + families = [] + current_families = instance.data.get("families", list()) + instance_family = instance.data.get("family", None) + + if instance_family is not None: + families.append(instance_family) + families += current_families + + self.log.debug("Registered root: {}".format(api.registered_root())) + # # create relative source path for DB + # try: + # source = instance.data['source'] + # except KeyError: + # source = context.data["currentFile"] + # + # relative_path = os.path.relpath(source, api.registered_root()) + # source = os.path.join("{root}", relative_path).replace("\\", "/") + + source = "standalone" + + # self.log.debug("Source: {}".format(source)) + version_data = {"families": families, + "time": context.data["time"], + "author": context.data["user"], + "source": source, + "comment": context.data.get("comment"), + "machine": context.data.get("machine"), + "fps": context.data.get("fps")} + + # Include optional data if present in + optionals = [ + "startFrame", "endFrame", "step", "handles", "sourceHashes" + ] + for key in optionals: + if key in instance.data: + version_data[key] = instance.data[key] + + return version_data diff --git a/pype/plugins/standalonepublish/publish/integrate_ftrack_api.py b/pype/plugins/standalonepublish/publish/integrate_ftrack_api.py new file mode 100644 index 0000000000..9eff10ba67 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/integrate_ftrack_api.py @@ -0,0 +1,315 @@ +import os +import sys +import pyblish.api +import clique + + +class IntegrateFtrackApi(pyblish.api.InstancePlugin): + """ Commit components to server. """ + + order = pyblish.api.IntegratorOrder+0.499 + label = "Integrate Ftrack Api" + families = ["ftrack"] + + def query(self, entitytype, data): + """ Generate a query expression from data supplied. + + If a value is not a string, we'll add the id of the entity to the + query. + + Args: + entitytype (str): The type of entity to query. + data (dict): The data to identify the entity. + exclusions (list): All keys to exclude from the query. + + Returns: + str: String query to use with "session.query" + """ + queries = [] + if sys.version_info[0] < 3: + for key, value in data.iteritems(): + if not isinstance(value, (basestring, int)): + self.log.info("value: {}".format(value)) + if "id" in value.keys(): + queries.append( + "{0}.id is \"{1}\"".format(key, value["id"]) + ) + else: + queries.append("{0} is \"{1}\"".format(key, value)) + else: + for key, value in data.items(): + if not isinstance(value, (str, int)): + self.log.info("value: {}".format(value)) + if "id" in value.keys(): + queries.append( + "{0}.id is \"{1}\"".format(key, value["id"]) + ) + else: + queries.append("{0} is \"{1}\"".format(key, value)) + + query = ( + "select id from " + entitytype + " where " + " and ".join(queries) + ) + self.log.debug(query) + return query + + def process(self, instance): + + session = instance.context.data["ftrackSession"] + if instance.context.data.get("ftrackTask"): + task = instance.context.data["ftrackTask"] + name = task['full_name'] + parent = task["parent"] + elif instance.context.data.get("ftrackEntity"): + task = None + name = instance.context.data.get("ftrackEntity")['name'] + parent = instance.context.data.get("ftrackEntity") + + info_msg = "Created new {entity_type} with data: {data}" + info_msg += ", metadata: {metadata}." + + # Iterate over components and publish + for data in instance.data.get("ftrackComponentsList", []): + + # AssetType + # Get existing entity. + assettype_data = {"short": "upload"} + assettype_data.update(data.get("assettype_data", {})) + self.log.debug("data: {}".format(data)) + + assettype_entity = session.query( + self.query("AssetType", assettype_data) + ).first() + + # 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) + ) + + # Asset + # Get existing entity. + asset_data = { + "name": name, + "type": assettype_entity, + "parent": parent, + } + asset_data.update(data.get("asset_data", {})) + + asset_entity = session.query( + self.query("Asset", asset_data) + ).first() + + self.log.info("asset entity: {}".format(asset_entity)) + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + asset_metadata = asset_data.pop("metadata", {}) + + # Create a new entity if none exits. + if not asset_entity: + asset_entity = session.create("Asset", asset_data) + self.log.debug( + info_msg.format( + entity_type="Asset", + data=asset_data, + metadata=asset_metadata + ) + ) + + # Adding metadata + existing_asset_metadata = asset_entity["metadata"] + existing_asset_metadata.update(asset_metadata) + asset_entity["metadata"] = existing_asset_metadata + + # AssetVersion + # Get existing entity. + assetversion_data = { + "version": 0, + "asset": asset_entity, + } + if task: + assetversion_data['task'] = task + + assetversion_data.update(data.get("assetversion_data", {})) + + assetversion_entity = session.query( + self.query("AssetVersion", assetversion_data) + ).first() + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + assetversion_metadata = assetversion_data.pop("metadata", {}) + + # Create a new entity if none exits. + if not assetversion_entity: + assetversion_entity = session.create( + "AssetVersion", assetversion_data + ) + self.log.debug( + info_msg.format( + entity_type="AssetVersion", + data=assetversion_data, + metadata=assetversion_metadata + ) + ) + + # Adding metadata + existing_assetversion_metadata = assetversion_entity["metadata"] + existing_assetversion_metadata.update(assetversion_metadata) + assetversion_entity["metadata"] = existing_assetversion_metadata + + # Have to commit the version and asset, because location can't + # determine the final location without. + session.commit() + + # Component + # Get existing entity. + component_data = { + "name": "main", + "version": assetversion_entity + } + component_data.update(data.get("component_data", {})) + + component_entity = session.query( + self.query("Component", component_data) + ).first() + + component_overwrite = data.get("component_overwrite", False) + location = data.get("component_location", session.pick_location()) + + # Overwrite existing component data if requested. + if component_entity and component_overwrite: + + origin_location = session.query( + "Location where name is \"ftrack.origin\"" + ).one() + + # Removing existing members from location + components = list(component_entity.get("members", [])) + components += [component_entity] + for component in components: + for loc in component["component_locations"]: + if location["id"] == loc["location_id"]: + location.remove_component( + component, recursive=False + ) + + # Deleting existing members on component entity + for member in component_entity.get("members", []): + session.delete(member) + del(member) + + session.commit() + + # Reset members in memory + if "members" in component_entity.keys(): + component_entity["members"] = [] + + # Add components to origin location + try: + collection = clique.parse(data["component_path"]) + except ValueError: + # Assume its a single file + # Changing file type + name, ext = os.path.splitext(data["component_path"]) + component_entity["file_type"] = ext + + origin_location.add_component( + component_entity, data["component_path"] + ) + else: + # Changing file type + component_entity["file_type"] = collection.format("{tail}") + + # Create member components for sequence. + for member_path in collection: + + size = 0 + try: + size = os.path.getsize(member_path) + except OSError: + pass + + name = collection.match(member_path).group("index") + + member_data = { + "name": name, + "container": component_entity, + "size": size, + "file_type": os.path.splitext(member_path)[-1] + } + + component = session.create( + "FileComponent", member_data + ) + origin_location.add_component( + component, member_path, recursive=False + ) + component_entity["members"].append(component) + + # Add components to location. + location.add_component( + component_entity, origin_location, recursive=True + ) + + data["component"] = component_entity + msg = "Overwriting Component with path: {0}, data: {1}, " + msg += "location: {2}" + self.log.info( + msg.format( + data["component_path"], + component_data, + location + ) + ) + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + component_metadata = component_data.pop("metadata", {}) + + # Create new component if none exists. + new_component = False + if not component_entity: + component_entity = assetversion_entity.create_component( + data["component_path"], + data=component_data, + location=location + ) + data["component"] = component_entity + msg = "Created new Component with path: {0}, data: {1}" + msg += ", metadata: {2}, location: {3}" + self.log.info( + msg.format( + data["component_path"], + component_data, + component_metadata, + location + ) + ) + new_component = True + + # Adding metadata + existing_component_metadata = component_entity["metadata"] + existing_component_metadata.update(component_metadata) + component_entity["metadata"] = existing_component_metadata + + # if component_data['name'] = 'ftrackreview-mp4-mp4': + # assetversion_entity["thumbnail_id"] + + # Setting assetversion thumbnail + if data.get("thumbnail", False): + assetversion_entity["thumbnail_id"] = component_entity["id"] + + # Inform user about no changes to the database. + if (component_entity and not component_overwrite and + not new_component): + data["component"] = component_entity + self.log.info( + "Found existing component, and no request to overwrite. " + "Nothing has been changed." + ) + else: + # Commit changes. + session.commit() diff --git a/pype/plugins/standalonepublish/publish/integrate_ftrack_instances.py b/pype/plugins/standalonepublish/publish/integrate_ftrack_instances.py new file mode 100644 index 0000000000..0dc9bb137c --- /dev/null +++ b/pype/plugins/standalonepublish/publish/integrate_ftrack_instances.py @@ -0,0 +1,101 @@ +import pyblish.api +import os +import json + + +class IntegrateFtrackInstance(pyblish.api.InstancePlugin): + """Collect ftrack component data + + Add ftrack component list to instance. + + + """ + + order = pyblish.api.IntegratorOrder + 0.48 + label = 'Integrate Ftrack Component' + families = ["ftrack"] + + family_mapping = {'camera': 'cam', + 'look': 'look', + 'mayaAscii': 'scene', + 'model': 'geo', + 'rig': 'rig', + 'setdress': 'setdress', + 'pointcache': 'cache', + 'write': 'img', + 'render': 'render', + 'nukescript': 'comp', + 'review': 'mov'} + + def process(self, instance): + self.log.debug('instance {}'.format(instance)) + + if instance.data.get('version'): + version_number = int(instance.data.get('version')) + + family = instance.data['family'].lower() + + asset_type = '' + asset_type = self.family_mapping[family] + + componentList = [] + ft_session = instance.context.data["ftrackSession"] + + components = instance.data['representations'] + + for comp in components: + self.log.debug('component {}'.format(comp)) + # filename, ext = os.path.splitext(file) + # self.log.debug('dest ext: ' + ext) + + # ext = comp['Context'] + + if comp['thumbnail']: + location = ft_session.query( + 'Location where name is "ftrack.server"').one() + component_data = { + "name": "thumbnail" # Default component name is "main". + } + elif comp['preview']: + if not comp.get('startFrameReview'): + comp['startFrameReview'] = comp['startFrame'] + if not comp.get('endFrameReview'): + comp['endFrameReview'] = instance.data['endFrame'] + location = ft_session.query( + 'Location where name is "ftrack.server"').one() + component_data = { + # Default component name is "main". + "name": "ftrackreview-mp4", + "metadata": {'ftr_meta': json.dumps({ + 'frameIn': int(comp['startFrameReview']), + 'frameOut': int(comp['endFrameReview']), + 'frameRate': float(comp['frameRate')]})} + } + else: + component_data = { + "name": comp['representation'] # Default component name is "main". + } + location = ft_session.query( + 'Location where name is "ftrack.unmanaged"').one() + + self.log.debug('location {}'.format(location)) + + componentList.append({"assettype_data": { + "short": asset_type, + }, + "asset_data": { + "name": instance.data["subset"], + }, + "assetversion_data": { + "version": version_number, + }, + "component_data": component_data, + "component_path": comp['published_path'], + 'component_location': location, + "component_overwrite": False, + "thumbnail": comp['thumbnail'] + } + ) + + self.log.debug('componentsList: {}'.format(str(componentList))) + instance.data["ftrackComponentsList"] = componentList diff --git a/pype/plugins/standalonepublish/publish/integrate_rendered_frames.py b/pype/plugins/standalonepublish/publish/integrate_rendered_frames.py new file mode 100644 index 0000000000..43653ab0ed --- /dev/null +++ b/pype/plugins/standalonepublish/publish/integrate_rendered_frames.py @@ -0,0 +1,436 @@ +import os +import logging +import shutil +import clique + +import errno +import pyblish.api +from avalon import api, io + + +log = logging.getLogger(__name__) + + +class IntegrateFrames(pyblish.api.InstancePlugin): + """Resolve any dependency issies + + This plug-in resolves any paths which, if not updated might break + the published file. + + The order of families is important, when working with lookdev you want to + first publish the texture, update the texture paths in the nodes and then + publish the shading network. Same goes for file dependent assets. + """ + + label = "Integrate Frames" + order = pyblish.api.IntegratorOrder + families = [ + "imagesequence", + "render", + "write", + "source", + 'review'] + + family_targets = [".frames", ".local", ".review", "review", "imagesequence", "render", "source"] + exclude_families = ["clip"] + + def process(self, instance): + if [ef for ef in self.exclude_families + if instance.data["family"] in ef]: + return + + families = [f for f in instance.data["families"] + for search in self.family_targets + if search in f] + + if not families: + return + + self.register(instance) + + # self.log.info("Integrating Asset in to the database ...") + # self.log.info("instance.data: {}".format(instance.data)) + if instance.data.get('transfer', True): + self.integrate(instance) + + def register(self, instance): + + # Required environment variables + PROJECT = api.Session["AVALON_PROJECT"] + ASSET = instance.data.get("asset") or api.Session["AVALON_ASSET"] + LOCATION = api.Session["AVALON_LOCATION"] + + context = instance.context + # Atomicity + # + # Guarantee atomic publishes - each asset contains + # an identical set of members. + # __ + # / o + # / \ + # | o | + # \ / + # o __/ + # + assert all(result["success"] for result in context.data["results"]), ( + "Atomicity not held, aborting.") + + # Assemble + # + # | + # v + # ---> <---- + # ^ + # | + # + # stagingdir = instance.data.get("stagingDir") + # assert stagingdir, ("Incomplete instance \"%s\": " + # "Missing reference to staging area." % instance) + + # extra check if stagingDir actually exists and is available + + # self.log.debug("Establishing staging directory @ %s" % stagingdir) + + project = io.find_one({"type": "project"}) + + asset = io.find_one({"type": "asset", + "name": ASSET, + "parent": project["_id"]}) + + assert all([project, asset]), ("Could not find current project or " + "asset '%s'" % ASSET) + + subset = self.get_subset(asset, instance) + + # get next version + latest_version = io.find_one({"type": "version", + "parent": subset["_id"]}, + {"name": True}, + sort=[("name", -1)]) + + next_version = 1 + if latest_version is not None: + next_version += latest_version["name"] + + self.log.info("Verifying version from assumed destination") + + # assumed_data = instance.data["assumedTemplateData"] + # assumed_version = assumed_data["version"] + # if assumed_version != next_version: + # raise AttributeError("Assumed version 'v{0:03d}' does not match" + # "next version in database " + # "('v{1:03d}')".format(assumed_version, + # next_version)) + + if instance.data.get('version'): + next_version = int(instance.data.get('version')) + + instance.data['version'] = next_version + + self.log.debug("Next version: v{0:03d}".format(next_version)) + + version_data = self.create_version_data(context, instance) + version = self.create_version(subset=subset, + version_number=next_version, + locations=[LOCATION], + data=version_data) + + self.log.debug("Creating version ...") + version_id = io.insert_one(version).inserted_id + + # Write to disk + # _ + # | | + # _| |_ + # ____\ / + # |\ \ / \ + # \ \ v \ + # \ \________. + # \|________| + # + root = api.registered_root() + hierarchy = "" + parents = io.find_one({"type": 'asset', "name": ASSET})[ + 'data']['parents'] + if parents and len(parents) > 0: + # hierarchy = os.path.sep.join(hierarchy) + hierarchy = os.path.join(*parents) + + template_data = {"root": root, + "project": {"name": PROJECT, + "code": project['data']['code']}, + "silo": asset['silo'], + "task": api.Session["AVALON_TASK"], + "asset": ASSET, + "family": instance.data['family'], + "subset": subset["name"], + "version": int(version["name"]), + "hierarchy": hierarchy} + + # template_publish = project["config"]["template"]["publish"] + anatomy = instance.context.data['anatomy'] + + # Find the representations to transfer amongst the files + # Each should be a single representation (as such, a single extension) + representations = [] + destination_list = [] + + if 'transfers' not in instance.data: + instance.data['transfers'] = [] + + # for repre in instance.data["representations"]: + for idx, repre in enumerate(instance.data["representations"]): + # Collection + # _______ + # |______|\ + # | |\| + # | || + # | || + # | || + # |_______| + # + + files = repre['files'] + + if len(files) > 1: + + src_collections, remainder = clique.assemble(files) + self.log.debug("dst_collections: {}".format(str(src_collections))) + src_collection = src_collections[0] + # Assert that each member has identical suffix + src_head = src_collection.format("{head}") + src_tail = ext = src_collection.format("{tail}") + + test_dest_files = list() + for i in [1, 2]: + template_data["representation"] = repre['representation'] + template_data["frame"] = src_collection.format( + "{padding}") % i + anatomy_filled = anatomy.format(template_data) + test_dest_files.append(anatomy_filled["render"]["path"]) + + dst_collections, remainder = clique.assemble(test_dest_files) + dst_collection = dst_collections[0] + dst_head = dst_collection.format("{head}") + dst_tail = dst_collection.format("{tail}") + + instance.data["representations"][idx]['published_path'] = dst_collection.format() + + for i in src_collection.indexes: + src_padding = src_collection.format("{padding}") % i + src_file_name = "{0}{1}{2}".format( + src_head, src_padding, src_tail) + dst_padding = dst_collection.format("{padding}") % i + dst = "{0}{1}{2}".format(dst_head, dst_padding, dst_tail) + + # src = os.path.join(stagingdir, src_file_name) + src = src_file_name + self.log.debug("source: {}".format(src)) + + instance.data["transfers"].append([src, dst]) + + else: + # Single file + # _______ + # | |\ + # | | + # | | + # | | + # |_______| + # + + template_data.pop("frame", None) + + fname = files[0] + + self.log.info("fname: {}".format(fname)) + + # assert not os.path.isabs(fname), ( + # "Given file name is a full path" + # ) + # _, ext = os.path.splitext(fname) + + template_data["representation"] = repre['representation'] + + # src = os.path.join(stagingdir, fname) + src = src_file_name + + anatomy_filled = anatomy.format(template_data) + dst = anatomy_filled["render"]["path"] + + instance.data["transfers"].append([src, dst]) + instance.data["representations"][idx]['published_path'] = dst + + if repre['ext'] not in ["jpeg", "jpg", "mov", "mp4", "wav"]: + template_data["frame"] = "#" * int(anatomy_filled["render"]["padding"]) + + anatomy_filled = anatomy.format(template_data) + path_to_save = anatomy_filled["render"]["path"] + template = anatomy.templates["render"]["path"] + + self.log.debug("path_to_save: {}".format(path_to_save)) + + representation = { + "schema": "pype:representation-2.0", + "type": "representation", + "parent": version_id, + "name": repre['representation'], + "data": {'path': path_to_save, 'template': template}, + "dependencies": instance.data.get("dependencies", "").split(), + + # Imprint shortcut to context + # for performance reasons. + "context": { + "root": root, + "project": { + "name": PROJECT, + "code": project['data']['code'] + }, + "task": api.Session["AVALON_TASK"], + "silo": asset['silo'], + "asset": ASSET, + "family": instance.data['family'], + "subset": subset["name"], + "version": int(version["name"]), + "hierarchy": hierarchy, + "representation": repre['representation'] + } + } + + destination_list.append(dst) + instance.data['destination_list'] = destination_list + representations.append(representation) + + self.log.info("Registering {} items".format(len(representations))) + io.insert_many(representations) + + def integrate(self, instance): + """Move the files + + Through `instance.data["transfers"]` + + Args: + instance: the instance to integrate + """ + + transfers = instance.data["transfers"] + + for src, dest in transfers: + src = os.path.normpath(src) + dest = os.path.normpath(dest) + if src in dest: + continue + + self.log.info("Copying file .. {} -> {}".format(src, dest)) + self.copy_file(src, dest) + + def copy_file(self, src, dst): + """ Copy given source to destination + + Arguments: + src (str): the source file which needs to be copied + dst (str): the destination of the sourc file + Returns: + None + """ + + dirname = os.path.dirname(dst) + try: + os.makedirs(dirname) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + self.log.critical("An unexpected error occurred.") + raise + + shutil.copy(src, dst) + + def get_subset(self, asset, instance): + + subset = io.find_one({"type": "subset", + "parent": asset["_id"], + "name": instance.data["subset"]}) + + if subset is None: + subset_name = instance.data["subset"] + self.log.info("Subset '%s' not found, creating.." % subset_name) + + _id = io.insert_one({ + "schema": "pype:subset-2.0", + "type": "subset", + "name": subset_name, + "data": {}, + "parent": asset["_id"] + }).inserted_id + + subset = io.find_one({"_id": _id}) + + return subset + + def create_version(self, subset, version_number, locations, data=None): + """ Copy given source to destination + + Args: + subset (dict): the registered subset of the asset + version_number (int): the version number + locations (list): the currently registered locations + + Returns: + dict: collection of data to create a version + """ + # Imprint currently registered location + version_locations = [location for location in locations if + location is not None] + + return {"schema": "pype:version-2.0", + "type": "version", + "parent": subset["_id"], + "name": version_number, + "locations": version_locations, + "data": data} + + def create_version_data(self, context, instance): + """Create the data collection for the version + + Args: + context: the current context + instance: the current instance being published + + Returns: + dict: the required information with instance.data as key + """ + + families = [] + current_families = instance.data.get("families", list()) + instance_family = instance.data.get("family", None) + + if instance_family is not None: + families.append(instance_family) + families += current_families + + # try: + # source = instance.data['source'] + # except KeyError: + # source = context.data["currentFile"] + # + # relative_path = os.path.relpath(source, api.registered_root()) + # source = os.path.join("{root}", relative_path).replace("\\", "/") + + source = "standalone" + + version_data = {"families": families, + "time": context.data["time"], + "author": context.data["user"], + "source": source, + "comment": context.data.get("comment")} + + # Include optional data if present in + optionals = ["startFrame", "endFrame", "step", + "handles", "colorspace", "fps", "outputDir"] + + for key in optionals: + if key in instance.data: + version_data[key] = instance.data.get(key, None) + + return version_data diff --git a/pype/premiere/__init__.py b/pype/premiere/__init__.py index 3d6610795e..cc5abe115e 100644 --- a/pype/premiere/__init__.py +++ b/pype/premiere/__init__.py @@ -1,18 +1,17 @@ import os -import sys from pysync import walktree from avalon import api as avalon from pyblish import api as pyblish -from app import api as app -from pprint import pprint +from pypeapp import Logger from .. import api +from ..widgets.message_window import message + import requests - -log = api.Logger.getLogger(__name__, "premiere") +log = Logger().get_logger(__name__, "premiere") AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") EXTENSIONS_PATH_LOCAL = os.getenv("EXTENSIONS_PATH", None) @@ -45,7 +44,7 @@ def request_aport(url_path, data={}): return req except Exception as e: - api.message(title="Premiere Aport Server", + message(title="Premiere Aport Server", message="Before you can run Premiere, start Aport Server. \n Error: {}".format( e), level="critical") @@ -102,7 +101,7 @@ def install(): # synchronize extensions extensions_sync() - api.message(title="pyblish_paths", message=str(reg_paths), level="info") + message(title="pyblish_paths", message=str(reg_paths), level="info") def uninstall(): diff --git a/pype/premiere/templates.py b/pype/premiere/templates.py index 33a7a6ff61..e53d529cc1 100644 --- a/pype/premiere/templates.py +++ b/pype/premiere/templates.py @@ -1,6 +1,6 @@ from pype import api as pype -log = pype.Logger.getLogger(__name__, "premiere") +log = pype.Logger().get_logger(__name__, "premiere") def get_anatomy(**kwarg): diff --git a/pype/services/__init__.py b/pype/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pype/services/idle_manager/__init__.py b/pype/services/idle_manager/__init__.py new file mode 100644 index 0000000000..f1a87bef41 --- /dev/null +++ b/pype/services/idle_manager/__init__.py @@ -0,0 +1,5 @@ +from .idle_manager import IdleManager + + +def tray_init(tray_widget, main_widget): + return IdleManager() diff --git a/pype/services/idle_manager/idle_manager.py b/pype/services/idle_manager/idle_manager.py new file mode 100644 index 0000000000..57b827a37e --- /dev/null +++ b/pype/services/idle_manager/idle_manager.py @@ -0,0 +1,128 @@ +import time +from Qt import QtCore, QtGui, QtWidgets +from pype.vendor.pynput import mouse, keyboard +from pypeapp import Logger + + +class IdleManager(QtCore.QThread): + """ 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 = {} + 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 + + def set_qaction(self, qaction, failed_icon): + self.qaction = qaction + self.failed_icon = failed_icon + + def tray_start(self): + self.start() + + 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 + """ + if emit_time not in self.time_signals: + self.time_signals[emit_time] = [] + self.time_signals[emit_time].append(signal) + + @property + def is_running(self): + return self._is_running + + def _reset_time(self): + self.idle_time = 0 + + def stop(self): + self._is_running = False + + def run(self): + self.log.info('IdleManager has started') + self._is_running = True + thread_mouse = MouseThread(self.signal_reset_timer) + thread_mouse.start() + thread_keyboard = KeyboardThread(self.signal_reset_timer) + thread_keyboard.start() + try: + while self.is_running: + 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 + ) + + if self.qaction and self.failed_icon: + self.qaction.setIcon(self.failed_icon) + thread_mouse.signal_stop.emit() + thread_mouse.terminate() + thread_mouse.wait() + thread_keyboard.signal_stop.emit() + thread_keyboard.terminate() + thread_keyboard.wait() + self._is_running = False + self.log.info('IdleManager has stopped') + + +class MouseThread(QtCore.QThread): + """Listens user's mouse movement + """ + signal_stop = QtCore.Signal() + + 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 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() + + +class KeyboardThread(QtCore.QThread): + """Listens user's keyboard input + """ + signal_stop = QtCore.Signal() + + def __init__(self, signal): + super(KeyboardThread, self).__init__() + self.signal_stop.connect(self.stop) + self.k_listener = None + + self.signal_reset_timer = signal + + def stop(self): + if self.k_listener is not None: + self.k_listener.stop() + + 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() diff --git a/pype/services/statics_server/__init__.py b/pype/services/statics_server/__init__.py new file mode 100644 index 0000000000..4b2721b18b --- /dev/null +++ b/pype/services/statics_server/__init__.py @@ -0,0 +1,5 @@ +from .statics_server import StaticsServer + + +def tray_init(tray_widget, main_widget): + return StaticsServer() diff --git a/pype/services/statics_server/statics_server.py b/pype/services/statics_server/statics_server.py new file mode 100644 index 0000000000..8655cd9df9 --- /dev/null +++ b/pype/services/statics_server/statics_server.py @@ -0,0 +1,202 @@ +import os +import sys +import datetime +import socket +import http.server +from http import HTTPStatus +import urllib +import posixpath +import socketserver + +from Qt import QtCore +from pypeapp import config, Logger + + +DIRECTORY = os.path.sep.join([os.environ['PYPE_MODULE_ROOT'], 'res']) + + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + py_version = sys.version.split('.') + # If python version is 3.7 or higher + if int(py_version[0]) >= 3 and int(py_version[1]) >= 7: + super().__init__(*args, directory=DIRECTORY, **kwargs) + else: + self.directory = DIRECTORY + super().__init__(*args, **kwargs) + + def send_head(self): + """Common code for GET and HEAD commands. + + This sends the response code and MIME headers. + + Return value is either a file object (which has to be copied + to the outputfile by the caller unless the command was HEAD, + and must be closed by the caller under all circumstances), or + None, in which case the caller has nothing further to do. + + """ + path = self.translate_path(self.path) + f = None + if os.path.isdir(path): + parts = urllib.parse.urlsplit(self.path) + if not parts.path.endswith('/'): + # redirect browser - doing basically what apache does + self.send_response(HTTPStatus.MOVED_PERMANENTLY) + new_parts = (parts[0], parts[1], parts[2] + '/', + parts[3], parts[4]) + new_url = urllib.parse.urlunsplit(new_parts) + self.send_header("Location", new_url) + self.end_headers() + return None + for index in "index.html", "index.htm": + index = os.path.join(path, index) + if os.path.exists(index): + path = index + break + else: + return self.list_directory(path) + ctype = self.guess_type(path) + try: + f = open(path, 'rb') + except OSError: + self.send_error(HTTPStatus.NOT_FOUND, "File not found") + return None + + try: + fs = os.fstat(f.fileno()) + # Use browser cache if possible + if ("If-Modified-Since" in self.headers + and "If-None-Match" not in self.headers): + # compare If-Modified-Since and time of last file modification + try: + ims = http.server.email.utils.parsedate_to_datetime( + self.headers["If-Modified-Since"]) + except (TypeError, IndexError, OverflowError, ValueError): + # ignore ill-formed values + pass + else: + if ims.tzinfo is None: + # obsolete format with no timezone, cf. + # https://tools.ietf.org/html/rfc7231#section-7.1.1.1 + ims = ims.replace(tzinfo=datetime.timezone.utc) + if ims.tzinfo is datetime.timezone.utc: + # compare to UTC datetime of last modification + last_modif = datetime.datetime.fromtimestamp( + fs.st_mtime, datetime.timezone.utc) + # remove microseconds, like in If-Modified-Since + last_modif = last_modif.replace(microsecond=0) + + if last_modif <= ims: + self.send_response(HTTPStatus.NOT_MODIFIED) + self.end_headers() + f.close() + return None + + self.send_response(HTTPStatus.OK) + self.send_header("Content-type", ctype) + self.send_header("Content-Length", str(fs[6])) + self.send_header("Last-Modified", + self.date_time_string(fs.st_mtime)) + self.end_headers() + return f + except: + f.close() + raise + + def translate_path(self, path): + """Translate a /-separated PATH to the local filename syntax. + + Components that mean special things to the local file system + (e.g. drive or directory names) are ignored. (XXX They should + probably be diagnosed.) + + """ + # abandon query parameters + path = path.split('?',1)[0] + path = path.split('#',1)[0] + # Don't forget explicit trailing slash when normalizing. Issue17324 + trailing_slash = path.rstrip().endswith('/') + try: + path = urllib.parse.unquote(path, errors='surrogatepass') + except UnicodeDecodeError: + path = urllib.parse.unquote(path) + path = posixpath.normpath(path) + words = path.split('/') + words = filter(None, words) + path = self.directory + for word in words: + if os.path.dirname(word) or word in (os.curdir, os.pardir): + # Ignore components that are not a simple file/directory name + continue + path = os.path.join(path, word) + if trailing_slash: + path += '/' + return path + + +class StaticsServer(QtCore.QThread): + """ Measure user's idle time in seconds. + Idle time resets on keyboard/mouse input. + Is able to emit signals at specific time idle. + """ + def __init__(self): + super(StaticsServer, self).__init__() + self.qaction = None + self.failed_icon = None + self._is_running = False + self.log = Logger().get_logger(self.__class__.__name__) + try: + self.presets = config.get_presets().get( + 'services', {}).get('statics_server') + except Exception: + self.presets = {'default_port': 8010, 'exclude_ports': []} + + self.port = self.find_port() + + def set_qaction(self, qaction, failed_icon): + self.qaction = qaction + self.failed_icon = failed_icon + + def tray_start(self): + self.start() + + @property + def is_running(self): + return self._is_running + + def stop(self): + self._is_running = False + + def run(self): + self._is_running = True + try: + with socketserver.TCPServer(("", self.port), Handler) as httpd: + while self._is_running: + httpd.handle_request() + except Exception: + self.log.warning( + 'Statics Server service has failed', exc_info=True + ) + self._is_running = False + if self.qaction and self.failed_icon: + self.qaction.setIcon(self.failed_icon) + + def find_port(self): + start_port = self.presets['default_port'] + exclude_ports = self.presets['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): + if port in exclude_ports: + continue + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + result = sock.connect_ex(('localhost', port)) + if result != 0: + found_port = port + if found_port is not None: + break + if found_port is None: + return None + os.environ['PYPE_STATICS_SERVER'] = 'http://localhost:{}'.format(found_port) + return found_port diff --git a/pype/services/timers_manager/__init__.py b/pype/services/timers_manager/__init__.py new file mode 100644 index 0000000000..a6c4535f3d --- /dev/null +++ b/pype/services/timers_manager/__init__.py @@ -0,0 +1,6 @@ +from .timers_manager import TimersManager +from .widget_user_idle import WidgetUserIdle + + +def tray_init(tray_widget, main_widget): + return TimersManager(tray_widget, main_widget) diff --git a/pype/services/timers_manager/timers_manager.py b/pype/services/timers_manager/timers_manager.py new file mode 100644 index 0000000000..6f10a0ec68 --- /dev/null +++ b/pype/services/timers_manager/timers_manager.py @@ -0,0 +1,175 @@ +from Qt import QtCore +from .widget_user_idle import WidgetUserIdle +from pypeapp.lib.config import get_presets +from pypeapp import Logger + + +class Singleton(type): + """ Signleton implementation + """ + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super( + Singleton, cls + ).__call__(*args, **kwargs) + return cls._instances[cls] + + +class TimersManager(metaclass=Singleton): + """ Handles about Timers. + + Should be able to start/stop all timers at once. + 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 + + def __init__(self, tray_widget, main_widget): + self.log = Logger().get_logger(self.__class__.__name__) + self.tray_widget = tray_widget + self.main_widget = main_widget + self.widget_user_idle = WidgetUserIdle(self) + + def set_signal_times(self): + try: + timer_info = get_presets()['services']['timers_manager']['timer'] + full_time = int(timer_info['full_time'])*60 + message_time = int(timer_info['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 + + def add_module(self, module): + """ Adds module to context + + Module must have implemented methods: + - ``start_timer_manager(data)`` + - ``stop_timer_manager()`` + """ + self.modules.append(module) + + def start_timers(self, data): + ''' + :param data: basic information needed to start any timer + :type data: dict + ..note:: + Dictionary "data" should contain: + - project_name(str) - Name of Project + - hierarchy(list/tuple) - list of parents(except project) + - task_type(str) + - task_name(str) + + Example: + - to run timers for task in + 'C001_BackToPast/assets/characters/villian/Lookdev BG' + - input data should contain: + .. code-block:: Python + data = { + 'project_name': 'C001_BackToPast', + 'hierarchy': ['assets', 'characters', 'villian'], + 'task_type': 'lookdev', + 'task_name': 'Lookdev BG' + } + ''' + self.last_task = data + for module in self.modules: + module.start_timer_manager(data) + self.is_running = True + + def restart_timers(self): + if self.last_task is not None: + self.start_timers(self.last_task) + + def stop_timers(self): + if self.is_running is False: + return + self.widget_user_idle.bool_not_stopped = False + self.widget_user_idle.refresh_context() + for module in self.modules: + module.stop_timer_manager() + self.is_running = False + + def process_modules(self, modules): + """ Gives ability to connect with imported modules from TrayManager. + + :param modules: All imported modules from TrayManager + :type modules: dict + """ + self.s_handler = SignalHandler(self) + + if 'IdleManager' in modules: + if self.set_signal_times() is True: + self.register_to_idle_manager(modules['IdleManager']) + + def register_to_idle_manager(self, man_obj): + self.idle_man = man_obj + # Times when idle is between show widget and stop timers + show_to_stop_range = range( + 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 + ) + # 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 + ) + # 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.time_stop_timer, + self.s_handler.signal_stop_timers + ) + + 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'): + return + + if self.idle_man.idle_time > self.time_show_message: + value = self.time_stop_timer - self.idle_man.idle_time + else: + value = 1 + ( + self.time_stop_timer - + self.time_show_message - + self.idle_man.idle_time + ) + self.widget_user_idle.change_count_widget(value) + + def show_message(self): + if self.is_running is False: + 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/services/timers_manager/widget_user_idle.py b/pype/services/timers_manager/widget_user_idle.py new file mode 100644 index 0000000000..b65ffd40ba --- /dev/null +++ b/pype/services/timers_manager/widget_user_idle.py @@ -0,0 +1,155 @@ +from pypeapp import style, Logger +from Qt import QtCore, QtGui, QtWidgets + + +class WidgetUserIdle(QtWidgets.QWidget): + + SIZE_W = 300 + SIZE_H = 160 + + def __init__(self, parent): + + 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.setWindowFlags( + QtCore.Qt.WindowCloseButtonHint | + QtCore.Qt.WindowMinimizeButtonHint + ) + + self._translate = QtCore.QCoreApplication.translate + + 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) + + 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)) + self.setStyleSheet(style.load_stylesheet()) + + self.setLayout(self._main()) + self.refresh_context() + self.setWindowTitle('Pype - Stop timers') + + def _main(self): + self.main = QtWidgets.QVBoxLayout() + self.main.setObjectName('main') + + self.form = QtWidgets.QFormLayout() + self.form.setContentsMargins(10, 15, 10, 5) + self.form.setObjectName('form') + + msg_info = 'You didn\'t work for a long time.' + msg_question = 'Would you like to stop Timers?' + msg_stopped = ( + 'Your Timers were stopped. Do you want to start them again?' + ) + + self.lbl_info = QtWidgets.QLabel(msg_info) + self.lbl_info.setFont(self.font) + self.lbl_info.setTextFormat(QtCore.Qt.RichText) + self.lbl_info.setObjectName("lbl_info") + self.lbl_info.setWordWrap(True) + + self.lbl_question = QtWidgets.QLabel(msg_question) + self.lbl_question.setFont(self.font) + self.lbl_question.setTextFormat(QtCore.Qt.RichText) + self.lbl_question.setObjectName("lbl_question") + self.lbl_question.setWordWrap(True) + + self.lbl_stopped = QtWidgets.QLabel(msg_stopped) + self.lbl_stopped.setFont(self.font) + self.lbl_stopped.setTextFormat(QtCore.Qt.RichText) + self.lbl_stopped.setObjectName("lbl_stopped") + self.lbl_stopped.setWordWrap(True) + + self.lbl_rest_time = QtWidgets.QLabel("") + self.lbl_rest_time.setFont(self.font) + self.lbl_rest_time.setTextFormat(QtCore.Qt.RichText) + self.lbl_rest_time.setObjectName("lbl_rest_time") + self.lbl_rest_time.setWordWrap(True) + self.lbl_rest_time.setAlignment(QtCore.Qt.AlignCenter) + + self.form.addRow(self.lbl_info) + self.form.addRow(self.lbl_question) + self.form.addRow(self.lbl_stopped) + self.form.addRow(self.lbl_rest_time) + + self.group_btn = QtWidgets.QHBoxLayout() + self.group_btn.addStretch(1) + self.group_btn.setObjectName("group_btn") + + self.btn_stop = QtWidgets.QPushButton("Stop timer") + self.btn_stop.setToolTip('Stop\'s All timers') + self.btn_stop.clicked.connect(self.stop_timer) + + self.btn_continue = QtWidgets.QPushButton("Continue") + self.btn_continue.setToolTip('Timer won\'t stop') + self.btn_continue.clicked.connect(self.continue_timer) + + self.btn_close = QtWidgets.QPushButton("Close") + self.btn_close.setToolTip('Close window') + self.btn_close.clicked.connect(self.close_widget) + + self.btn_restart = QtWidgets.QPushButton("Start timers") + self.btn_restart.setToolTip('Timer will be started again') + self.btn_restart.clicked.connect(self.restart_timer) + + self.group_btn.addWidget(self.btn_continue) + self.group_btn.addWidget(self.btn_stop) + self.group_btn.addWidget(self.btn_restart) + self.group_btn.addWidget(self.btn_close) + + self.main.addLayout(self.form) + self.main.addLayout(self.group_btn) + + return self.main + + def refresh_context(self): + self.lbl_question.setVisible(self.bool_not_stopped) + self.lbl_rest_time.setVisible(self.bool_not_stopped) + self.lbl_stopped.setVisible(not self.bool_not_stopped) + + self.btn_continue.setVisible(self.bool_not_stopped) + self.btn_stop.setVisible(self.bool_not_stopped) + self.btn_restart.setVisible(not self.bool_not_stopped) + self.btn_close.setVisible(not self.bool_not_stopped) + + def change_count_widget(self, time): + str_time = str(time) + self.lbl_rest_time.setText(str_time) + + def stop_timer(self): + self.parent_widget.stop_timers() + self.close_widget() + + def restart_timer(self): + self.parent_widget.restart_timers() + self.close_widget() + + def continue_timer(self): + self.close_widget() + + def closeEvent(self, event): + event.ignore() + if self.bool_not_stopped is True: + self.continue_timer() + else: + self.close_widget() + + def close_widget(self): + self.bool_is_showed = False + self.bool_not_stopped = True + self.refresh_context() + self.hide() + + def showEvent(self, event): + self.bool_is_showed = True diff --git a/pype/standalonepublish/__init__.py b/pype/standalonepublish/__init__.py new file mode 100644 index 0000000000..c7be80f189 --- /dev/null +++ b/pype/standalonepublish/__init__.py @@ -0,0 +1,12 @@ +from .standalonepublish_module import StandAlonePublishModule +from .app import ( + show, + cli +) +__all__ = [ + "show", + "cli" +] + +def tray_init(tray_widget, main_widget): + return StandAlonePublishModule(main_widget, tray_widget) diff --git a/pype/standalonepublish/__main__.py b/pype/standalonepublish/__main__.py new file mode 100644 index 0000000000..d77bc585c5 --- /dev/null +++ b/pype/standalonepublish/__main__.py @@ -0,0 +1,5 @@ +from . import cli + +if __name__ == '__main__': + import sys + sys.exit(cli(sys.argv[1:])) diff --git a/pype/standalonepublish/app.py b/pype/standalonepublish/app.py new file mode 100644 index 0000000000..da5fbbba10 --- /dev/null +++ b/pype/standalonepublish/app.py @@ -0,0 +1,211 @@ +import os +import sys +import json +from subprocess import Popen +from pype import lib as pypelib +from avalon.vendor.Qt import QtWidgets, QtCore +from avalon import api, style, schema +from avalon.tools import lib as parentlib +from .widgets import * +# Move this to pype lib? +from avalon.tools.libraryloader.io_nonsingleton import DbConnector + +module = sys.modules[__name__] +module.window = None + +class Window(QtWidgets.QDialog): + """Main window of Standalone publisher. + + :param parent: Main widget that cares about all GUIs + :type parent: QtWidgets.QMainWindow + """ + _db = DbConnector() + _jobs = {} + valid_family = False + valid_components = False + initialized = False + WIDTH = 1100 + HEIGHT = 500 + + def __init__(self, parent=None): + super(Window, self).__init__(parent=parent) + self._db.install() + + self.setWindowTitle("Standalone Publish") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.setStyleSheet(style.load_stylesheet()) + + # Validators + self.valid_parent = False + + # assets widget + widget_assets = AssetWidget(self) + + # family widget + widget_family = FamilyWidget(self) + + # components widget + widget_components = ComponentsWidget(self) + + # Body + body = QtWidgets.QSplitter() + body.setContentsMargins(0, 0, 0, 0) + body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + body.setOrientation(QtCore.Qt.Horizontal) + body.addWidget(widget_assets) + body.addWidget(widget_family) + body.addWidget(widget_components) + body.setStretchFactor(body.indexOf(widget_assets), 2) + body.setStretchFactor(body.indexOf(widget_family), 3) + body.setStretchFactor(body.indexOf(widget_components), 5) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + + self.resize(self.WIDTH, self.HEIGHT) + + # signals + widget_assets.selection_changed.connect(self.on_asset_changed) + + self.widget_assets = widget_assets + self.widget_family = widget_family + self.widget_components = widget_components + + # on start + self.on_start() + + @property + def db(self): + ''' Returns DB object for MongoDB I/O + ''' + return self._db + + def on_start(self): + ''' Things must be done when initilized. + ''' + # Refresh asset input in Family widget + self.on_asset_changed() + self.widget_components.validation() + # Initializing shadow widget + self.shadow_widget = ShadowWidget(self) + self.shadow_widget.setVisible(False) + + def resizeEvent(self, event=None): + ''' Helps resize shadow widget + ''' + position_x = (self.frameGeometry().width()-self.shadow_widget.frameGeometry().width())/2 + position_y = (self.frameGeometry().height()-self.shadow_widget.frameGeometry().height())/2 + self.shadow_widget.move(position_x, position_y) + w = self.frameGeometry().width() + h = self.frameGeometry().height() + self.shadow_widget.resize(QtCore.QSize(w, h)) + if event: + super().resizeEvent(event) + + def get_avalon_parent(self, entity): + ''' Avalon DB entities helper - get all parents (exclude project). + ''' + parent_id = entity['data']['visualParent'] + parents = [] + if parent_id is not None: + parent = self.db.find_one({'_id': parent_id}) + parents.extend(self.get_avalon_parent(parent)) + parents.append(parent['name']) + return parents + + def on_asset_changed(self): + '''Callback on asset selection changed + + Updates the task view. + + ''' + selected = self.widget_assets.get_selected_assets() + if len(selected) == 1: + self.valid_parent = True + asset = self.db.find_one({"_id": selected[0], "type": "asset"}) + self.widget_family.change_asset(asset['name']) + else: + self.valid_parent = False + self.widget_family.change_asset(None) + self.widget_family.on_data_changed() + + def keyPressEvent(self, event): + ''' Handling Ctrl+V KeyPress event + Can handle: + - files/folders in clipboard (tested only on Windows OS) + - copied path of file/folder in clipboard ('c:/path/to/folder') + ''' + if event.key() == QtCore.Qt.Key_V and event.modifiers() == QtCore.Qt.ControlModifier: + clip = QtWidgets.QApplication.clipboard() + self.widget_components.process_mime_data(clip) + super().keyPressEvent(event) + + def working_start(self, msg=None): + ''' Shows shadowed foreground with message + :param msg: Message that will be displayed + (set to `Please wait...` if `None` entered) + :type msg: str + ''' + if msg is None: + msg = 'Please wait...' + self.shadow_widget.message = msg + self.shadow_widget.setVisible(True) + self.resizeEvent() + QtWidgets.QApplication.processEvents() + + def working_stop(self): + ''' Hides shadowed foreground + ''' + if self.shadow_widget.isVisible(): + self.shadow_widget.setVisible(False) + + def set_valid_family(self, valid): + ''' Sets `valid_family` attribute for validation + + .. note:: + if set to `False` publishing is not possible + ''' + self.valid_family = valid + # If widget_components not initialized yet + if hasattr(self, 'widget_components'): + self.widget_components.validation() + + def collect_data(self): + ''' Collecting necessary data for pyblish from child widgets + ''' + data = {} + data.update(self.widget_assets.collect_data()) + data.update(self.widget_family.collect_data()) + data.update(self.widget_components.collect_data()) + + return data + +def show(parent=None, debug=False): + try: + module.window.close() + del module.window + except (RuntimeError, AttributeError): + pass + + with parentlib.application(): + window = Window(parent) + window.show() + + module.window = window + + +def cli(args): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("project") + parser.add_argument("asset") + + args = parser.parse_args(args) + # project = args.project + # asset = args.asset + + show() diff --git a/pype/standalonepublish/publish.py b/pype/standalonepublish/publish.py new file mode 100644 index 0000000000..4442f0243c --- /dev/null +++ b/pype/standalonepublish/publish.py @@ -0,0 +1,154 @@ +import os +import sys +import json +import tempfile +import random +import string + +from avalon import io +from avalon import api as avalon +from avalon.tools import publish as av_publish + +import pype +from pypeapp import execute + +import pyblish.api + + +# Registers Global pyblish plugins +# pype.install() +# Registers Standalone pyblish plugins +PUBLISH_PATH = os.path.sep.join( + [pype.PLUGINS_DIR, 'standalonepublish', 'publish'] +) +pyblish.api.register_plugin_path(PUBLISH_PATH) + +# # Registers Standalone pyblish plugins +# PUBLISH_PATH = os.path.sep.join( +# [pype.PLUGINS_DIR, 'ftrack', 'publish'] +# ) +# pyblish.api.register_plugin_path(PUBLISH_PATH) + + +def set_context(project, asset, app): + ''' Sets context for pyblish (must be done before pyblish is launched) + :param project: Name of `Project` where instance should be published + :type project: str + :param asset: Name of `Asset` where instance should be published + :type asset: str + ''' + os.environ["AVALON_PROJECT"] = project + io.Session["AVALON_PROJECT"] = project + os.environ["AVALON_ASSET"] = asset + io.Session["AVALON_ASSET"] = asset + + io.install() + + av_project = io.find_one({'type': 'project'}) + av_asset = io.find_one({ + "type": 'asset', + "name": asset + }) + + parents = av_asset['data']['parents'] + hierarchy = '' + if parents and len(parents) > 0: + hierarchy = os.path.sep.join(parents) + + os.environ["AVALON_HIERARCHY"] = hierarchy + io.Session["AVALON_HIERARCHY"] = hierarchy + + os.environ["AVALON_PROJECTCODE"] = av_project['data'].get('code', '') + io.Session["AVALON_PROJECTCODE"] = av_project['data'].get('code', '') + + io.Session["current_dir"] = os.path.normpath(os.getcwd()) + + os.environ["AVALON_APP"] = app + io.Session["AVALON_APP"] = app + + io.uninstall() + + +def publish(data, gui=True): + # cli pyblish seems like better solution + return cli_publish(data, gui) + # # this uses avalon pyblish launch tool + # avalon_api_publish(data, gui) + + +def avalon_api_publish(data, gui=True): + ''' Launches Pyblish (GUI by default) + :param data: Should include data for pyblish and standalone collector + :type data: dict + :param gui: Pyblish will be launched in GUI mode if set to True + :type gui: bool + ''' + io.install() + + # Create hash name folder in temp + chars = "".join( [random.choice(string.ascii_letters) for i in range(15)] ) + staging_dir = tempfile.mkdtemp(chars) + + # create also json and fill with data + json_data_path = staging_dir + os.path.basename(staging_dir) + '.json' + with open(json_data_path, 'w') as outfile: + json.dump(data, outfile) + + args = [ + "-pp", os.pathsep.join(pyblish.api.registered_paths()) + ] + + os.environ["PYBLISH_HOSTS"] = "shell" + os.environ["SAPUBLISH_INPATH"] = json_data_path + + if gui: + av_publish.show() + else: + returncode = execute([ + sys.executable, "-u", "-m", "pyblish" + ] + args, env=os.environ) + + io.uninstall() + + +def cli_publish(data, gui=True): + io.install() + + # Create hash name folder in temp + chars = "".join( [random.choice(string.ascii_letters) for i in range(15)] ) + staging_dir = tempfile.mkdtemp(chars) + + # create json for return data + return_data_path = ( + staging_dir + os.path.basename(staging_dir) + 'return.json' + ) + # create also json and fill with data + json_data_path = staging_dir + os.path.basename(staging_dir) + '.json' + with open(json_data_path, 'w') as outfile: + json.dump(data, outfile) + + args = [ + "-pp", os.pathsep.join(pyblish.api.registered_paths()) + ] + + if gui: + args += ["gui"] + + os.environ["PYBLISH_HOSTS"] = "shell" + os.environ["SAPUBLISH_INPATH"] = json_data_path + os.environ["SAPUBLISH_OUTPATH"] = return_data_path + + returncode = execute([ + sys.executable, "-u", "-m", "pyblish" + ] + args, env=os.environ) + + result = {} + if os.path.exists(json_data_path): + with open(json_data_path, "r") as f: + result = json.load(f) + + io.uninstall() + # TODO: check if was pyblish successful + # if successful return True + print('Check result here') + return False diff --git a/pype/standalonepublish/resources/__init__.py b/pype/standalonepublish/resources/__init__.py new file mode 100644 index 0000000000..ce329ee585 --- /dev/null +++ b/pype/standalonepublish/resources/__init__.py @@ -0,0 +1,14 @@ +import os + + +resource_path = os.path.dirname(__file__) + + +def get_resource(*args): + """ Serves to simple resources access + + :param \*args: should contain *subfolder* names and *filename* of + resource from resources folder + :type \*args: list + """ + return os.path.normpath(os.path.join(resource_path, *args)) diff --git a/pype/standalonepublish/resources/edit.svg b/pype/standalonepublish/resources/edit.svg new file mode 100644 index 0000000000..26451b4a9d --- /dev/null +++ b/pype/standalonepublish/resources/edit.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/pype/standalonepublish/resources/file.png b/pype/standalonepublish/resources/file.png new file mode 100644 index 0000000000..7a830ad133 Binary files /dev/null and b/pype/standalonepublish/resources/file.png differ diff --git a/pype/standalonepublish/resources/files.png b/pype/standalonepublish/resources/files.png new file mode 100644 index 0000000000..f6f89fe149 Binary files /dev/null and b/pype/standalonepublish/resources/files.png differ diff --git a/pype/standalonepublish/resources/houdini.png b/pype/standalonepublish/resources/houdini.png new file mode 100644 index 0000000000..11cfa46dce Binary files /dev/null and b/pype/standalonepublish/resources/houdini.png differ diff --git a/pype/standalonepublish/resources/image_file.png b/pype/standalonepublish/resources/image_file.png new file mode 100644 index 0000000000..adea862e5b Binary files /dev/null and b/pype/standalonepublish/resources/image_file.png differ diff --git a/pype/standalonepublish/resources/image_files.png b/pype/standalonepublish/resources/image_files.png new file mode 100644 index 0000000000..2db779ab30 Binary files /dev/null and b/pype/standalonepublish/resources/image_files.png differ diff --git a/pype/standalonepublish/resources/information.svg b/pype/standalonepublish/resources/information.svg new file mode 100644 index 0000000000..e0f73a7eb1 --- /dev/null +++ b/pype/standalonepublish/resources/information.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/pype/standalonepublish/resources/maya.png b/pype/standalonepublish/resources/maya.png new file mode 100644 index 0000000000..e84a6a3742 Binary files /dev/null and b/pype/standalonepublish/resources/maya.png differ diff --git a/pype/standalonepublish/resources/menu.svg b/pype/standalonepublish/resources/menu.svg new file mode 100644 index 0000000000..ac1e728011 --- /dev/null +++ b/pype/standalonepublish/resources/menu.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/pype/standalonepublish/resources/nuke.png b/pype/standalonepublish/resources/nuke.png new file mode 100644 index 0000000000..4234454096 Binary files /dev/null and b/pype/standalonepublish/resources/nuke.png differ diff --git a/pype/standalonepublish/resources/premiere.png b/pype/standalonepublish/resources/premiere.png new file mode 100644 index 0000000000..eb5b3d1ba2 Binary files /dev/null and b/pype/standalonepublish/resources/premiere.png differ diff --git a/pype/standalonepublish/resources/preview.svg b/pype/standalonepublish/resources/preview.svg new file mode 100644 index 0000000000..4a9810c1d5 --- /dev/null +++ b/pype/standalonepublish/resources/preview.svg @@ -0,0 +1,19 @@ + + + + + PREVIEW + + diff --git a/pype/standalonepublish/resources/thumbnail.svg b/pype/standalonepublish/resources/thumbnail.svg new file mode 100644 index 0000000000..dbc228f8c8 --- /dev/null +++ b/pype/standalonepublish/resources/thumbnail.svg @@ -0,0 +1,19 @@ + + + + + THUMBNAIL + + diff --git a/pype/standalonepublish/resources/trash.svg b/pype/standalonepublish/resources/trash.svg new file mode 100644 index 0000000000..07905024c0 --- /dev/null +++ b/pype/standalonepublish/resources/trash.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/pype/standalonepublish/resources/video_file.png b/pype/standalonepublish/resources/video_file.png new file mode 100644 index 0000000000..346277e40f Binary files /dev/null and b/pype/standalonepublish/resources/video_file.png differ diff --git a/pype/standalonepublish/standalonepublish_module.py b/pype/standalonepublish/standalonepublish_module.py new file mode 100644 index 0000000000..703f457138 --- /dev/null +++ b/pype/standalonepublish/standalonepublish_module.py @@ -0,0 +1,18 @@ +from .app import show +from .widgets import QtWidgets + + +class StandAlonePublishModule: + def __init__(self, main_parent=None, parent=None): + self.main_parent = main_parent + self.parent_widget = parent + + def tray_menu(self, parent_menu): + self.run_action = QtWidgets.QAction( + "Publish", parent_menu + ) + self.run_action.triggered.connect(self.show) + parent_menu.addAction(self.run_action) + + def show(self): + show(self.main_parent, False) diff --git a/pype/standalonepublish/widgets/__init__.py b/pype/standalonepublish/widgets/__init__.py new file mode 100644 index 0000000000..4c6a0e85a5 --- /dev/null +++ b/pype/standalonepublish/widgets/__init__.py @@ -0,0 +1,33 @@ +from avalon.vendor.Qt import * +from avalon.vendor import qtawesome as awesome +from avalon import style + +HelpRole = QtCore.Qt.UserRole + 2 +FamilyRole = QtCore.Qt.UserRole + 3 +ExistsRole = QtCore.Qt.UserRole + 4 +PluginRole = QtCore.Qt.UserRole + 5 + +from ..resources import get_resource +from .button_from_svgs import SvgResizable, SvgButton + +from .model_node import Node +from .model_tree import TreeModel +from .model_asset import AssetModel +from .model_filter_proxy_exact_match import ExactMatchesFilterProxyModel +from .model_filter_proxy_recursive_sort import RecursiveSortFilterProxyModel +from .model_tasks_template import TasksTemplateModel +from .model_tree_view_deselectable import DeselectableTreeView + +from .widget_asset_view import AssetView +from .widget_asset import AssetWidget + +from .widget_family_desc import FamilyDescriptionWidget +from .widget_family import FamilyWidget + +from .widget_drop_empty import DropEmpty +from .widget_component_item import ComponentItem +from .widget_components_list import ComponentsList +from .widget_drop_frame import DropDataFrame +from .widget_components import ComponentsWidget + +from .widget_shadow import ShadowWidget diff --git a/pype/standalonepublish/widgets/button_from_svgs.py b/pype/standalonepublish/widgets/button_from_svgs.py new file mode 100644 index 0000000000..4255c5f29b --- /dev/null +++ b/pype/standalonepublish/widgets/button_from_svgs.py @@ -0,0 +1,113 @@ +from xml.dom import minidom + +from . import QtGui, QtCore, QtWidgets +from PyQt5 import QtSvg, QtXml + + +class SvgResizable(QtSvg.QSvgWidget): + clicked = QtCore.Signal() + + def __init__(self, filepath, width=None, height=None, fill=None): + super().__init__() + self.xmldoc = minidom.parse(filepath) + itemlist = self.xmldoc.getElementsByTagName('svg') + for element in itemlist: + if fill: + element.setAttribute('fill', str(fill)) + # TODO auto scale if only one is set + if width is not None and height is not None: + self.setMaximumSize(width, height) + self.setMinimumSize(width, height) + xml_string = self.xmldoc.toxml() + svg_bytes = bytearray(xml_string, encoding='utf-8') + + self.load(svg_bytes) + + def change_color(self, color): + element = self.xmldoc.getElementsByTagName('svg')[0] + element.setAttribute('fill', str(color)) + xml_string = self.xmldoc.toxml() + svg_bytes = bytearray(xml_string, encoding='utf-8') + self.load(svg_bytes) + + def mousePressEvent(self, event): + self.clicked.emit() + + +class SvgButton(QtWidgets.QFrame): + clicked = QtCore.Signal() + def __init__( + self, filepath, width=None, height=None, fills=[], + parent=None, checkable=True + ): + super().__init__(parent) + self.checkable = checkable + self.checked = False + + xmldoc = minidom.parse(filepath) + element = xmldoc.getElementsByTagName('svg')[0] + c_actual = '#777777' + if element.hasAttribute('fill'): + c_actual = element.getAttribute('fill') + self.store_fills(fills, c_actual) + + self.installEventFilter(self) + self.svg_widget = SvgResizable(filepath, width, height, self.c_normal) + xmldoc = minidom.parse(filepath) + + layout = QtWidgets.QHBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.svg_widget) + + if width is not None and height is not None: + self.setMaximumSize(width, height) + self.setMinimumSize(width, height) + + def store_fills(self, fills, actual): + if len(fills) == 0: + fills = [actual, actual, actual, actual] + elif len(fills) == 1: + fills = [fills[0], fills[0], fills[0], fills[0]] + elif len(fills) == 2: + fills = [fills[0], fills[1], fills[1], fills[1]] + elif len(fills) == 3: + fills = [fills[0], fills[1], fills[2], fills[2]] + self.c_normal = fills[0] + self.c_hover = fills[1] + self.c_active = fills[2] + self.c_active_hover = fills[3] + + def eventFilter(self, object, event): + if event.type() == QtCore.QEvent.Enter: + self.hoverEnterEvent(event) + return True + elif event.type() == QtCore.QEvent.Leave: + self.hoverLeaveEvent(event) + return True + elif event.type() == QtCore.QEvent.MouseButtonRelease: + self.mousePressEvent(event) + return False + + def change_checked(self, hover=True): + if self.checkable: + self.checked = not self.checked + if hover: + self.hoverEnterEvent() + else: + self.hoverLeaveEvent() + + def hoverEnterEvent(self, event=None): + color = self.c_hover + if self.checked: + color = self.c_active_hover + self.svg_widget.change_color(color) + + def hoverLeaveEvent(self, event=None): + color = self.c_normal + if self.checked: + color = self.c_active + self.svg_widget.change_color(color) + + def mousePressEvent(self, event=None): + self.clicked.emit() diff --git a/pype/standalonepublish/widgets/model_asset.py b/pype/standalonepublish/widgets/model_asset.py new file mode 100644 index 0000000000..fdf844342e --- /dev/null +++ b/pype/standalonepublish/widgets/model_asset.py @@ -0,0 +1,158 @@ +import logging +from . import QtCore, QtGui +from . import TreeModel, Node +from . import style, awesome + + +log = logging.getLogger(__name__) + + +def _iter_model_rows(model, + column, + include_root=False): + """Iterate over all row indices in a model""" + indices = [QtCore.QModelIndex()] # start iteration at root + + for index in indices: + + # Add children to the iterations + child_rows = model.rowCount(index) + for child_row in range(child_rows): + child_index = model.index(child_row, column, index) + indices.append(child_index) + + if not include_root and not index.isValid(): + continue + + yield index + + +class AssetModel(TreeModel): + """A model listing assets in the silo in the active project. + + The assets are displayed in a treeview, they are visually parented by + a `visualParent` field in the database containing an `_id` to a parent + asset. + + """ + + COLUMNS = ["label"] + Name = 0 + Deprecated = 2 + ObjectId = 3 + + DocumentRole = QtCore.Qt.UserRole + 2 + ObjectIdRole = QtCore.Qt.UserRole + 3 + + def __init__(self, parent): + super(AssetModel, self).__init__(parent=parent) + self.parent_widget = parent + self.refresh() + + @property + def db(self): + return self.parent_widget.db + + def _add_hierarchy(self, parent=None): + + # Find the assets under the parent + find_data = { + "type": "asset" + } + if parent is None: + find_data['$or'] = [ + {'data.visualParent': {'$exists': False}}, + {'data.visualParent': None} + ] + else: + find_data["data.visualParent"] = parent['_id'] + + assets = self.db.find(find_data).sort('name', 1) + for asset in assets: + # get label from data, otherwise use name + data = asset.get("data", {}) + label = data.get("label", asset['name']) + tags = data.get("tags", []) + + # store for the asset for optimization + deprecated = "deprecated" in tags + + node = Node({ + "_id": asset['_id'], + "name": asset["name"], + "label": label, + "type": asset['type'], + "tags": ", ".join(tags), + "deprecated": deprecated, + "_document": asset + }) + self.add_child(node, parent=parent) + + # Add asset's children recursively + self._add_hierarchy(node) + + def refresh(self): + """Refresh the data for the model.""" + + self.clear() + if ( + self.db.active_project() is None or + self.db.active_project() == '' + ): + return + self.beginResetModel() + self._add_hierarchy(parent=None) + self.endResetModel() + + def flags(self, index): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + def data(self, index, role): + + if not index.isValid(): + return + + node = index.internalPointer() + if role == QtCore.Qt.DecorationRole: # icon + + column = index.column() + if column == self.Name: + + # Allow a custom icon and custom icon color to be defined + data = node["_document"]["data"] + icon = data.get("icon", None) + color = data.get("color", style.colors.default) + + if icon is None: + # Use default icons if no custom one is specified. + # If it has children show a full folder, otherwise + # show an open folder + has_children = self.rowCount(index) > 0 + icon = "folder" if has_children else "folder-o" + + # Make the color darker when the asset is deprecated + if node.get("deprecated", False): + color = QtGui.QColor(color).darker(250) + + try: + key = "fa.{0}".format(icon) # font-awesome key + icon = awesome.icon(key, color=color) + return icon + except Exception as exception: + # Log an error message instead of erroring out completely + # when the icon couldn't be created (e.g. invalid name) + log.error(exception) + + return + + if role == QtCore.Qt.ForegroundRole: # font color + if "deprecated" in node.get("tags", []): + return QtGui.QColor(style.colors.light).darker(250) + + if role == self.ObjectIdRole: + return node.get("_id", None) + + if role == self.DocumentRole: + return node.get("_document", None) + + return super(AssetModel, self).data(index, role) diff --git a/pype/standalonepublish/widgets/model_filter_proxy_exact_match.py b/pype/standalonepublish/widgets/model_filter_proxy_exact_match.py new file mode 100644 index 0000000000..862e4071db --- /dev/null +++ b/pype/standalonepublish/widgets/model_filter_proxy_exact_match.py @@ -0,0 +1,28 @@ +from . import QtCore + + +class ExactMatchesFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filter model to where key column's value is in the filtered tags""" + + def __init__(self, *args, **kwargs): + super(ExactMatchesFilterProxyModel, self).__init__(*args, **kwargs) + self._filters = set() + + def setFilters(self, filters): + self._filters = set(filters) + + def filterAcceptsRow(self, source_row, source_parent): + + # No filter + if not self._filters: + return True + + else: + model = self.sourceModel() + column = self.filterKeyColumn() + idx = model.index(source_row, column, source_parent) + data = model.data(idx, self.filterRole()) + if data in self._filters: + return True + else: + return False diff --git a/pype/standalonepublish/widgets/model_filter_proxy_recursive_sort.py b/pype/standalonepublish/widgets/model_filter_proxy_recursive_sort.py new file mode 100644 index 0000000000..04ee88229f --- /dev/null +++ b/pype/standalonepublish/widgets/model_filter_proxy_recursive_sort.py @@ -0,0 +1,30 @@ +from . import QtCore + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filters to the regex if any of the children matches allow parent""" + def filterAcceptsRow(self, row, parent): + + regex = self.filterRegExp() + if not regex.isEmpty(): + pattern = regex.pattern() + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if source_index.isValid(): + + # Check current index itself + key = model.data(source_index, self.filterRole()) + if re.search(pattern, key, re.IGNORECASE): + return True + + # Check children + rows = model.rowCount(source_index) + for i in range(rows): + if self.filterAcceptsRow(i, source_index): + return True + + # Otherwise filter it + return False + + return super(RecursiveSortFilterProxyModel, + self).filterAcceptsRow(row, parent) diff --git a/pype/standalonepublish/widgets/model_node.py b/pype/standalonepublish/widgets/model_node.py new file mode 100644 index 0000000000..e8326d5b90 --- /dev/null +++ b/pype/standalonepublish/widgets/model_node.py @@ -0,0 +1,56 @@ +import logging + + +log = logging.getLogger(__name__) + + +class Node(dict): + """A node that can be represented in a tree view. + + The node can store data just like a dictionary. + + >>> data = {"name": "John", "score": 10} + >>> node = Node(data) + >>> assert node["name"] == "John" + + """ + + def __init__(self, data=None): + super(Node, self).__init__() + + self._children = list() + self._parent = None + + if data is not None: + assert isinstance(data, dict) + self.update(data) + + def childCount(self): + return len(self._children) + + def child(self, row): + + if row >= len(self._children): + log.warning("Invalid row as child: {0}".format(row)) + return + + return self._children[row] + + def children(self): + return self._children + + def parent(self): + return self._parent + + def row(self): + """ + Returns: + int: Index of this node under parent""" + if self._parent is not None: + siblings = self.parent().children() + return siblings.index(self) + + def add_child(self, child): + """Add a child to this node""" + child._parent = self + self._children.append(child) diff --git a/pype/standalonepublish/widgets/model_tasks_template.py b/pype/standalonepublish/widgets/model_tasks_template.py new file mode 100644 index 0000000000..bd1984029c --- /dev/null +++ b/pype/standalonepublish/widgets/model_tasks_template.py @@ -0,0 +1,63 @@ +from . import QtCore, TreeModel +from . import Node +from . import awesome, style + + +class TasksTemplateModel(TreeModel): + """A model listing the tasks combined for a list of assets""" + + COLUMNS = ["Tasks"] + + def __init__(self, selectable=True): + super(TasksTemplateModel, self).__init__() + self.selectable = selectable + self.icon = awesome.icon( + 'fa.calendar-check-o', + color=style.colors.default + ) + + def set_tasks(self, tasks): + """Set assets to track by their database id + + Arguments: + asset_ids (list): List of asset ids. + + """ + + self.clear() + + # let cleared task view if no tasks are available + if len(tasks) == 0: + return + + self.beginResetModel() + + for task in tasks: + node = Node({ + "Tasks": task, + "icon": self.icon + }) + self.add_child(node) + + self.endResetModel() + + def flags(self, index): + if self.selectable is False: + return QtCore.Qt.ItemIsEnabled + else: + return ( + QtCore.Qt.ItemIsEnabled | + QtCore.Qt.ItemIsSelectable + ) + + def data(self, index, role): + + if not index.isValid(): + return + + # Add icon to the first column + if role == QtCore.Qt.DecorationRole: + if index.column() == 0: + return index.internalPointer()['icon'] + + return super(TasksTemplateModel, self).data(index, role) diff --git a/pype/standalonepublish/widgets/model_tree.py b/pype/standalonepublish/widgets/model_tree.py new file mode 100644 index 0000000000..e4f1aa5eb7 --- /dev/null +++ b/pype/standalonepublish/widgets/model_tree.py @@ -0,0 +1,122 @@ +from . import QtCore +from . import Node + + +class TreeModel(QtCore.QAbstractItemModel): + + COLUMNS = list() + NodeRole = QtCore.Qt.UserRole + 1 + + def __init__(self, parent=None): + super(TreeModel, self).__init__(parent) + self._root_node = Node() + + def rowCount(self, parent): + if parent.isValid(): + node = parent.internalPointer() + else: + node = self._root_node + + return node.childCount() + + def columnCount(self, parent): + return len(self.COLUMNS) + + def data(self, index, role): + + if not index.isValid(): + return None + + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + + node = index.internalPointer() + column = index.column() + + key = self.COLUMNS[column] + return node.get(key, None) + + if role == self.NodeRole: + return index.internalPointer() + + def setData(self, index, value, role=QtCore.Qt.EditRole): + """Change the data on the nodes. + + Returns: + bool: Whether the edit was successful + """ + + if index.isValid(): + if role == QtCore.Qt.EditRole: + + node = index.internalPointer() + column = index.column() + key = self.COLUMNS[column] + node[key] = value + + # passing `list()` for PyQt5 (see PYSIDE-462) + self.dataChanged.emit(index, index, list()) + + # must return true if successful + return True + + return False + + def setColumns(self, keys): + assert isinstance(keys, (list, tuple)) + self.COLUMNS = keys + + def headerData(self, section, orientation, role): + + if role == QtCore.Qt.DisplayRole: + if section < len(self.COLUMNS): + return self.COLUMNS[section] + + super(TreeModel, self).headerData(section, orientation, role) + + def flags(self, index): + return ( + QtCore.Qt.ItemIsEnabled | + QtCore.Qt.ItemIsSelectable + ) + + def parent(self, index): + + node = index.internalPointer() + parent_node = node.parent() + + # If it has no parents we return invalid + if parent_node == self._root_node or not parent_node: + return QtCore.QModelIndex() + + return self.createIndex(parent_node.row(), 0, parent_node) + + def index(self, row, column, parent): + """Return index for row/column under parent""" + + if not parent.isValid(): + parentNode = self._root_node + else: + parentNode = parent.internalPointer() + + childItem = parentNode.child(row) + if childItem: + return self.createIndex(row, column, childItem) + else: + return QtCore.QModelIndex() + + def add_child(self, node, parent=None): + if parent is None: + parent = self._root_node + + parent.add_child(node) + + def column_name(self, column): + """Return column key by index""" + + if column < len(self.COLUMNS): + return self.COLUMNS[column] + + def clear(self): + self.beginResetModel() + self._root_node = Node() + self.endResetModel() diff --git a/pype/standalonepublish/widgets/model_tree_view_deselectable.py b/pype/standalonepublish/widgets/model_tree_view_deselectable.py new file mode 100644 index 0000000000..78bec44d36 --- /dev/null +++ b/pype/standalonepublish/widgets/model_tree_view_deselectable.py @@ -0,0 +1,16 @@ +from . import QtWidgets, QtCore + + +class DeselectableTreeView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + + def mousePressEvent(self, event): + + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + + QtWidgets.QTreeView.mousePressEvent(self, event) diff --git a/pype/standalonepublish/widgets/widget_asset.py b/pype/standalonepublish/widgets/widget_asset.py new file mode 100644 index 0000000000..54b7f7db44 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_asset.py @@ -0,0 +1,314 @@ +import contextlib +from . import QtWidgets, QtCore +from . import RecursiveSortFilterProxyModel, AssetModel, AssetView +from . import awesome, style +from . import TasksTemplateModel, DeselectableTreeView + + +@contextlib.contextmanager +def preserve_expanded_rows(tree_view, + column=0, + role=QtCore.Qt.DisplayRole): + """Preserves expanded row in QTreeView by column's data role. + + This function is created to maintain the expand vs collapse status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + Arguments: + tree_view (QWidgets.QTreeView): the tree view which is + nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + + model = tree_view.model() + + expanded = set() + + for index in _iter_model_rows(model, + column=column, + include_root=False): + if tree_view.isExpanded(index): + value = index.data(role) + expanded.add(value) + + try: + yield + finally: + if not expanded: + return + + for index in _iter_model_rows(model, + column=column, + include_root=False): + value = index.data(role) + state = value in expanded + if state: + tree_view.expand(index) + else: + tree_view.collapse(index) + + +@contextlib.contextmanager +def preserve_selection(tree_view, + column=0, + role=QtCore.Qt.DisplayRole, + current_index=True): + """Preserves row selection in QTreeView by column's data role. + + This function is created to maintain the selection status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + tree_view (QWidgets.QTreeView): the tree view nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + + model = tree_view.model() + selection_model = tree_view.selectionModel() + flags = selection_model.Select | selection_model.Rows + + if current_index: + current_index_value = tree_view.currentIndex().data(role) + else: + current_index_value = None + + selected_rows = selection_model.selectedRows() + if not selected_rows: + yield + return + + selected = set(row.data(role) for row in selected_rows) + try: + yield + finally: + if not selected: + return + + # Go through all indices, select the ones with similar data + for index in _iter_model_rows(model, + column=column, + include_root=False): + + value = index.data(role) + state = value in selected + if state: + tree_view.scrollTo(index) # Ensure item is visible + selection_model.select(index, flags) + + if current_index_value and value == current_index_value: + tree_view.setCurrentIndex(index) + + +class AssetWidget(QtWidgets.QWidget): + """A Widget to display a tree of assets with filter + + To list the assets of the active project: + >>> # widget = AssetWidget() + >>> # widget.refresh() + >>> # widget.show() + + """ + + assets_refreshed = QtCore.Signal() # on model refresh + selection_changed = QtCore.Signal() # on view selection change + current_changed = QtCore.Signal() # on view current index change + + def __init__(self, parent): + super(AssetWidget, self).__init__(parent=parent) + self.setContentsMargins(0, 0, 0, 0) + + self.parent_widget = parent + + layout = QtWidgets.QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # Project + self.combo_projects = QtWidgets.QComboBox() + self._set_projects() + self.combo_projects.currentTextChanged.connect(self.on_project_change) + # Tree View + model = AssetModel(self) + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(model) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + view = AssetView() + view.setModel(proxy) + + # Header + header = QtWidgets.QHBoxLayout() + + icon = awesome.icon("fa.refresh", color=style.colors.light) + refresh = QtWidgets.QPushButton(icon, "") + refresh.setToolTip("Refresh items") + + filter = QtWidgets.QLineEdit() + filter.textChanged.connect(proxy.setFilterFixedString) + filter.setPlaceholderText("Filter assets..") + + header.addWidget(filter) + header.addWidget(refresh) + + # Layout + layout.addWidget(self.combo_projects) + layout.addLayout(header) + layout.addWidget(view) + + # tasks + task_view = DeselectableTreeView() + task_view.setIndentation(0) + task_view.setHeaderHidden(True) + task_view.setVisible(False) + + task_model = TasksTemplateModel() + task_view.setModel(task_model) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(4) + main_layout.addLayout(layout, 80) + main_layout.addWidget(task_view, 20) + + # Signals/Slots + selection = view.selectionModel() + selection.selectionChanged.connect(self.selection_changed) + selection.currentChanged.connect(self.current_changed) + refresh.clicked.connect(self.refresh) + + self.selection_changed.connect(self._refresh_tasks) + + self.task_view = task_view + self.task_model = task_model + self.refreshButton = refresh + self.model = model + self.proxy = proxy + self.view = view + + @property + def db(self): + return self.parent_widget.db + + def collect_data(self): + project = self.db.find_one({'type': 'project'}) + asset = self.db.find_one({'_id': self.get_active_asset()}) + + try: + index = self.task_view.selectedIndexes()[0] + task = self.task_model.itemData(index)[0] + except Exception: + task = None + data = { + 'project': project['name'], + 'asset': asset['name'], + 'parents': self.get_parents(asset), + 'task': task + } + return data + + def get_parents(self, entity): + output = [] + if entity.get('data', {}).get('visualParent', None) is None: + return output + parent = self.db.find_one({'_id': entity['data']['visualParent']}) + output.append(parent['name']) + output.extend(self.get_parents(parent)) + return output + + def _set_projects(self): + projects = list() + for project in self.db.projects(): + projects.append(project['name']) + + self.combo_projects.clear() + if len(projects) > 0: + self.combo_projects.addItems(projects) + self.db.activate_project(projects[0]) + + def on_project_change(self): + projects = list() + for project in self.db.projects(): + projects.append(project['name']) + project_name = self.combo_projects.currentText() + if project_name in projects: + self.db.activate_project(project_name) + self.refresh() + + def _refresh_model(self): + self.model.refresh() + self.assets_refreshed.emit() + + def refresh(self): + self._refresh_model() + + def _refresh_tasks(self): + tasks = [] + selected = self.get_selected_assets() + if len(selected) == 1: + asset = self.db.find_one({ + "_id": selected[0], "type": "asset" + }) + if asset: + tasks = asset.get('data', {}).get('tasks', []) + self.task_model.set_tasks(tasks) + self.task_view.setVisible(len(tasks)>0) + + def get_active_asset(self): + """Return the asset id the current asset.""" + current = self.view.currentIndex() + return current.data(self.model.ObjectIdRole) + + def get_active_index(self): + return self.view.currentIndex() + + def get_selected_assets(self): + """Return the assets' ids that are selected.""" + selection = self.view.selectionModel() + rows = selection.selectedRows() + return [row.data(self.model.ObjectIdRole) for row in rows] + + def select_assets(self, assets, expand=True): + """Select assets by name. + + Args: + assets (list): List of asset names + expand (bool): Whether to also expand to the asset in the view + + Returns: + None + + """ + # TODO: Instead of individual selection optimize for many assets + + assert isinstance(assets, + (tuple, list)), "Assets must be list or tuple" + + # Clear selection + selection_model = self.view.selectionModel() + selection_model.clearSelection() + + # Select + mode = selection_model.Select | selection_model.Rows + for index in _iter_model_rows(self.proxy, + column=0, + include_root=False): + data = index.data(self.model.NodeRole) + name = data['name'] + if name in assets: + selection_model.select(index, mode) + + if expand: + self.view.expand(index) + + # Set the currently active index + self.view.setCurrentIndex(index) diff --git a/pype/standalonepublish/widgets/widget_asset_view.py b/pype/standalonepublish/widgets/widget_asset_view.py new file mode 100644 index 0000000000..27bf374599 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_asset_view.py @@ -0,0 +1,16 @@ +from . import QtCore +from . import DeselectableTreeView + + +class AssetView(DeselectableTreeView): + """Item view. + + This implements a context menu. + + """ + + def __init__(self): + super(AssetView, self).__init__() + self.setIndentation(15) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setHeaderHidden(True) diff --git a/pype/standalonepublish/widgets/widget_component_item.py b/pype/standalonepublish/widgets/widget_component_item.py new file mode 100644 index 0000000000..a58a292ec5 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_component_item.py @@ -0,0 +1,311 @@ +import os +from . import QtCore, QtGui, QtWidgets +from . import SvgButton +from . import get_resource +from avalon import style + + +class ComponentItem(QtWidgets.QFrame): + C_NORMAL = '#777777' + C_HOVER = '#ffffff' + C_ACTIVE = '#4BB543' + C_ACTIVE_HOVER = '#4BF543' + + signal_remove = QtCore.Signal(object) + signal_thumbnail = QtCore.Signal(object) + signal_preview = QtCore.Signal(object) + signal_repre_change = QtCore.Signal(object, object) + + def __init__(self, parent, main_parent): + super().__init__() + self.has_valid_repre = True + self.actions = [] + self.resize(290, 70) + self.setMinimumSize(QtCore.QSize(0, 70)) + self.parent_list = parent + self.parent_widget = main_parent + # Font + font = QtGui.QFont() + font.setFamily("DejaVu Sans Condensed") + font.setPointSize(9) + font.setBold(True) + font.setWeight(50) + font.setKerning(True) + + # Main widgets + frame = QtWidgets.QFrame(self) + frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + frame.setFrameShadow(QtWidgets.QFrame.Raised) + + layout_main = QtWidgets.QHBoxLayout(frame) + layout_main.setSpacing(2) + layout_main.setContentsMargins(2, 2, 2, 2) + + # Image + Info + frame_image_info = QtWidgets.QFrame(frame) + + # Layout image info + layout = QtWidgets.QVBoxLayout(frame_image_info) + layout.setSpacing(2) + layout.setContentsMargins(2, 2, 2, 2) + + self.icon = QtWidgets.QLabel(frame) + self.icon.setMinimumSize(QtCore.QSize(22, 22)) + self.icon.setMaximumSize(QtCore.QSize(22, 22)) + self.icon.setText("") + self.icon.setScaledContents(True) + + self.btn_action_menu = SvgButton( + get_resource('menu.svg'), 22, 22, + [self.C_NORMAL, self.C_HOVER], + frame_image_info, False + ) + + self.action_menu = QtWidgets.QMenu() + + expanding_sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + expanding_sizePolicy.setHorizontalStretch(0) + expanding_sizePolicy.setVerticalStretch(0) + + layout.addWidget(self.icon, alignment=QtCore.Qt.AlignCenter) + layout.addWidget(self.btn_action_menu, alignment=QtCore.Qt.AlignCenter) + + layout_main.addWidget(frame_image_info) + + # Name + representation + self.name = QtWidgets.QLabel(frame) + self.file_info = QtWidgets.QLabel(frame) + self.ext = QtWidgets.QLabel(frame) + + self.name.setFont(font) + self.file_info.setFont(font) + self.ext.setFont(font) + + self.file_info.setStyleSheet('padding-left:3px;') + + expanding_sizePolicy.setHeightForWidth(self.name.sizePolicy().hasHeightForWidth()) + + frame_name_repre = QtWidgets.QFrame(frame) + + self.file_info.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + self.ext.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + self.name.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + + layout = QtWidgets.QHBoxLayout(frame_name_repre) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.name, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(self.file_info, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(self.ext, alignment=QtCore.Qt.AlignRight) + + frame_name_repre.setSizePolicy( + QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.MinimumExpanding + ) + + # Repre + icons + frame_repre_icons = QtWidgets.QFrame(frame) + + frame_repre = QtWidgets.QFrame(frame_repre_icons) + + label_repre = QtWidgets.QLabel() + label_repre.setText('Representation:') + + self.input_repre = QtWidgets.QLineEdit() + self.input_repre.setMaximumWidth(50) + + layout = QtWidgets.QHBoxLayout(frame_repre) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + layout.addWidget(label_repre, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(self.input_repre, alignment=QtCore.Qt.AlignLeft) + + frame_icons = QtWidgets.QFrame(frame_repre_icons) + + self.preview = SvgButton( + get_resource('preview.svg'), 64, 18, + [self.C_NORMAL, self.C_HOVER, self.C_ACTIVE, self.C_ACTIVE_HOVER], + frame_icons + ) + + self.thumbnail = SvgButton( + get_resource('thumbnail.svg'), 84, 18, + [self.C_NORMAL, self.C_HOVER, self.C_ACTIVE, self.C_ACTIVE_HOVER], + frame_icons + ) + + layout = QtWidgets.QHBoxLayout(frame_icons) + layout.setSpacing(6) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.thumbnail) + layout.addWidget(self.preview) + + layout = QtWidgets.QHBoxLayout(frame_repre_icons) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + layout.addWidget(frame_repre, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(frame_icons, alignment=QtCore.Qt.AlignRight) + + frame_middle = QtWidgets.QFrame(frame) + + layout = QtWidgets.QVBoxLayout(frame_middle) + layout.setSpacing(0) + layout.setContentsMargins(4, 0, 4, 0) + layout.addWidget(frame_name_repre) + layout.addWidget(frame_repre_icons) + + layout.setStretchFactor(frame_name_repre, 1) + layout.setStretchFactor(frame_repre_icons, 1) + + layout_main.addWidget(frame_middle) + + self.remove = SvgButton( + get_resource('trash.svg'), 22, 22, + [self.C_NORMAL, self.C_HOVER], + frame, False + ) + + layout_main.addWidget(self.remove) + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(2, 2, 2, 2) + layout.addWidget(frame) + + self.preview.setToolTip('Mark component as Preview') + self.thumbnail.setToolTip('Component will be selected as thumbnail') + + # self.frame.setStyleSheet("border: 1px solid black;") + + def set_context(self, data): + self.btn_action_menu.setVisible(False) + self.in_data = data + self.remove.clicked.connect(self._remove) + self.thumbnail.clicked.connect(self._thumbnail_clicked) + self.preview.clicked.connect(self._preview_clicked) + self.input_repre.textChanged.connect(self._handle_duplicate_repre) + name = data['name'] + representation = data['representation'] + ext = data['ext'] + file_info = data['file_info'] + thumb = data['thumb'] + prev = data['prev'] + icon = data['icon'] + + resource = None + if icon is not None: + resource = get_resource('{}.png'.format(icon)) + + if resource is None or not os.path.isfile(resource): + if data['is_sequence']: + resource = get_resource('files.png') + else: + resource = get_resource('file.png') + + pixmap = QtGui.QPixmap(resource) + self.icon.setPixmap(pixmap) + + self.name.setText(name) + self.input_repre.setText(representation) + self.ext.setText('( {} )'.format(ext)) + if file_info is None: + self.file_info.setVisible(False) + else: + self.file_info.setText('[{}]'.format(file_info)) + + self.thumbnail.setVisible(thumb) + self.preview.setVisible(prev) + + def add_action(self, action_name): + if action_name.lower() == 'split': + for action in self.actions: + if action.text() == 'Split to frames': + return + new_action = QtWidgets.QAction('Split to frames', self) + new_action.triggered.connect(self.split_sequence) + elif action_name.lower() == 'merge': + for action in self.actions: + if action.text() == 'Merge components': + return + new_action = QtWidgets.QAction('Merge components', self) + new_action.triggered.connect(self.merge_sequence) + else: + print('unknown action') + return + self.action_menu.addAction(new_action) + self.actions.append(new_action) + if not self.btn_action_menu.isVisible(): + self.btn_action_menu.setVisible(True) + self.btn_action_menu.clicked.connect(self.show_actions) + self.action_menu.setStyleSheet(style.load_stylesheet()) + + def set_repre_name_valid(self, valid): + self.has_valid_repre = valid + if valid: + self.input_repre.setStyleSheet("") + else: + self.input_repre.setStyleSheet("border: 1px solid red;") + + def split_sequence(self): + self.parent_widget.split_items(self) + + def merge_sequence(self): + self.parent_widget.merge_items(self) + + def show_actions(self): + position = QtGui.QCursor().pos() + self.action_menu.popup(position) + + def _remove(self): + self.signal_remove.emit(self) + + def _thumbnail_clicked(self): + self.signal_thumbnail.emit(self) + + def _preview_clicked(self): + self.signal_preview.emit(self) + + def _handle_duplicate_repre(self, repre_name): + self.signal_repre_change.emit(self, repre_name) + + def is_thumbnail(self): + return self.thumbnail.checked + + def change_thumbnail(self, hover=True): + self.thumbnail.change_checked(hover) + + def is_preview(self): + return self.preview.checked + + def change_preview(self, hover=True): + self.preview.change_checked(hover) + + def collect_data(self): + in_files = self.in_data['files'] + staging_dir = os.path.dirname(in_files[0]) + + files = [os.path.basename(file) for file in in_files] + if len(files) == 1: + files = files[0] + + data = { + 'ext': self.in_data['ext'], + 'label': self.name.text(), + 'name': self.input_repre.text(), + 'stagingDir': staging_dir, + 'files': files, + 'thumbnail': self.is_thumbnail(), + 'preview': self.is_preview() + } + + if ('startFrame' in self.in_data and 'endFrame' in self.in_data): + data['startFrame'] = self.in_data['startFrame'] + data['endFrame'] = self.in_data['endFrame'] + + if 'frameRate' in self.in_data: + data['frameRate'] = self.in_data['frameRate'] + + return data diff --git a/pype/standalonepublish/widgets/widget_components.py b/pype/standalonepublish/widgets/widget_components.py new file mode 100644 index 0000000000..1e1fdf88e3 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_components.py @@ -0,0 +1,128 @@ +from . import QtWidgets, QtCore, QtGui +from . import DropDataFrame + +from .. import publish + + +class ComponentsWidget(QtWidgets.QWidget): + def __init__(self, parent): + super().__init__() + self.initialized = False + self.valid_components = False + self.valid_family = False + self.valid_repre_names = False + + body = QtWidgets.QWidget() + self.parent_widget = parent + self.drop_frame = DropDataFrame(self) + + buttons = QtWidgets.QWidget() + + layout = QtWidgets.QHBoxLayout(buttons) + + self.btn_browse = QtWidgets.QPushButton('Browse') + self.btn_browse.setToolTip('Browse for file(s).') + self.btn_browse.setFocusPolicy(QtCore.Qt.NoFocus) + + self.btn_publish = QtWidgets.QPushButton('Publish') + self.btn_publish.setToolTip('Publishes data.') + self.btn_publish.setFocusPolicy(QtCore.Qt.NoFocus) + + layout.addWidget(self.btn_browse, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(self.btn_publish, alignment=QtCore.Qt.AlignRight) + + layout = QtWidgets.QVBoxLayout(body) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.drop_frame) + layout.addWidget(buttons) + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(body) + + self.btn_browse.clicked.connect(self._browse) + self.btn_publish.clicked.connect(self._publish) + self.initialized = True + + def validation(self): + if self.initialized is False: + return + valid = ( + self.parent_widget.valid_family and + self.valid_components and + self.valid_repre_names + ) + self.btn_publish.setEnabled(valid) + + def set_valid_components(self, valid): + self.valid_components = valid + self.validation() + + def set_valid_repre_names(self, valid): + self.valid_repre_names = valid + self.validation() + + def process_mime_data(self, mime_data): + self.drop_frame.process_ent_mime(mime_data) + + def collect_data(self): + return self.drop_frame.collect_data() + + def _browse(self): + options = [ + QtWidgets.QFileDialog.DontResolveSymlinks, + QtWidgets.QFileDialog.DontUseNativeDialog + ] + folders = False + if folders: + # browse folders specifics + caption = "Browse folders to publish image sequences" + file_mode = QtWidgets.QFileDialog.Directory + options.append(QtWidgets.QFileDialog.ShowDirsOnly) + else: + # browse files specifics + caption = "Browse files to publish" + file_mode = QtWidgets.QFileDialog.ExistingFiles + + # create the dialog + file_dialog = QtWidgets.QFileDialog(parent=self, caption=caption) + file_dialog.setLabelText(QtWidgets.QFileDialog.Accept, "Select") + file_dialog.setLabelText(QtWidgets.QFileDialog.Reject, "Cancel") + file_dialog.setFileMode(file_mode) + + # set the appropriate options + for option in options: + file_dialog.setOption(option) + + # browse! + if not file_dialog.exec_(): + return + + # process the browsed files/folders for publishing + paths = file_dialog.selectedFiles() + self.drop_frame._process_paths(paths) + + def working_start(self, msg=None): + if hasattr(self, 'parent_widget'): + self.parent_widget.working_start(msg) + + def working_stop(self): + if hasattr(self, 'parent_widget'): + self.parent_widget.working_stop() + + def _publish(self): + self.working_start('Pyblish is running') + try: + data = self.parent_widget.collect_data() + publish.set_context( + data['project'], data['asset'], 'standalonepublish' + ) + result = publish.publish(data) + # Clear widgets from components list if publishing was successful + if result: + self.drop_frame.components_list.clear_widgets() + self.drop_frame._refresh_view() + finally: + self.working_stop() diff --git a/pype/standalonepublish/widgets/widget_components_list.py b/pype/standalonepublish/widgets/widget_components_list.py new file mode 100644 index 0000000000..f85e9f0aa6 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_components_list.py @@ -0,0 +1,89 @@ +from . import QtCore, QtGui, QtWidgets + + +class ComponentsList(QtWidgets.QTableWidget): + def __init__(self, parent=None): + super().__init__(parent=parent) + + self._main_column = 0 + + self.setColumnCount(1) + self.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectRows + ) + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.setVerticalScrollMode( + QtWidgets.QAbstractItemView.ScrollPerPixel + ) + self.verticalHeader().hide() + + try: + self.verticalHeader().setResizeMode( + QtWidgets.QHeaderView.ResizeToContents + ) + except Exception: + self.verticalHeader().setSectionResizeMode( + QtWidgets.QHeaderView.ResizeToContents + ) + + self.horizontalHeader().setStretchLastSection(True) + self.horizontalHeader().hide() + + def count(self): + return self.rowCount() + + def add_widget(self, widget, row=None): + if row is None: + row = self.count() + + self.insertRow(row) + self.setCellWidget(row, self._main_column, widget) + + self.resizeRowToContents(row) + + return row + + def remove_widget(self, row): + self.removeRow(row) + + def move_widget(self, widget, newRow): + oldRow = self.indexOfWidget(widget) + if oldRow: + self.insertRow(newRow) + # Collect the oldRow after insert to make sure we move the correct + # widget. + oldRow = self.indexOfWidget(widget) + + self.setCellWidget(newRow, self._main_column, widget) + self.resizeRowToContents(oldRow) + + # Remove the old row + self.removeRow(oldRow) + + def clear_widgets(self): + '''Remove all widgets.''' + self.clear() + self.setRowCount(0) + + def widget_index(self, widget): + index = None + for row in range(self.count()): + candidateWidget = self.widget_at(row) + if candidateWidget == widget: + index = row + break + + return index + + def widgets(self): + widgets = [] + for row in range(self.count()): + widget = self.widget_at(row) + widgets.append(widget) + + return widgets + + def widget_at(self, row): + return self.cellWidget(row, self._main_column) diff --git a/pype/standalonepublish/widgets/widget_drop_empty.py b/pype/standalonepublish/widgets/widget_drop_empty.py new file mode 100644 index 0000000000..a68b91da59 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_drop_empty.py @@ -0,0 +1,52 @@ +import os +import logging +import clique +from . import QtWidgets, QtCore, QtGui + + +class DropEmpty(QtWidgets.QWidget): + + def __init__(self, parent): + '''Initialise DataDropZone widget.''' + super().__init__(parent) + + layout = QtWidgets.QVBoxLayout(self) + + BottomCenterAlignment = QtCore.Qt.AlignBottom | QtCore.Qt.AlignHCenter + TopCenterAlignment = QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter + + font = QtGui.QFont() + font.setFamily("DejaVu Sans Condensed") + font.setPointSize(26) + font.setBold(True) + font.setWeight(50) + font.setKerning(True) + + self._label = QtWidgets.QLabel('Drag & Drop') + self._label.setFont(font) + self._label.setStyleSheet( + 'background-color: rgb(255, 255, 255, 0);' + ) + + font.setPointSize(12) + self._sub_label = QtWidgets.QLabel('(drop files here)') + self._sub_label.setFont(font) + self._sub_label.setStyleSheet( + 'background-color: rgb(255, 255, 255, 0);' + ) + + layout.addWidget(self._label, alignment=BottomCenterAlignment) + layout.addWidget(self._sub_label, alignment=TopCenterAlignment) + + def paintEvent(self, event): + super().paintEvent(event) + painter = QtGui.QPainter(self) + pen = QtGui.QPen() + pen.setWidth(1); + pen.setBrush(QtCore.Qt.darkGray); + pen.setStyle(QtCore.Qt.DashLine); + painter.setPen(pen) + painter.drawRect( + 10, 10, + self.rect().width()-15, self.rect().height()-15 + ) diff --git a/pype/standalonepublish/widgets/widget_drop_frame.py b/pype/standalonepublish/widgets/widget_drop_frame.py new file mode 100644 index 0000000000..4e99f697cb --- /dev/null +++ b/pype/standalonepublish/widgets/widget_drop_frame.py @@ -0,0 +1,447 @@ +import os +import re +import json +import clique +import subprocess +from pypeapp import config +from . import QtWidgets, QtCore +from . import DropEmpty, ComponentsList, ComponentItem + + +class DropDataFrame(QtWidgets.QFrame): + def __init__(self, parent): + super().__init__() + self.parent_widget = parent + self.presets = config.get_presets()['standalone_publish'] + + self.setAcceptDrops(True) + layout = QtWidgets.QVBoxLayout(self) + self.components_list = ComponentsList(self) + layout.addWidget(self.components_list) + + self.drop_widget = DropEmpty(self) + + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.drop_widget.sizePolicy().hasHeightForWidth()) + self.drop_widget.setSizePolicy(sizePolicy) + + layout.addWidget(self.drop_widget) + + self._refresh_view() + + def dragEnterEvent(self, event): + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + self.process_ent_mime(event) + event.accept() + + def process_ent_mime(self, ent): + paths = [] + if ent.mimeData().hasUrls(): + paths = self._processMimeData(ent.mimeData()) + else: + # If path is in clipboard as string + try: + path = os.path.normpath(ent.text()) + if os.path.exists(path): + paths.append(path) + else: + print('Dropped invalid file/folder') + except Exception: + pass + if paths: + self._process_paths(paths) + + def _processMimeData(self, mimeData): + paths = [] + + for path in mimeData.urls(): + local_path = path.toLocalFile() + if os.path.isfile(local_path) or os.path.isdir(local_path): + paths.append(local_path) + else: + print('Invalid input: "{}"'.format(local_path)) + return paths + + def _add_item(self, data, actions=[]): + # Assign to self so garbage collector wont remove the component + # during initialization + new_component = ComponentItem(self.components_list, self) + new_component.set_context(data) + self.components_list.add_widget(new_component) + + new_component.signal_remove.connect(self._remove_item) + new_component.signal_preview.connect(self._set_preview) + new_component.signal_thumbnail.connect( + self._set_thumbnail + ) + new_component.signal_repre_change.connect(self.repre_name_changed) + for action in actions: + new_component.add_action(action) + + if len(self.components_list.widgets()) == 1: + self.parent_widget.set_valid_repre_names(True) + self._refresh_view() + + def _set_thumbnail(self, in_item): + checked_item = None + for item in self.components_list.widgets(): + if item.is_thumbnail(): + checked_item = item + break + if checked_item is None or checked_item == in_item: + in_item.change_thumbnail() + else: + checked_item.change_thumbnail(False) + in_item.change_thumbnail() + + def _set_preview(self, in_item): + checked_item = None + for item in self.components_list.widgets(): + if item.is_preview(): + checked_item = item + break + if checked_item is None or checked_item == in_item: + in_item.change_preview() + else: + checked_item.change_preview(False) + in_item.change_preview() + + def _remove_item(self, in_item): + valid_repre = in_item.has_valid_repre is True + + self.components_list.remove_widget( + self.components_list.widget_index(in_item) + ) + self._refresh_view() + if valid_repre: + return + for item in self.components_list.widgets(): + if item.has_valid_repre: + continue + self.repre_name_changed(item, item.input_repre.text()) + + def _refresh_view(self): + _bool = len(self.components_list.widgets()) == 0 + self.components_list.setVisible(not _bool) + self.drop_widget.setVisible(_bool) + + self.parent_widget.set_valid_components(not _bool) + + def _process_paths(self, in_paths): + self.parent_widget.working_start() + paths = self._get_all_paths(in_paths) + collections, remainders = clique.assemble(paths) + for collection in collections: + self._process_collection(collection) + for remainder in remainders: + self._process_remainder(remainder) + self.parent_widget.working_stop() + + def _get_all_paths(self, paths): + output_paths = [] + for path in paths: + path = os.path.normpath(path) + if os.path.isfile(path): + output_paths.append(path) + elif os.path.isdir(path): + s_paths = [] + for s_item in os.listdir(path): + s_path = os.path.sep.join([path, s_item]) + s_paths.append(s_path) + output_paths.extend(self._get_all_paths(s_paths)) + else: + print('Invalid path: "{}"'.format(path)) + return output_paths + + def _process_collection(self, collection): + file_base = os.path.basename(collection.head) + folder_path = os.path.dirname(collection.head) + if file_base[-1] in ['.', '_']: + file_base = file_base[:-1] + file_ext = collection.tail + repr_name = file_ext.replace('.', '') + range = collection.format('{ranges}') + + # TODO: ranges must not be with missing frames!!! + # - this is goal implementation: + # startFrame, endFrame = range.split('-') + rngs = range.split(',') + startFrame = rngs[0].split('-')[0] + endFrame = rngs[-1].split('-')[-1] + + actions = [] + + data = { + 'files': [file for file in collection], + 'name': file_base, + 'ext': file_ext, + 'file_info': range, + 'startFrame': startFrame, + 'endFrame': endFrame, + 'representation': repr_name, + 'folder_path': folder_path, + 'is_sequence': True, + 'actions': actions + } + + self._process_data(data) + + def _process_remainder(self, remainder): + filename = os.path.basename(remainder) + folder_path = os.path.dirname(remainder) + file_base, file_ext = os.path.splitext(filename) + repr_name = file_ext.replace('.', '') + file_info = None + + files = [] + files.append(remainder) + + actions = [] + + data = { + 'files': files, + 'name': file_base, + 'ext': file_ext, + 'representation': repr_name, + 'folder_path': folder_path, + 'is_sequence': False, + 'actions': actions + } + + self._process_data(data) + + def load_data_with_probe(self, filepath): + args = [ + 'ffprobe', + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + '-show_streams', filepath + ] + ffprobe_p = subprocess.Popen( + args, + stdout=subprocess.PIPE, + shell=True + ) + ffprobe_output = ffprobe_p.communicate()[0] + if ffprobe_p.returncode != 0: + raise RuntimeError( + 'Failed on ffprobe: check if ffprobe path is set in PATH env' + ) + return json.loads(ffprobe_output)['streams'][0] + + def get_file_data(self, data): + filepath = data['files'][0] + ext = data['ext'] + output = {} + probe_data = self.load_data_with_probe(filepath) + + if ( + ext in self.presets['extensions']['image_file'] or + ext in self.presets['extensions']['video_file'] + ): + if 'frameRate' not in data: + # default value + frameRate = 25 + frameRate_string = probe_data.get('r_frame_rate') + if frameRate_string: + frameRate = int(frameRate_string.split('/')[0]) + + output['frameRate'] = frameRate + + if 'startFrame' not in data or 'endFrame' not in data: + startFrame = endFrame = 1 + endFrame_string = probe_data.get('nb_frames') + + if endFrame_string: + endFrame = int(endFrame_string) + + output['startFrame'] = startFrame + output['endFrame'] = endFrame + + file_info = None + if 'file_info' in data: + file_info = data['file_info'] + elif ext in ['.mov']: + file_info = probe_data.get('codec_name') + + output['file_info'] = file_info + + return output + + def _process_data(self, data): + ext = data['ext'] + # load file data info + file_data = self.get_file_data(data) + for key, value in file_data.items(): + data[key] = value + + icon = 'default' + for ico, exts in self.presets['extensions'].items(): + if ext in exts: + icon = ico + break + # Add 's' to icon_name if is sequence (image -> images) + if data['is_sequence']: + icon += 's' + data['icon'] = icon + data['thumb'] = ( + ext in self.presets['extensions']['image_file'] or + ext in self.presets['extensions']['video_file'] + ) + data['prev'] = ext in self.presets['extensions']['video_file'] + + actions = [] + new_is_seq = data['is_sequence'] + + found = False + for item in self.components_list.widgets(): + if data['ext'] != item.in_data['ext']: + continue + if data['folder_path'] != item.in_data['folder_path']: + continue + + ex_is_seq = item.in_data['is_sequence'] + + # If both are single files + if not new_is_seq and not ex_is_seq: + if data['name'] == item.in_data['name']: + found = True + break + paths = data['files'] + paths.extend(item.in_data['files']) + c, r = clique.assemble(paths) + if len(c) == 0: + continue + a_name = 'merge' + item.add_action(a_name) + if a_name not in actions: + actions.append(a_name) + + # If new is sequence and ex is single file + elif new_is_seq and not ex_is_seq: + if data['name'] not in item.in_data['name']: + continue + ex_file = item.in_data['files'][0] + + a_name = 'merge' + item.add_action(a_name) + if a_name not in actions: + actions.append(a_name) + continue + + # If new is single file existing is sequence + elif not new_is_seq and ex_is_seq: + if item.in_data['name'] not in data['name']: + continue + a_name = 'merge' + item.add_action(a_name) + if a_name not in actions: + actions.append(a_name) + + # If both are sequence + else: + if data['name'] != item.in_data['name']: + continue + if data['files'] == item.in_data['files']: + found = True + break + a_name = 'merge' + item.add_action(a_name) + if a_name not in actions: + actions.append(a_name) + + if new_is_seq: + actions.append('split') + + if found is False: + new_repre = self.handle_new_repre_name(data['representation']) + data['representation'] = new_repre + self._add_item(data, actions) + + def handle_new_repre_name(self, repre_name): + renamed = False + for item in self.components_list.widgets(): + if repre_name == item.input_repre.text(): + check_regex = '_\w+$' + result = re.findall(check_regex, repre_name) + next_num = 2 + if len(result) == 1: + repre_name = repre_name.replace(result[0], '') + next_num = int(result[0].replace('_', '')) + next_num += 1 + repre_name = '{}_{}'.format(repre_name, next_num) + renamed = True + break + if renamed: + return self.handle_new_repre_name(repre_name) + return repre_name + + def repre_name_changed(self, in_item, repre_name): + is_valid = True + if repre_name.strip() == '': + in_item.set_repre_name_valid(False) + is_valid = False + else: + for item in self.components_list.widgets(): + if item == in_item: + continue + if item.input_repre.text() == repre_name: + item.set_repre_name_valid(False) + in_item.set_repre_name_valid(False) + is_valid = False + global_valid = is_valid + if is_valid: + in_item.set_repre_name_valid(True) + for item in self.components_list.widgets(): + if item.has_valid_repre: + continue + self.repre_name_changed(item, item.input_repre.text()) + for item in self.components_list.widgets(): + if not item.has_valid_repre: + global_valid = False + break + self.parent_widget.set_valid_repre_names(global_valid) + + def merge_items(self, in_item): + self.parent_widget.working_start() + items = [] + in_paths = in_item.in_data['files'] + paths = in_paths + for item in self.components_list.widgets(): + if item.in_data['files'] == in_paths: + items.append(item) + continue + copy_paths = paths.copy() + copy_paths.extend(item.in_data['files']) + collections, remainders = clique.assemble(copy_paths) + if len(collections) == 1 and len(remainders) == 0: + paths.extend(item.in_data['files']) + items.append(item) + for item in items: + self._remove_item(item) + self._process_paths(paths) + self.parent_widget.working_stop() + + def split_items(self, item): + self.parent_widget.working_start() + paths = item.in_data['files'] + self._remove_item(item) + for path in paths: + self._process_remainder(path) + self.parent_widget.working_stop() + + def collect_data(self): + data = {'representations' : []} + for item in self.components_list.widgets(): + data['representations'].append(item.collect_data()) + return data diff --git a/pype/standalonepublish/widgets/widget_family.py b/pype/standalonepublish/widgets/widget_family.py new file mode 100644 index 0000000000..63776b1df3 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_family.py @@ -0,0 +1,353 @@ +import os +import sys +import inspect +import json +from collections import namedtuple + +from . import QtWidgets, QtCore +from . import HelpRole, FamilyRole, ExistsRole, PluginRole +from . import FamilyDescriptionWidget + +from pypeapp import config + + +class FamilyWidget(QtWidgets.QWidget): + + stateChanged = QtCore.Signal(bool) + data = dict() + _jobs = dict() + Separator = "---separator---" + NOT_SELECTED = '< Nothing is selected >' + + def __init__(self, parent): + super().__init__(parent) + # Store internal states in here + self.state = {"valid": False} + self.parent_widget = parent + self.asset_name = self.NOT_SELECTED + + body = QtWidgets.QWidget() + lists = QtWidgets.QWidget() + + container = QtWidgets.QWidget() + + list_families = QtWidgets.QListWidget() + + input_subset = QtWidgets.QLineEdit() + input_result = QtWidgets.QLineEdit() + input_result.setStyleSheet("color: #BBBBBB;") + input_result.setEnabled(False) + + # region Menu for default subset names + btn_subset = QtWidgets.QPushButton() + btn_subset.setFixedWidth(18) + btn_subset.setFixedHeight(20) + menu_subset = QtWidgets.QMenu(btn_subset) + btn_subset.setMenu(menu_subset) + + # endregion + name_layout = QtWidgets.QHBoxLayout() + name_layout.addWidget(input_subset) + name_layout.addWidget(btn_subset) + name_layout.setContentsMargins(0, 0, 0, 0) + + # version + version_spinbox = QtWidgets.QSpinBox() + version_spinbox.setMinimum(1) + version_spinbox.setMaximum(9999) + version_spinbox.setEnabled(False) + version_spinbox.setStyleSheet("color: #BBBBBB;") + + version_checkbox = QtWidgets.QCheckBox("Next Available Version") + version_checkbox.setCheckState(QtCore.Qt.CheckState(2)) + + version_layout = QtWidgets.QHBoxLayout() + version_layout.addWidget(version_spinbox) + version_layout.addWidget(version_checkbox) + + layout = QtWidgets.QVBoxLayout(container) + + header = FamilyDescriptionWidget(self) + layout.addWidget(header) + + layout.addWidget(QtWidgets.QLabel("Family")) + layout.addWidget(list_families) + layout.addWidget(QtWidgets.QLabel("Subset")) + layout.addLayout(name_layout) + layout.addWidget(input_result) + layout.addWidget(QtWidgets.QLabel("Version")) + layout.addLayout(version_layout) + layout.setContentsMargins(0, 0, 0, 0) + + options = QtWidgets.QWidget() + + layout = QtWidgets.QGridLayout(options) + layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QHBoxLayout(lists) + layout.addWidget(container) + layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(body) + + layout.addWidget(lists) + layout.addWidget(options, 0, QtCore.Qt.AlignLeft) + layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + + input_subset.textChanged.connect(self.on_data_changed) + list_families.currentItemChanged.connect(self.on_selection_changed) + list_families.currentItemChanged.connect(header.set_item) + version_checkbox.stateChanged.connect(self.on_version_refresh) + + self.stateChanged.connect(self._on_state_changed) + + self.input_subset = input_subset + self.menu_subset = menu_subset + self.btn_subset = btn_subset + self.list_families = list_families + self.input_result = input_result + self.version_checkbox = version_checkbox + self.version_spinbox = version_spinbox + + self.refresh() + + def collect_data(self): + plugin = self.list_families.currentItem().data(PluginRole) + family = plugin.family.rsplit(".", 1)[-1] + data = { + 'family': family, + 'subset': self.input_result.text(), + 'version': self.version_spinbox.value() + } + return data + + @property + def db(self): + return self.parent_widget.db + + def change_asset(self, name): + if name is None: + name = self.NOT_SELECTED + self.asset_name = name + self.on_data_changed() + + def _on_state_changed(self, state): + self.state['valid'] = state + self.parent_widget.set_valid_family(state) + + def _build_menu(self, default_names): + """Create optional predefined subset names + + Args: + default_names(list): all predefined names + + Returns: + None + """ + + # Get and destroy the action group + group = self.btn_subset.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + state = any(default_names) + self.btn_subset.setEnabled(state) + if state is False: + return + + # Build new action group + group = QtWidgets.QActionGroup(self.btn_subset) + for name in default_names: + if name == self.Separator: + self.menu_subset.addSeparator() + continue + action = group.addAction(name) + self.menu_subset.addAction(action) + + group.triggered.connect(self._on_action_clicked) + + def _on_action_clicked(self, action): + self.input_subset.setText(action.text()) + + def _on_data_changed(self): + asset_name = self.asset_name + subset_name = self.input_subset.text() + item = self.list_families.currentItem() + + if item is None: + return + + assets = None + if asset_name != self.NOT_SELECTED: + # Get the assets from the database which match with the name + assets_db = self.db.find( + filter={"type": "asset"}, + projection={"name": 1} + ) + assets = [ + asset for asset in assets_db if asset_name in asset["name"] + ] + + # Get plugin and family + plugin = item.data(PluginRole) + if plugin is None: + return + + family = plugin.family.rsplit(".", 1)[-1] + + # Update the result + if subset_name: + subset_name = subset_name[0].upper() + subset_name[1:] + self.input_result.setText("{}{}".format(family, subset_name)) + + if assets: + # Get all subsets of the current asset + asset_ids = [asset["_id"] for asset in assets] + subsets = self.db.find(filter={"type": "subset", + "name": {"$regex": "{}*".format(family), + "$options": "i"}, + "parent": {"$in": asset_ids}}) or [] + + # Get all subsets' their subset name, "Default", "High", "Low" + existed_subsets = [sub["name"].split(family)[-1] + for sub in subsets] + + if plugin.defaults and isinstance(plugin.defaults, list): + defaults = plugin.defaults[:] + [self.Separator] + lowered = [d.lower() for d in plugin.defaults] + for sub in [s for s in existed_subsets + if s.lower() not in lowered]: + defaults.append(sub) + else: + defaults = existed_subsets + + self._build_menu(defaults) + + item.setData(ExistsRole, True) + else: + self._build_menu([]) + item.setData(ExistsRole, False) + if asset_name != self.NOT_SELECTED: + # TODO add logging into standalone_publish + print("'%s' not found .." % asset_name) + + self.on_version_refresh() + + # Update the valid state + valid = ( + asset_name != self.NOT_SELECTED and + subset_name.strip() != "" and + item.data(QtCore.Qt.ItemIsEnabled) and + item.data(ExistsRole) + ) + self.stateChanged.emit(valid) + + def on_version_refresh(self): + auto_version = self.version_checkbox.isChecked() + self.version_spinbox.setEnabled(not auto_version) + if not auto_version: + return + + asset_name = self.asset_name + subset_name = self.input_result.text() + version = 1 + + if ( + asset_name != self.NOT_SELECTED and + subset_name.strip() != '' + ): + asset = self.db.find_one({ + 'type': 'asset', + 'name': asset_name + }) + subset = self.db.find_one({ + 'type': 'subset', + 'parent': asset['_id'], + 'name': subset_name + }) + if subset: + versions = self.db.find({ + 'type': 'version', + 'parent': subset['_id'] + }) + if versions: + versions = sorted( + [v for v in versions], + key=lambda ver: ver['name'] + ) + version = int(versions[-1]['name']) + 1 + + self.version_spinbox.setValue(version) + + def on_data_changed(self, *args): + + # Set invalid state until it's reconfirmed to be valid by the + # scheduled callback so any form of creation is held back until + # valid again + self.stateChanged.emit(False) + self.schedule(self._on_data_changed, 500, channel="gui") + + def on_selection_changed(self, *args): + plugin = self.list_families.currentItem().data(PluginRole) + if plugin is None: + return + + if plugin.defaults and isinstance(plugin.defaults, list): + default = plugin.defaults[0] + else: + default = "Default" + + self.input_subset.setText(default) + + self.on_data_changed() + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidently perform Maya commands + whilst trying to name an instance. + + """ + + def refresh(self): + has_families = False + presets = config.get_presets().get('standalone_publish', {}) + + for creator in presets.get('families', {}).values(): + creator = namedtuple("Creator", creator.keys())(*creator.values()) + + label = creator.label or creator.family + item = QtWidgets.QListWidgetItem(label) + item.setData(QtCore.Qt.ItemIsEnabled, True) + item.setData(HelpRole, creator.help or "") + item.setData(FamilyRole, creator.family) + item.setData(PluginRole, creator) + item.setData(ExistsRole, False) + self.list_families.addItem(item) + + has_families = True + + if not has_families: + item = QtWidgets.QListWidgetItem("No registered families") + item.setData(QtCore.Qt.ItemIsEnabled, False) + self.list_families.addItem(item) + + self.list_families.setCurrentItem(self.list_families.item(0)) + + def schedule(self, func, time, channel="default"): + try: + self._jobs[channel].stop() + except (AttributeError, KeyError): + pass + + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.timeout.connect(func) + timer.start(time) + + self._jobs[channel] = timer diff --git a/pype/standalonepublish/widgets/widget_family_desc.py b/pype/standalonepublish/widgets/widget_family_desc.py new file mode 100644 index 0000000000..e329f28ba6 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_family_desc.py @@ -0,0 +1,101 @@ +import os +import sys +import inspect +import json + +from . import QtWidgets, QtCore, QtGui +from . import HelpRole, FamilyRole, ExistsRole, PluginRole +from . import awesome +from pype.vendor import six +from pype import lib as pypelib + + +class FamilyDescriptionWidget(QtWidgets.QWidget): + """A family description widget. + + Shows a family icon, family name and a help description. + Used in creator header. + + _________________ + | ____ | + | |icon| FAMILY | + | |____| help | + |_________________| + + """ + + SIZE = 35 + + def __init__(self, parent=None): + super(FamilyDescriptionWidget, self).__init__(parent=parent) + + # Header font + font = QtGui.QFont() + font.setBold(True) + font.setPointSize(14) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + icon = QtWidgets.QLabel() + icon.setSizePolicy(QtWidgets.QSizePolicy.Maximum, + QtWidgets.QSizePolicy.Maximum) + + # Add 4 pixel padding to avoid icon being cut off + icon.setFixedWidth(self.SIZE + 4) + icon.setFixedHeight(self.SIZE + 4) + icon.setStyleSheet(""" + QLabel { + padding-right: 5px; + } + """) + + label_layout = QtWidgets.QVBoxLayout() + label_layout.setSpacing(0) + + family = QtWidgets.QLabel("family") + family.setFont(font) + family.setAlignment(QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft) + + help = QtWidgets.QLabel("help") + help.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) + + label_layout.addWidget(family) + label_layout.addWidget(help) + + layout.addWidget(icon) + layout.addLayout(label_layout) + + self.help = help + self.family = family + self.icon = icon + + def set_item(self, item): + """Update elements to display information of a family item. + + Args: + family (dict): A family item as registered with name, help and icon + + Returns: + None + + """ + if not item: + return + + # Support a font-awesome icon + plugin = item.data(PluginRole) + icon = getattr(plugin, "icon", "info-circle") + assert isinstance(icon, six.string_types) + icon = awesome.icon("fa.{}".format(icon), color="white") + pixmap = icon.pixmap(self.SIZE, self.SIZE) + pixmap = pixmap.scaled(self.SIZE, self.SIZE) + + # Parse a clean line from the Creator's docstring + docstring = plugin.help or "" + + help = docstring.splitlines()[0] if docstring else "" + + self.icon.setPixmap(pixmap) + self.family.setText(item.data(FamilyRole)) + self.help.setText(help) diff --git a/pype/standalonepublish/widgets/widget_shadow.py b/pype/standalonepublish/widgets/widget_shadow.py new file mode 100644 index 0000000000..1bb9cee44b --- /dev/null +++ b/pype/standalonepublish/widgets/widget_shadow.py @@ -0,0 +1,40 @@ +from . import QtWidgets, QtCore, QtGui + + +class ShadowWidget(QtWidgets.QWidget): + def __init__(self, parent): + self.parent_widget = parent + super().__init__(parent) + w = self.parent_widget.frameGeometry().width() + h = self.parent_widget.frameGeometry().height() + self.resize(QtCore.QSize(w, h)) + palette = QtGui.QPalette(self.palette()) + palette.setColor(palette.Background, QtCore.Qt.transparent) + self.setPalette(palette) + self.message = '' + + font = QtGui.QFont() + font.setFamily("DejaVu Sans Condensed") + font.setPointSize(40) + font.setBold(True) + font.setWeight(50) + font.setKerning(True) + self.font = font + + def paintEvent(self, event): + painter = QtGui.QPainter() + painter.begin(self) + painter.setFont(self.font) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.fillRect(event.rect(), QtGui.QBrush(QtGui.QColor(0, 0, 0, 127))) + painter.drawText( + QtCore.QRectF( + 0.0, + 0.0, + self.parent_widget.frameGeometry().width(), + self.parent_widget.frameGeometry().height() + ), + QtCore.Qt.AlignCenter|QtCore.Qt.AlignCenter, + self.message + ) + painter.end() diff --git a/pype/templates.py b/pype/templates.py index 7157cb1399..7d12801a00 100644 --- a/pype/templates.py +++ b/pype/templates.py @@ -3,9 +3,9 @@ import re import sys from avalon import io, api as avalon, lib as avalonlib from . import lib -from app.api import (Templates, Logger, format) -log = Logger.getLogger(__name__, - os.getenv("AVALON_APP", "pype-config")) +# from pypeapp.api import (Templates, Logger, format) +from pypeapp import Logger, config, Anatomy +log = Logger().get_logger(__name__, os.getenv("AVALON_APP", "pype-config")) self = sys.modules[__name__] @@ -19,7 +19,7 @@ def set_session(): def load_data_from_templates(): """ - Load Templates `contextual` data as singleton object + Load Presets and Anatomy `contextual` data as singleton object [info](https://en.wikipedia.org/wiki/Singleton_pattern) Returns: @@ -31,17 +31,29 @@ def load_data_from_templates(): if not any([ api.Dataflow, api.Anatomy, - api.Colorspace, - api.Metadata + api.Colorspace ] ): - # base = Templates() - t = Templates(type=["anatomy", "metadata", "dataflow", "colorspace"]) - api.Anatomy = t.anatomy - api.Metadata = t.metadata.format() - data = {"metadata": api.Metadata} - api.Dataflow = t.dataflow.format(data) - api.Colorspace = t.colorspace + presets = config.get_presets() + anatomy = Anatomy() + + try: + # try if it is not in projects custom directory + # `{PYPE_PROJECT_CONFIGS}/[PROJECT_NAME]/init.json` + # init.json define preset names to be used + p_init = presets["init"] + colorspace = presets["colorspace"][p_init["colorspace"]] + dataflow = presets["dataflow"][p_init["dataflow"]] + except KeyError: + log.warning("No projects custom preset available...") + colorspace = presets["colorspace"]["default"] + dataflow = presets["dataflow"]["default"] + log.info("Presets `colorspace` and `dataflow` loaded from `default`...") + + api.Anatomy = anatomy + api.Dataflow = dataflow + api.Colorspace = colorspace + log.info("Data from templates were Loaded...") @@ -59,7 +71,6 @@ def reset_data_from_templates(): api.Dataflow = None api.Anatomy = None api.Colorspace = None - api.Metadata = None log.info("Data from templates were Unloaded...") @@ -283,11 +294,12 @@ def get_workdir_template(data=None): load_data_from_templates() anatomy = api.Anatomy + anatomy_filled = anatomy.format(data or get_context_data()) try: - work = anatomy.work.format(data or get_context_data()) + work = anatomy_filled["work"] except Exception as e: log.error("{0} Error in " "get_workdir_template(): {1}".format(__name__, e)) - return os.path.join(work.root, work.folder) + return work["folder"] diff --git a/pype/widgets/project_settings.py b/pype/widgets/project_settings.py index 98c97b4885..3aa2fc06b6 100644 --- a/pype/widgets/project_settings.py +++ b/pype/widgets/project_settings.py @@ -1,6 +1,6 @@ -from app import style +from pypeapp import style from avalon.vendor.Qt import QtCore, QtGui, QtWidgets import os import getpass @@ -9,9 +9,6 @@ import platform import ftrack_api -# object symbol - - class Project_name_getUI(QtWidgets.QWidget): ''' Project setting ui: here all the neceserry ui widgets are created diff --git a/res/app_icons/Aport.png b/res/app_icons/Aport.png new file mode 100644 index 0000000000..0a6816513a Binary files /dev/null and b/res/app_icons/Aport.png differ diff --git a/res/app_icons/clockify-white.png b/res/app_icons/clockify-white.png new file mode 100644 index 0000000000..2803049fbe Binary files /dev/null and b/res/app_icons/clockify-white.png differ diff --git a/res/app_icons/clockify.png b/res/app_icons/clockify.png new file mode 100644 index 0000000000..ac4c44c763 Binary files /dev/null and b/res/app_icons/clockify.png differ diff --git a/res/app_icons/djvView.png b/res/app_icons/djvView.png new file mode 100644 index 0000000000..854604d57f Binary files /dev/null and b/res/app_icons/djvView.png differ diff --git a/res/app_icons/houdini.png b/res/app_icons/houdini.png new file mode 100644 index 0000000000..11cfa46dce Binary files /dev/null and b/res/app_icons/houdini.png differ diff --git a/res/app_icons/maya.png b/res/app_icons/maya.png new file mode 100644 index 0000000000..e84a6a3742 Binary files /dev/null and b/res/app_icons/maya.png differ diff --git a/res/app_icons/nuke.png b/res/app_icons/nuke.png new file mode 100644 index 0000000000..4234454096 Binary files /dev/null and b/res/app_icons/nuke.png differ diff --git a/res/app_icons/premiere.png b/res/app_icons/premiere.png new file mode 100644 index 0000000000..eb5b3d1ba2 Binary files /dev/null and b/res/app_icons/premiere.png differ diff --git a/res/app_icons/python.png b/res/app_icons/python.png new file mode 100644 index 0000000000..b3b5b2220a Binary files /dev/null and b/res/app_icons/python.png differ diff --git a/res/ftrack/action_icons/AssetsRemover.svg b/res/ftrack/action_icons/AssetsRemover.svg new file mode 100644 index 0000000000..e838ee9f28 --- /dev/null +++ b/res/ftrack/action_icons/AssetsRemover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/ftrack/action_icons/ComponentOpen.svg b/res/ftrack/action_icons/ComponentOpen.svg new file mode 100644 index 0000000000..6d4eba6839 --- /dev/null +++ b/res/ftrack/action_icons/ComponentOpen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/ftrack/action_icons/CreateFolders.svg b/res/ftrack/action_icons/CreateFolders.svg new file mode 100644 index 0000000000..c07e474e5c --- /dev/null +++ b/res/ftrack/action_icons/CreateFolders.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/ftrack/action_icons/CreateProjectFolders.svg b/res/ftrack/action_icons/CreateProjectFolders.svg new file mode 100644 index 0000000000..5fa653361e --- /dev/null +++ b/res/ftrack/action_icons/CreateProjectFolders.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/ftrack/action_icons/CustomAttributes.svg b/res/ftrack/action_icons/CustomAttributes.svg new file mode 100644 index 0000000000..6d73746ed0 --- /dev/null +++ b/res/ftrack/action_icons/CustomAttributes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/ftrack/action_icons/DeleteAsset.svg b/res/ftrack/action_icons/DeleteAsset.svg new file mode 100644 index 0000000000..a41ae31d12 --- /dev/null +++ b/res/ftrack/action_icons/DeleteAsset.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/ftrack/action_icons/JobKiller.svg b/res/ftrack/action_icons/JobKiller.svg new file mode 100644 index 0000000000..595c780a9b --- /dev/null +++ b/res/ftrack/action_icons/JobKiller.svg @@ -0,0 +1,374 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/ftrack/action_icons/MultipleNotes.svg b/res/ftrack/action_icons/MultipleNotes.svg new file mode 100644 index 0000000000..6ed916f1aa --- /dev/null +++ b/res/ftrack/action_icons/MultipleNotes.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/res/ftrack/action_icons/RV.png b/res/ftrack/action_icons/RV.png new file mode 100644 index 0000000000..741e7a9772 Binary files /dev/null and b/res/ftrack/action_icons/RV.png differ diff --git a/res/ftrack/action_icons/SyncToAvalon-local.svg b/res/ftrack/action_icons/SyncToAvalon-local.svg new file mode 100644 index 0000000000..bf4708e8a5 --- /dev/null +++ b/res/ftrack/action_icons/SyncToAvalon-local.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/ftrack/action_icons/SyncToAvalon.svg b/res/ftrack/action_icons/SyncToAvalon.svg new file mode 100644 index 0000000000..48071b2430 --- /dev/null +++ b/res/ftrack/action_icons/SyncToAvalon.svg @@ -0,0 +1,67 @@ + + + + + + diff --git a/res/ftrack/action_icons/TestAction.svg b/res/ftrack/action_icons/TestAction.svg new file mode 100644 index 0000000000..771644340e --- /dev/null +++ b/res/ftrack/action_icons/TestAction.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/res/ftrack/action_icons/thumbToChildren.svg b/res/ftrack/action_icons/thumbToChildren.svg new file mode 100644 index 0000000000..30b146803e --- /dev/null +++ b/res/ftrack/action_icons/thumbToChildren.svg @@ -0,0 +1,88 @@ + + + + + + diff --git a/res/ftrack/action_icons/thumbToParent.svg b/res/ftrack/action_icons/thumbToParent.svg new file mode 100644 index 0000000000..254b650306 --- /dev/null +++ b/res/ftrack/action_icons/thumbToParent.svg @@ -0,0 +1,95 @@ + + + + + + diff --git a/setup/nuke/nuke_path/menu.py b/setup/nuke/nuke_path/menu.py index 3613bc99f2..4982513b78 100644 --- a/setup/nuke/nuke_path/menu.py +++ b/setup/nuke/nuke_path/menu.py @@ -1,12 +1,18 @@ -from pype.nuke.lib import writes_version_sync, onScriptLoad +from pype.nuke.lib import ( + writes_version_sync, + onScriptLoad, + checkInventoryVersions +) + import nuke -from pype.api import Logger +from pypeapp import Logger -log = Logger.getLogger(__name__, "nuke") +log = Logger().get_logger(__name__, "nuke") nuke.addOnScriptSave(writes_version_sync) nuke.addOnScriptSave(onScriptLoad) +nuke.addOnScriptSave(checkInventoryVersions) log.info('Automatic syncing of write file knob to script version') diff --git a/setup/nukestudio/hiero_plugin_path/HieroPlayer/PlayerPresets.hrox b/setup/nukestudio/hiero_plugin_path/HieroPlayer/PlayerPresets.hrox new file mode 100644 index 0000000000..ec50e123f0 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/HieroPlayer/PlayerPresets.hrox @@ -0,0 +1,1108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 50 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + + + 2 + 70 + + + 2 + 70 + 17 + + + 126935040 + 70 + -1 + + + 2 + 70 + 2 + + + + + + diff --git a/setup/nukestudio/hiero_plugin_path/Python/Startup/SpreadsheetExport.py b/setup/nukestudio/hiero_plugin_path/Python/Startup/SpreadsheetExport.py new file mode 100644 index 0000000000..3adea8051c --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/Startup/SpreadsheetExport.py @@ -0,0 +1,140 @@ +# This action adds itself to the Spreadsheet View context menu allowing the contents of the Spreadsheet be exported as a CSV file. +# Usage: Right-click in Spreadsheet > "Export as .CSV" +# Note: This only prints the text data that is visible in the active Spreadsheet View. +# If you've filtered text, only the visible text will be printed to the CSV file +# Usage: Copy to ~/.hiero/Python/StartupUI +import hiero.core.events +import hiero.ui +import os, csv +try: + from PySide.QtGui import * + from PySide.QtCore import * +except: + from PySide2.QtGui import * + from PySide2.QtWidgets import * + from PySide2.QtCore import * + + +### Magic Widget Finding Methods - This stuff crawls all the PySide widgets, looking for an answer +def findWidget(w): + global foundryWidgets + if 'Foundry' in w.metaObject().className(): + foundryWidgets += [w] + + for c in w.children(): + findWidget(c) + return foundryWidgets + + +def getFoundryWidgetsWithClassName(filter=None): + global foundryWidgets + foundryWidgets = [] + widgets = [] + app = QApplication.instance() + for w in app.topLevelWidgets(): + findWidget(w) + + filteredWidgets = foundryWidgets + if filter: + filteredWidgets = [] + for widget in foundryWidgets: + if filter in widget.metaObject().className(): + filteredWidgets += [widget] + return filteredWidgets + + +# When right click, get the Sequence Name +def activeSpreadsheetTreeView(): + """ + Does some PySide widget Magic to detect the Active Spreadsheet TreeView. + """ + spreadsheetViews = getFoundryWidgetsWithClassName( + filter='SpreadsheetTreeView') + for spreadSheet in spreadsheetViews: + if spreadSheet.hasFocus(): + activeSpreadSheet = spreadSheet + return activeSpreadSheet + return None + + +#### Adds "Export .CSV" action to the Spreadsheet Context menu #### +class SpreadsheetExportCSVAction(QAction): + def __init__(self): + QAction.__init__(self, "Export as .CSV", None) + self.triggered.connect(self.exportCSVFromActiveSpreadsheetView) + hiero.core.events.registerInterest("kShowContextMenu/kSpreadsheet", + self.eventHandler) + self.setIcon(QIcon("icons:FBGridView.png")) + + def eventHandler(self, event): + # Insert the action to the Export CSV menu + event.menu.addAction(self) + + #### The guts!.. Writes a CSV file from a Sequence Object #### + def exportCSVFromActiveSpreadsheetView(self): + + # Get the active QTreeView from the active Spreadsheet + spreadsheetTreeView = activeSpreadsheetTreeView() + + if not spreadsheetTreeView: + return 'Unable to detect the active TreeView.' + seq = hiero.ui.activeView().sequence() + if not seq: + print 'Unable to detect the active Sequence from the activeView.' + return + + # The data model of the QTreeView + model = spreadsheetTreeView.model() + + csvSavePath = os.path.join(QDir.homePath(), 'Desktop', + seq.name() + '.csv') + savePath, filter = QFileDialog.getSaveFileName( + None, + caption="Export Spreadsheet to .CSV as...", + dir=csvSavePath, + filter="*.csv") + print 'Saving To: ' + str(savePath) + + # Saving was cancelled... + if len(savePath) == 0: + return + + # Get the Visible Header Columns from the QTreeView + + #csvHeader = ['Event', 'Status', 'Shot Name', 'Reel', 'Track', 'Speed', 'Src In', 'Src Out','Src Duration', 'Dst In', 'Dst Out', 'Dst Duration', 'Clip', 'Clip Media'] + + # Get a CSV writer object + f = open(savePath, 'w') + csvWriter = csv.writer( + f, delimiter=',', quotechar='|', quoting=csv.QUOTE_MINIMAL) + + # This is a list of the Column titles + csvHeader = [] + + for col in range(0, model.columnCount()): + if not spreadsheetTreeView.isColumnHidden(col): + csvHeader += [model.headerData(col, Qt.Horizontal)] + + # Write the Header row to the CSV file + csvWriter.writerow(csvHeader) + + # Go through each row/column and print + for row in range(model.rowCount()): + row_data = [] + for col in range(model.columnCount()): + if not spreadsheetTreeView.isColumnHidden(col): + row_data.append( + model.index(row, col, QModelIndex()).data( + Qt.DisplayRole)) + + # Write row to CSV file... + csvWriter.writerow(row_data) + + f.close() + # Conveniently show the CSV file in the native file browser... + QDesktopServices.openUrl( + QUrl('file:///%s' % (os.path.dirname(savePath)))) + + +# Add the action... +csvActions = SpreadsheetExportCSVAction() diff --git a/setup/nukestudio/hiero_plugin_path/Python/Startup/Startup.py b/setup/nukestudio/hiero_plugin_path/Python/Startup/Startup.py new file mode 100644 index 0000000000..bbef6502a9 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/Startup/Startup.py @@ -0,0 +1,19 @@ +import traceback + +# activate nukestudio from pype +import avalon.api +import pype.nukestudio +avalon.api.install(pype.nukestudio) + +try: + __import__("pype.nukestudio") + __import__("pyblish") + +except ImportError as e: + print traceback.format_exc() + print("pyblish: Could not load integration: %s " % e) + +else: + # Setup integration + import pype.nukestudio.lib + pype.nukestudio.lib.setup() diff --git a/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/OTIOExportTask.py b/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/OTIOExportTask.py new file mode 100644 index 0000000000..77dc9c45b3 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/OTIOExportTask.py @@ -0,0 +1,369 @@ +# MIT License +# +# Copyright (c) 2018 Daniel Flehner Heen (Storm Studios) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +import os +import re +import hiero.core +from hiero.core import util + +import opentimelineio as otio + + +marker_color_map = { + "magenta": otio.schema.MarkerColor.MAGENTA, + "red": otio.schema.MarkerColor.RED, + "yellow": otio.schema.MarkerColor.YELLOW, + "green": otio.schema.MarkerColor.GREEN, + "cyan": otio.schema.MarkerColor.CYAN, + "blue": otio.schema.MarkerColor.BLUE, +} + + +class OTIOExportTask(hiero.core.TaskBase): + + def __init__(self, initDict): + """Initialize""" + hiero.core.TaskBase.__init__(self, initDict) + + def name(self): + return str(type(self)) + + def get_rate(self, item): + num, den = item.framerate().toRational() + rate = float(num) / float(den) + + if rate.is_integer(): + return rate + + return round(rate, 2) + + def get_clip_ranges(self, trackitem): + # Is clip an audio file? Use sequence frame rate + if not trackitem.source().mediaSource().hasVideo(): + rate_item = trackitem.sequence() + + else: + rate_item = trackitem.source() + + source_rate = self.get_rate(rate_item) + + # Reversed video/audio + if trackitem.playbackSpeed() < 0: + start = trackitem.sourceOut() + + else: + start = trackitem.sourceIn() + + source_start_time = otio.opentime.RationalTime( + start, + source_rate + ) + source_duration = otio.opentime.RationalTime( + trackitem.duration(), + source_rate + ) + + source_range = otio.opentime.TimeRange( + start_time=source_start_time, + duration=source_duration + ) + + available_range = None + hiero_clip = trackitem.source() + if not hiero_clip.mediaSource().isOffline(): + start_time = otio.opentime.RationalTime( + hiero_clip.mediaSource().startTime(), + source_rate + ) + duration = otio.opentime.RationalTime( + hiero_clip.mediaSource().duration(), + source_rate + ) + available_range = otio.opentime.TimeRange( + start_time=start_time, + duration=duration + ) + + return source_range, available_range + + def add_gap(self, trackitem, otio_track, prev_out): + gap_length = trackitem.timelineIn() - prev_out + if prev_out != 0: + gap_length -= 1 + + rate = self.get_rate(trackitem.sequence()) + gap = otio.opentime.TimeRange( + duration=otio.opentime.RationalTime( + gap_length, + rate + ) + ) + otio_gap = otio.schema.Gap(source_range=gap) + otio_track.append(otio_gap) + + def get_marker_color(self, tag): + icon = tag.icon() + pat = 'icons:Tag(?P\w+)\.\w+' + + res = re.search(pat, icon) + if res: + color = res.groupdict().get('color') + if color.lower() in marker_color_map: + return marker_color_map[color.lower()] + + return otio.schema.MarkerColor.RED + + def add_markers(self, hiero_item, otio_item): + for tag in hiero_item.tags(): + if not tag.visible(): + continue + + if tag.name() == 'Copy': + # Hiero adds this tag to a lot of clips + continue + + frame_rate = self.get_rate(hiero_item) + + marked_range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + tag.inTime(), + frame_rate + ), + duration=otio.opentime.RationalTime( + int(tag.metadata().dict().get('tag.length', '0')), + frame_rate + ) + ) + + marker = otio.schema.Marker( + name=tag.name(), + color=self.get_marker_color(tag), + marked_range=marked_range, + metadata={ + 'Hiero': tag.metadata().dict() + } + ) + + otio_item.markers.append(marker) + + def add_clip(self, trackitem, otio_track, itemindex): + hiero_clip = trackitem.source() + + # Add Gap if needed + prev_item = ( + itemindex and trackitem.parent().items()[itemindex - 1] or + trackitem + ) + + if prev_item == trackitem and trackitem.timelineIn() > 0: + self.add_gap(trackitem, otio_track, 0) + + elif ( + prev_item != trackitem and + prev_item.timelineOut() != trackitem.timelineIn() + ): + self.add_gap(trackitem, otio_track, prev_item.timelineOut()) + + # Create Clip + source_range, available_range = self.get_clip_ranges(trackitem) + + otio_clip = otio.schema.Clip() + otio_clip.name = trackitem.name() + otio_clip.source_range = source_range + + # Add media reference + media_reference = otio.schema.MissingReference() + if not hiero_clip.mediaSource().isOffline(): + source = hiero_clip.mediaSource() + media_reference = otio.schema.ExternalReference() + media_reference.available_range = available_range + + path, name = os.path.split(source.fileinfos()[0].filename()) + media_reference.target_url = os.path.join(path, name) + media_reference.name = name + + otio_clip.media_reference = media_reference + + # Add Time Effects + playbackspeed = trackitem.playbackSpeed() + if playbackspeed != 1: + if playbackspeed == 0: + time_effect = otio.schema.FreezeFrame() + + else: + time_effect = otio.schema.LinearTimeWarp( + time_scalar=playbackspeed + ) + otio_clip.effects.append(time_effect) + + # Add tags as markers + if self._preset.properties()["includeTags"]: + self.add_markers(trackitem.source(), otio_clip) + + otio_track.append(otio_clip) + + # Add Transition if needed + if trackitem.inTransition() or trackitem.outTransition(): + self.add_transition(trackitem, otio_track) + + def add_transition(self, trackitem, otio_track): + transitions = [] + + if trackitem.inTransition(): + if trackitem.inTransition().alignment().name == 'kFadeIn': + transitions.append(trackitem.inTransition()) + + if trackitem.outTransition(): + transitions.append(trackitem.outTransition()) + + for transition in transitions: + alignment = transition.alignment().name + + if alignment == 'kFadeIn': + in_offset_frames = 0 + out_offset_frames = ( + transition.timelineOut() - transition.timelineIn() + ) + 1 + + elif alignment == 'kFadeOut': + in_offset_frames = ( + trackitem.timelineOut() - transition.timelineIn() + ) + 1 + out_offset_frames = 0 + + elif alignment == 'kDissolve': + in_offset_frames = ( + transition.inTrackItem().timelineOut() - + transition.timelineIn() + ) + out_offset_frames = ( + transition.timelineOut() - + transition.outTrackItem().timelineIn() + ) + + else: + # kUnknown transition is ignored + continue + + rate = trackitem.source().framerate().toFloat() + in_time = otio.opentime.RationalTime(in_offset_frames, rate) + out_time = otio.opentime.RationalTime(out_offset_frames, rate) + + otio_transition = otio.schema.Transition( + name=alignment, # Consider placing Hiero name in metadata + transition_type=otio.schema.TransitionTypes.SMPTE_Dissolve, + in_offset=in_time, + out_offset=out_time, + metadata={} + ) + + if alignment == 'kFadeIn': + otio_track.insert(-2, otio_transition) + + else: + otio_track.append(otio_transition) + + def add_tracks(self): + for track in self._sequence.items(): + if isinstance(track, hiero.core.AudioTrack): + kind = otio.schema.TrackKind.Audio + + else: + kind = otio.schema.TrackKind.Video + + otio_track = otio.schema.Track(kind=kind) + otio_track.name = track.name() + + for itemindex, trackitem in enumerate(track): + if isinstance(trackitem.source(), hiero.core.Clip): + self.add_clip(trackitem, otio_track, itemindex) + + self.otio_timeline.tracks.append(otio_track) + + # Add tags as markers + if self._preset.properties()["includeTags"]: + self.add_markers(self._sequence, self.otio_timeline.tracks) + + def create_OTIO(self): + self.otio_timeline = otio.schema.Timeline() + self.otio_timeline.name = self._sequence.name() + + self.add_tracks() + + def startTask(self): + self.create_OTIO() + + def taskStep(self): + return False + + def finishTask(self): + try: + exportPath = self.resolvedExportPath() + + # Check file extension + if not exportPath.lower().endswith(".otio"): + exportPath += ".otio" + + # check export root exists + dirname = os.path.dirname(exportPath) + util.filesystem.makeDirs(dirname) + + # write otio file + otio.adapters.write_to_file(self.otio_timeline, exportPath) + + # Catch all exceptions and log error + except Exception as e: + self.setError("failed to write file {f}\n{e}".format( + f=exportPath, + e=e) + ) + + hiero.core.TaskBase.finishTask(self) + + def forcedAbort(self): + pass + + +class OTIOExportPreset(hiero.core.TaskPresetBase): + def __init__(self, name, properties): + """Initialise presets to default values""" + hiero.core.TaskPresetBase.__init__(self, OTIOExportTask, name) + + self.properties()["includeTags"] = True + self.properties().update(properties) + + def supportedItems(self): + return hiero.core.TaskPresetBase.kSequence + + def addCustomResolveEntries(self, resolver): + resolver.addResolver( + "{ext}", + "Extension of the file to be output", + lambda keyword, task: "otio" + ) + + def supportsAudio(self): + return True + + +hiero.core.taskRegistry.registerTask(OTIOExportPreset, OTIOExportTask) diff --git a/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/OTIOExportUI.py b/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/OTIOExportUI.py new file mode 100644 index 0000000000..887ff05ec8 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/OTIOExportUI.py @@ -0,0 +1,65 @@ +import hiero.ui +import OTIOExportTask + +try: + # Hiero >= 11.x + from PySide2 import QtCore + from PySide2.QtWidgets import QCheckBox + from hiero.ui.FnTaskUIFormLayout import TaskUIFormLayout as FormLayout + +except ImportError: + # Hiero <= 10.x + from PySide import QtCore # lint:ok + from PySide.QtGui import QCheckBox, QFormLayout # lint:ok + + FormLayout = QFormLayout # lint:ok + + +class OTIOExportUI(hiero.ui.TaskUIBase): + def __init__(self, preset): + """Initialize""" + hiero.ui.TaskUIBase.__init__( + self, + OTIOExportTask.OTIOExportTask, + preset, + "OTIO Exporter" + ) + + def includeMarkersCheckboxChanged(self, state): + # Slot to handle change of checkbox state + self._preset.properties()["includeTags"] = state == QtCore.Qt.Checked + + def populateUI(self, widget, exportTemplate): + layout = widget.layout() + formLayout = FormLayout() + + # Hiero ~= 10.0v4 + if layout is None: + layout = formLayout + widget.setLayout(layout) + + else: + layout.addLayout(formLayout) + + # Checkboxes for whether the OTIO should contain markers or not + self.includeMarkersCheckbox = QCheckBox() + self.includeMarkersCheckbox.setToolTip( + "Enable to include Tags as markers in the exported OTIO file." + ) + self.includeMarkersCheckbox.setCheckState(QtCore.Qt.Unchecked) + + if self._preset.properties()["includeTags"]: + self.includeMarkersCheckbox.setCheckState(QtCore.Qt.Checked) + + self.includeMarkersCheckbox.stateChanged.connect( + self.includeMarkersCheckboxChanged + ) + + # Add Checkbox to layout + formLayout.addRow("Include Tags:", self.includeMarkersCheckbox) + + +hiero.ui.taskUIRegistry.registerTaskUI( + OTIOExportTask.OTIOExportPreset, + OTIOExportUI +) diff --git a/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/__init__.py b/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/__init__.py new file mode 100644 index 0000000000..67e6e78d35 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/Startup/otioexporter/__init__.py @@ -0,0 +1,29 @@ +# MIT License +# +# Copyright (c) 2018 Daniel Flehner Heen (Storm Studios) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from OTIOExportTask import OTIOExportTask +from OTIOExportUI import OTIOExportUI + +__all__ = [ + 'OTIOExportTask', + 'OTIOExportUI' +] diff --git a/setup/nukestudio/hiero_plugin_path/Python/Startup/selection_tracker.py b/setup/nukestudio/hiero_plugin_path/Python/Startup/selection_tracker.py new file mode 100644 index 0000000000..b7e05fed7c --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/Startup/selection_tracker.py @@ -0,0 +1,9 @@ +"""Puts the selection project into 'hiero.selection'""" + +import hiero + + +def selectionChanged(event): + hiero.selection = event.sender.selection() + +hiero.core.events.registerInterest('kSelectionChanged', selectionChanged) diff --git a/setup/nukestudio/hiero_plugin_path/Python/Startup/setFrameRate.py b/setup/nukestudio/hiero_plugin_path/Python/Startup/setFrameRate.py new file mode 100644 index 0000000000..ceb96a6fce --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/Startup/setFrameRate.py @@ -0,0 +1,164 @@ +# setFrameRate - adds a Right-click menu to the Project Bin view, allowing multiple BinItems (Clips/Sequences) to have their frame rates set. +# Install in: ~/.hiero/Python/StartupUI +# Requires 1.5v1 or later + +import hiero.core +import hiero.ui +try: + from PySide.QtGui import * + from PySide.QtCore import * +except: + from PySide2.QtGui import * + from PySide2.QtCore import * + from PySide2.QtWidgets import * + +# Dialog for setting a Custom frame rate. +class SetFrameRateDialog(QDialog): + + def __init__(self,itemSelection=None,parent=None): + super(SetFrameRateDialog, self).__init__(parent) + self.setWindowTitle("Set Custom Frame Rate") + self.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Fixed ) + layout = QFormLayout() + self._itemSelection = itemSelection + + self._frameRateField = QLineEdit() + self._frameRateField.setToolTip('Enter custom frame rate here.') + self._frameRateField.setValidator(QDoubleValidator(1, 99, 3, self)) + self._frameRateField.textChanged.connect(self._textChanged) + layout.addRow("Enter fps: ",self._frameRateField) + + # Standard buttons for Add/Cancel + self._buttonbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self._buttonbox.accepted.connect(self.accept) + self._buttonbox.rejected.connect(self.reject) + self._buttonbox.button(QDialogButtonBox.Ok).setEnabled(False) + layout.addRow("",self._buttonbox) + self.setLayout(layout) + + def _updateOkButtonState(self): + # Cancel is always an option but only enable Ok if there is some text. + currentFramerate = float(self.currentFramerateString()) + enableOk = False + enableOk = ((currentFramerate > 0.0) and (currentFramerate <= 250.0)) + print 'enabledOk',enableOk + self._buttonbox.button(QDialogButtonBox.Ok).setEnabled(enableOk) + + def _textChanged(self, newText): + self._updateOkButtonState() + + # Returns the current frame rate as a string + def currentFramerateString(self): + return str(self._frameRateField.text()) + + # Presents the Dialog and sets the Frame rate from a selection + def showDialogAndSetFrameRateFromSelection(self): + + if self._itemSelection is not None: + if self.exec_(): + # For the Undo loop... + + # Construct an TimeBase object for setting the Frame Rate (fps) + fps = hiero.core.TimeBase().fromString(self.currentFramerateString()) + + + # Set the frame rate for the selected BinItmes + for item in self._itemSelection: + item.setFramerate(fps) + return + +# This is just a convenience method for returning QActions with a title, triggered method and icon. +def makeAction(title, method, icon = None): + action = QAction(title,None) + action.setIcon(QIcon(icon)) + + # We do this magic, so that the title string from the action is used to set the frame rate! + def methodWrapper(): + method(title) + + action.triggered.connect( methodWrapper ) + return action + +# Menu which adds a Set Frame Rate Menu to Project Bin view +class SetFrameRateMenu: + + def __init__(self): + self._frameRateMenu = None + self._frameRatesDialog = None + + + # ant: Could use hiero.core.defaultFrameRates() here but messes up with string matching because we seem to mix decimal points + self.frameRates = ['8','12','12.50','15','23.98','24','25','29.97','30','48','50','59.94','60'] + hiero.core.events.registerInterest("kShowContextMenu/kBin", self.binViewEventHandler) + + self.menuActions = [] + + def createFrameRateMenus(self,selection): + selectedClipFPS = [str(bi.activeItem().framerate()) for bi in selection if (isinstance(bi,hiero.core.BinItem) and hasattr(bi,'activeItem'))] + selectedClipFPS = hiero.core.util.uniquify(selectedClipFPS) + sameFrameRate = len(selectedClipFPS)==1 + self.menuActions = [] + for fps in self.frameRates: + if fps in selectedClipFPS: + if sameFrameRate: + self.menuActions+=[makeAction(fps,self.setFrameRateFromMenuSelection, icon="icons:Ticked.png")] + else: + self.menuActions+=[makeAction(fps,self.setFrameRateFromMenuSelection, icon="icons:remove active.png")] + else: + self.menuActions+=[makeAction(fps,self.setFrameRateFromMenuSelection, icon=None)] + + # Now add Custom... menu + self.menuActions+=[makeAction('Custom...',self.setFrameRateFromMenuSelection, icon=None)] + + frameRateMenu = QMenu("Set Frame Rate") + for a in self.menuActions: + frameRateMenu.addAction(a) + + return frameRateMenu + + def setFrameRateFromMenuSelection(self, menuSelectionFPS): + + selectedBinItems = [bi.activeItem() for bi in self._selection if (isinstance(bi,hiero.core.BinItem) and hasattr(bi,'activeItem'))] + currentProject = selectedBinItems[0].project() + + with currentProject.beginUndo("Set Frame Rate"): + if menuSelectionFPS == 'Custom...': + self._frameRatesDialog = SetFrameRateDialog(itemSelection = selectedBinItems ) + self._frameRatesDialog.showDialogAndSetFrameRateFromSelection() + + else: + for b in selectedBinItems: + b.setFramerate(hiero.core.TimeBase().fromString(menuSelectionFPS)) + + return + + # This handles events from the Project Bin View + def binViewEventHandler(self,event): + if not hasattr(event.sender, 'selection'): + # Something has gone wrong, we should only be here if raised + # by the Bin view which gives a selection. + return + + # Reset the selection to None... + self._selection = None + s = event.sender.selection() + + # Return if there's no Selection. We won't add the Menu. + if s == None: + return + # Filter the selection to BinItems + self._selection = [item for item in s if isinstance(item, hiero.core.BinItem)] + if len(self._selection)==0: + return + # Creating the menu based on items selected, to highlight which frame rates are contained + + self._frameRateMenu = self.createFrameRateMenus(self._selection) + + # Insert the Set Frame Rate Button before the Set Media Colour Transform Action + for action in event.menu.actions(): + if str(action.text()) == "Set Media Colour Transform": + event.menu.insertMenu(action, self._frameRateMenu) + break + +# Instantiate the Menu to get it to register itself. +SetFrameRateMenu = SetFrameRateMenu() \ No newline at end of file diff --git a/setup/nukestudio/hiero_plugin_path/Python/Startup/version_everywhere.py b/setup/nukestudio/hiero_plugin_path/Python/Startup/version_everywhere.py new file mode 100644 index 0000000000..e85e02bfa5 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/Startup/version_everywhere.py @@ -0,0 +1,352 @@ +# version_up_everywhere.py +# Adds action to enable a Clip/Shot to be Min/Max/Next/Prev versioned in all shots used in a Project. +# +# Usage: +# 1) Copy file to /Python/Startup +# 2) Right-click on Clip(s) or Bins containing Clips in in the Bin View, or on Shots in the Timeline/Spreadsheet +# 3) Set Version for all Shots > OPTION to update the version in all shots where the Clip is used in the Project. + +import hiero.core +try: + from PySide.QtGui import * + from PySide.QtCore import * +except: + from PySide2.QtGui import * + from PySide2.QtWidgets import * + from PySide2.QtCore import * + + +def whereAmI(self, searchType='TrackItem'): + """returns a list of TrackItem or Sequnece objects in the Project which contain this Clip. + By default this will return a list of TrackItems where the Clip is used in its project. + You can also return a list of Sequences by specifying the searchType to be 'Sequence'. + Should consider putting this into hiero.core.Clip by default? + + Example usage: + + shotsForClip = clip.whereAmI('TrackItem') + sequencesForClip = clip.whereAmI('Sequence') + """ + proj = self.project() + + if ('TrackItem' not in searchType) and ('Sequence' not in searchType): + print "searchType argument must be 'TrackItem' or 'Sequence'" + return None + + # If user specifies a TrackItem, then it will return + searches = hiero.core.findItemsInProject(proj, searchType) + + if len(searches) == 0: + print 'Unable to find %s in any items of type: %s' % (str(self), + str(searchType)) + return None + + # Case 1: Looking for Shots (trackItems) + clipUsedIn = [] + if isinstance(searches[0], hiero.core.TrackItem): + for shot in searches: + # We have to wrap this in a try/except because it's possible through the Python API for a Shot to exist without a Clip in the Bin + try: + + # For versioning to work, we must look to the BinItem that a Clip is wrapped in. + if shot.source().binItem() == self.binItem(): + clipUsedIn.append(shot) + + # If we throw an exception here its because the Shot did not have a Source Clip in the Bin. + except RuntimeError: + hiero.core.log.info( + 'Unable to find Parent Clip BinItem for Shot: %s, Source:%s' + % (shot, shot.source())) + pass + + # Case 1: Looking for Shots (trackItems) + elif isinstance(searches[0], hiero.core.Sequence): + for seq in searches: + # Iterate tracks > shots... + tracks = seq.items() + for track in tracks: + shots = track.items() + for shot in shots: + if shot.source().binItem() == self.binItem(): + clipUsedIn.append(seq) + + return clipUsedIn + + +# Add whereAmI method to Clip object +hiero.core.Clip.whereAmI = whereAmI + + +#### MAIN VERSION EVERYWHERE GUBBINS ##### +class VersionAllMenu(object): + + # These are a set of action names we can use for operating on multiple Clip/TrackItems + eMaxVersion = "Max Version" + eMinVersion = "Min Version" + eNextVersion = "Next Version" + ePreviousVersion = "Previous Version" + + # This is the title used for the Version Menu title. It's long isn't it? + actionTitle = "Set Version for all Shots" + + def __init__(self): + self._versionEverywhereMenu = None + self._versionActions = [] + + hiero.core.events.registerInterest("kShowContextMenu/kBin", + self.binViewEventHandler) + hiero.core.events.registerInterest("kShowContextMenu/kTimeline", + self.binViewEventHandler) + hiero.core.events.registerInterest("kShowContextMenu/kSpreadsheet", + self.binViewEventHandler) + + def showVersionUpdateReportFromShotManifest(self, sequenceShotManifest): + """This just displays an info Message box, based on a Sequence[Shot] manifest dictionary""" + + # Now present an info dialog, explaining where shots were updated + updateReportString = "The following Versions were updated:\n" + for seq in sequenceShotManifest.keys(): + updateReportString += "%s:\n Shots:\n" % (seq.name()) + for shot in sequenceShotManifest[seq]: + updateReportString += ' %s\n (New Version: %s)\n' % ( + shot.name(), shot.currentVersion().name()) + updateReportString += '\n' + + infoBox = QMessageBox(hiero.ui.mainWindow()) + infoBox.setIcon(QMessageBox.Information) + + if len(sequenceShotManifest) <= 0: + infoBox.setText("No Shot Versions were updated") + infoBox.setInformativeText( + "Clip could not be found in any Shots in this Project") + else: + infoBox.setText( + "Versions were updated in %i Sequences of this Project." % + (len(sequenceShotManifest))) + infoBox.setInformativeText("Show Details for more info.") + infoBox.setDetailedText(updateReportString) + + infoBox.exec_() + + def makeVersionActionForSingleClip(self, version): + """This is used to populate the QAction list of Versions when a single Clip is selected in the BinView. + It also triggers the Version Update action based on the version passed to it. + (Not sure if this is good design practice, but it's compact!)""" + action = QAction(version.name(), None) + action.setData(lambda: version) + + def updateAllTrackItems(): + currentClip = version.item() + trackItems = currentClip.whereAmI() + if not trackItems: + return + + proj = currentClip.project() + + # A Sequence-Shot manifest dictionary + sequenceShotManifest = {} + + # Make this all undo-able in a single Group undo + with proj.beginUndo( + "Update All Versions for %s" % currentClip.name()): + for shot in trackItems: + seq = shot.parentSequence() + if seq not in sequenceShotManifest.keys(): + sequenceShotManifest[seq] = [shot] + else: + sequenceShotManifest[seq] += [shot] + shot.setCurrentVersion(version) + + # We also should update the current Version of the selected Clip for completeness... + currentClip.binItem().setActiveVersion(version) + + # Now disaplay a Dialog which informs the user of where and what was changed + self.showVersionUpdateReportFromShotManifest(sequenceShotManifest) + + action.triggered.connect(updateAllTrackItems) + return action + + # This is just a convenience method for returning QActions with a title, triggered method and icon. + def makeAction(self, title, method, icon=None): + action = QAction(title, None) + action.setIcon(QIcon(icon)) + + # We do this magic, so that the title string from the action is used to trigger the version change + def methodWrapper(): + method(title) + + action.triggered.connect(methodWrapper) + return action + + def clipSelectionFromView(self, view): + """Helper method to return a list of Clips in the Active View""" + selection = hiero.ui.activeView().selection() + + if len(selection) == 0: + return None + + if isinstance(view, hiero.ui.BinView): + # We could have a mixture of Bins and Clips selected, so sort of the Clips and Clips inside Bins + clipItems = [ + item.activeItem() for item in selection + if hasattr(item, "activeItem") + and isinstance(item.activeItem(), hiero.core.Clip) + ] + + # We'll also append Bins here, and see if can find Clips inside + bins = [ + item for item in selection if isinstance(item, hiero.core.Bin) + ] + + # We search inside of a Bin for a Clip which is not already in clipBinItems + if len(bins) > 0: + # Grab the Clips inside of a Bin and append them to a list + for bin in bins: + clips = hiero.core.findItemsInBin(bin, 'Clip') + for clip in clips: + if clip not in clipItems: + clipItems.append(clip) + + elif isinstance(view, + (hiero.ui.TimelineEditor, hiero.ui.SpreadsheetView)): + # Here, we have shots. To get to the Clip froma TrackItem, just call source() + clipItems = [ + item.source() for item in selection if hasattr(item, "source") + and isinstance(item, hiero.core.TrackItem) + ] + + return clipItems + + # This generates the Version Up Everywhere menu + def createVersionEveryWhereMenuForView(self, view): + + versionEverywhereMenu = QMenu(self.actionTitle) + self._versionActions = [] + # We look to the activeView for a selection of Clips + clips = self.clipSelectionFromView(view) + + # And bail if nothing is found + if len(clips) == 0: + return versionEverywhereMenu + + # Now, if we have just one Clip selected, we'll form a special menu, which lists all versions + if len(clips) == 1: + + # Get a reversed list of Versions, so that bigger ones appear at top + versions = list(reversed(clips[0].binItem().items())) + for version in versions: + self._versionActions += [ + self.makeVersionActionForSingleClip(version) + ] + + elif len(clips) > 1: + # We will add Max/Min/Prev/Next options, which can be called on a TrackItem, without the need for a Version object + self._versionActions += [ + self.makeAction( + self.eMaxVersion, + self.setTrackItemVersionForClipSelection, + icon=None) + ] + self._versionActions += [ + self.makeAction( + self.eMinVersion, + self.setTrackItemVersionForClipSelection, + icon=None) + ] + self._versionActions += [ + self.makeAction( + self.eNextVersion, + self.setTrackItemVersionForClipSelection, + icon=None) + ] + self._versionActions += [ + self.makeAction( + self.ePreviousVersion, + self.setTrackItemVersionForClipSelection, + icon=None) + ] + + for act in self._versionActions: + versionEverywhereMenu.addAction(act) + + return versionEverywhereMenu + + def setTrackItemVersionForClipSelection(self, versionOption): + + view = hiero.ui.activeView() + if not view: + return + + clipSelection = self.clipSelectionFromView(view) + + if len(clipSelection) == 0: + return + + proj = clipSelection[0].project() + + # Create a Sequence-Shot Manifest, to report to users where a Shot was updated + sequenceShotManifest = {} + + with proj.beginUndo("Update multiple Versions"): + for clip in clipSelection: + + # Look to see if it exists in a TrackItem somewhere... + shotUsage = clip.whereAmI('TrackItem') + + # Next, depending on the versionOption, make the appropriate update + # There's probably a more neat/compact way of doing this... + for shot in shotUsage: + + # This step is done for reporting reasons + seq = shot.parentSequence() + if seq not in sequenceShotManifest.keys(): + sequenceShotManifest[seq] = [shot] + else: + sequenceShotManifest[seq] += [shot] + + if versionOption == self.eMaxVersion: + shot.maxVersion() + elif versionOption == self.eMinVersion: + shot.minVersion() + elif versionOption == self.eNextVersion: + shot.nextVersion() + elif versionOption == self.ePreviousVersion: + shot.prevVersion() + + # Finally, for completeness, set the Max/Min version of the Clip too (if chosen) + # Note: It doesn't make sense to do Next/Prev on a Clip here because next/prev means different things for different Shots + if versionOption == self.eMaxVersion: + clip.binItem().maxVersion() + elif versionOption == self.eMinVersion: + clip.binItem().minVersion() + + # Now disaplay a Dialog which informs the user of where and what was changed + self.showVersionUpdateReportFromShotManifest(sequenceShotManifest) + + # This handles events from the Project Bin View + def binViewEventHandler(self, event): + + if not hasattr(event.sender, 'selection'): + # Something has gone wrong, we should only be here if raised + # by the Bin view which gives a selection. + return + selection = event.sender.selection() + + # Return if there's no Selection. We won't add the Localise Menu. + if selection == None: + return + + view = hiero.ui.activeView() + # Only add the Menu if Bins or Sequences are selected (this ensures menu isn't added in the Tags Pane) + if len(selection) > 0: + self._versionEverywhereMenu = self.createVersionEveryWhereMenuForView( + view) + hiero.ui.insertMenuAction( + self._versionEverywhereMenu.menuAction(), + event.menu, + after="foundry.menu.version") + return + + +# Instantiate the Menu to get it to register itself. +VersionAllMenu = VersionAllMenu() diff --git a/setup/nukestudio/hiero_plugin_path/Python/StartupUI/PimpMySpreadsheet.py b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/PimpMySpreadsheet.py new file mode 100644 index 0000000000..3d40aa0293 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/PimpMySpreadsheet.py @@ -0,0 +1,844 @@ +# PimpMySpreadsheet 1.0, Antony Nasce, 23/05/13. +# Adds custom spreadsheet columns and right-click menu for setting the Shot Status, and Artist Shot Assignement. +# gStatusTags is a global dictionary of key(status)-value(icon) pairs, which can be overridden with custom icons if required +# Requires Hiero 1.7v2 or later. +# Install Instructions: Copy to ~/.hiero/Python/StartupUI + +import hiero.core +import hiero.ui + +try: + from PySide.QtGui import * + from PySide.QtCore import * +except: + from PySide2.QtGui import * + from PySide2.QtWidgets import * + from PySide2.QtCore import * + +# Set to True, if you wat 'Set Status' right-click menu, False if not +kAddStatusMenu = True + +# Set to True, if you wat 'Assign Artist' right-click menu, False if not +kAssignArtistMenu = True + +# Global list of Artist Name Dictionaries +# Note: Override this to add different names, icons, department, IDs. +gArtistList = [{ + 'artistName': 'John Smith', + 'artistIcon': 'icons:TagActor.png', + 'artistDepartment': '3D', + 'artistID': 0 +}, { + 'artistName': 'Savlvador Dali', + 'artistIcon': 'icons:TagActor.png', + 'artistDepartment': 'Roto', + 'artistID': 1 +}, { + 'artistName': 'Leonardo Da Vinci', + 'artistIcon': 'icons:TagActor.png', + 'artistDepartment': 'Paint', + 'artistID': 2 +}, { + 'artistName': 'Claude Monet', + 'artistIcon': 'icons:TagActor.png', + 'artistDepartment': 'Comp', + 'artistID': 3 +}, { + 'artistName': 'Pablo Picasso', + 'artistIcon': 'icons:TagActor.png', + 'artistDepartment': 'Animation', + 'artistID': 4 +}] + +# Global Dictionary of Status Tags. +# Note: This can be overwritten if you want to add a new status cellType or custom icon +# Override the gStatusTags dictionary by adding your own 'Status':'Icon.png' key-value pairs. +# Add new custom keys like so: gStatusTags['For Client'] = 'forClient.png' +gStatusTags = { + 'Approved': 'icons:status/TagApproved.png', + 'Unapproved': 'icons:status/TagUnapproved.png', + 'Ready To Start': 'icons:status/TagReadyToStart.png', + 'Blocked': 'icons:status/TagBlocked.png', + 'On Hold': 'icons:status/TagOnHold.png', + 'In Progress': 'icons:status/TagInProgress.png', + 'Awaiting Approval': 'icons:status/TagAwaitingApproval.png', + 'Omitted': 'icons:status/TagOmitted.png', + 'Final': 'icons:status/TagFinal.png' +} + + +# The Custom Spreadsheet Columns +class CustomSpreadsheetColumns(QObject): + """ + A class defining custom columns for Hiero's spreadsheet view. This has a similar, but + slightly simplified, interface to the QAbstractItemModel and QItemDelegate classes. + """ + global gStatusTags + global gArtistList + + # Ideally, we'd set this list on a Per Item basis, but this is expensive for a large mixed selection + standardColourSpaces = [ + 'linear', 'sRGB', 'rec709', 'Cineon', 'Gamma1.8', 'Gamma2.2', + 'Panalog', 'REDLog', 'ViperLog' + ] + arriColourSpaces = [ + 'Video - Rec709', 'LogC - Camera Native', 'Video - P3', 'ACES', + 'LogC - Film', 'LogC - Wide Gamut' + ] + r3dColourSpaces = [ + 'Linear', 'Rec709', 'REDspace', 'REDlog', 'PDlog685', 'PDlog985', + 'CustomPDlog', 'REDgamma', 'SRGB', 'REDlogFilm', 'REDgamma2', + 'REDgamma3' + ] + gColourSpaces = standardColourSpaces + arriColourSpaces + r3dColourSpaces + + currentView = hiero.ui.activeView() + + # This is the list of Columns available + gCustomColumnList = [ + { + 'name': 'Tags', + 'cellType': 'readonly' + }, + { + 'name': 'Colourspace', + 'cellType': 'dropdown' + }, + { + 'name': 'Notes', + 'cellType': 'readonly' + }, + { + 'name': 'FileType', + 'cellType': 'readonly' + }, + { + 'name': 'Shot Status', + 'cellType': 'dropdown' + }, + { + 'name': 'Thumbnail', + 'cellType': 'readonly' + }, + { + 'name': 'MediaType', + 'cellType': 'readonly' + }, + { + 'name': 'Width', + 'cellType': 'readonly' + }, + { + 'name': 'Height', + 'cellType': 'readonly' + }, + { + 'name': 'Pixel Aspect', + 'cellType': 'readonly' + }, + { + 'name': 'Artist', + 'cellType': 'dropdown' + }, + { + 'name': 'Department', + 'cellType': 'readonly' + }, + ] + + def numColumns(self): + """ + Return the number of custom columns in the spreadsheet view + """ + return len(self.gCustomColumnList) + + def columnName(self, column): + """ + Return the name of a custom column + """ + return self.gCustomColumnList[column]['name'] + + def getTagsString(self, item): + """ + Convenience method for returning all the Notes in a Tag as a string + """ + tagNames = [] + tags = item.tags() + for tag in tags: + tagNames += [tag.name()] + tagNameString = ','.join(tagNames) + return tagNameString + + def getNotes(self, item): + """ + Convenience method for returning all the Notes in a Tag as a string + """ + notes = '' + tags = item.tags() + for tag in tags: + note = tag.note() + if len(note) > 0: + notes += tag.note() + ', ' + return notes[:-2] + + def getData(self, row, column, item): + """ + Return the data in a cell + """ + currentColumn = self.gCustomColumnList[column] + if currentColumn['name'] == 'Tags': + return self.getTagsString(item) + + if currentColumn['name'] == 'Colourspace': + try: + colTransform = item.sourceMediaColourTransform() + except: + colTransform = '--' + return colTransform + + if currentColumn['name'] == 'Notes': + try: + note = self.getNotes(item) + except: + note = '' + return note + + if currentColumn['name'] == 'FileType': + fileType = '--' + M = item.source().mediaSource().metadata() + if M.hasKey('foundry.source.type'): + fileType = M.value('foundry.source.type') + elif M.hasKey('media.input.filereader'): + fileType = M.value('media.input.filereader') + return fileType + + if currentColumn['name'] == 'Shot Status': + status = item.status() + if not status: + status = "--" + return str(status) + + if currentColumn['name'] == 'MediaType': + M = item.mediaType() + return str(M).split('MediaType')[-1].replace('.k', '') + + if currentColumn['name'] == 'Thumbnail': + return str(item.eventNumber()) + + if currentColumn['name'] == 'Width': + return str(item.source().format().width()) + + if currentColumn['name'] == 'Height': + return str(item.source().format().height()) + + if currentColumn['name'] == 'Pixel Aspect': + return str(item.source().format().pixelAspect()) + + if currentColumn['name'] == 'Artist': + if item.artist(): + name = item.artist()['artistName'] + return name + else: + return '--' + + if currentColumn['name'] == 'Department': + if item.artist(): + dep = item.artist()['artistDepartment'] + return dep + else: + return '--' + + return "" + + def setData(self, row, column, item, data): + """ + Set the data in a cell - unused in this example + """ + + return None + + def getTooltip(self, row, column, item): + """ + Return the tooltip for a cell + """ + currentColumn = self.gCustomColumnList[column] + if currentColumn['name'] == 'Tags': + return str([item.name() for item in item.tags()]) + + if currentColumn['name'] == 'Notes': + return str(self.getNotes(item)) + return "" + + def getFont(self, row, column, item): + """ + Return the tooltip for a cell + """ + return None + + def getBackground(self, row, column, item): + """ + Return the background colour for a cell + """ + if not item.source().mediaSource().isMediaPresent(): + return QColor(80, 20, 20) + return None + + def getForeground(self, row, column, item): + """ + Return the text colour for a cell + """ + #if column == 1: + # return QColor(255, 64, 64) + return None + + def getIcon(self, row, column, item): + """ + Return the icon for a cell + """ + currentColumn = self.gCustomColumnList[column] + if currentColumn['name'] == 'Colourspace': + return QIcon("icons:LUT.png") + + if currentColumn['name'] == 'Shot Status': + status = item.status() + if status: + return QIcon(gStatusTags[status]) + + if currentColumn['name'] == 'MediaType': + mediaType = item.mediaType() + if mediaType == hiero.core.TrackItem.kVideo: + return QIcon("icons:VideoOnly.png") + elif mediaType == hiero.core.TrackItem.kAudio: + return QIcon("icons:AudioOnly.png") + + if currentColumn['name'] == 'Artist': + try: + return QIcon(item.artist()['artistIcon']) + except: + return None + return None + + def getSizeHint(self, row, column, item): + """ + Return the size hint for a cell + """ + currentColumnName = self.gCustomColumnList[column]['name'] + + if currentColumnName == 'Thumbnail': + return QSize(90, 50) + + return QSize(50, 50) + + def paintCell(self, row, column, item, painter, option): + """ + Paint a custom cell. Return True if the cell was painted, or False to continue + with the default cell painting. + """ + currentColumn = self.gCustomColumnList[column] + if currentColumn['name'] == 'Tags': + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + iconSize = 20 + r = QRect(option.rect.x(), + option.rect.y() + (option.rect.height() - iconSize) / 2, + iconSize, iconSize) + tags = item.tags() + if len(tags) > 0: + painter.save() + painter.setClipRect(option.rect) + for tag in item.tags(): + M = tag.metadata() + if not (M.hasKey('tag.status') + or M.hasKey('tag.artistID')): + QIcon(tag.icon()).paint(painter, r, Qt.AlignLeft) + r.translate(r.width() + 2, 0) + painter.restore() + return True + + if currentColumn['name'] == 'Thumbnail': + imageView = None + pen = QPen() + r = QRect(option.rect.x() + 2, (option.rect.y() + + (option.rect.height() - 46) / 2), + 85, 46) + if not item.source().mediaSource().isMediaPresent(): + imageView = QImage("icons:Offline.png") + pen.setColor(QColor(Qt.red)) + + if item.mediaType() == hiero.core.TrackItem.MediaType.kAudio: + imageView = QImage("icons:AudioOnly.png") + #pen.setColor(QColor(Qt.green)) + painter.fillRect(r, QColor(45, 59, 45)) + + if option.state & QStyle.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + + tags = item.tags() + painter.save() + painter.setClipRect(option.rect) + + if not imageView: + try: + imageView = item.thumbnail(item.sourceIn()) + pen.setColor(QColor(20, 20, 20)) + # If we're here, we probably have a TC error, no thumbnail, so get it from the source Clip... + except: + pen.setColor(QColor(Qt.red)) + + if not imageView: + try: + imageView = item.source().thumbnail() + pen.setColor(QColor(Qt.yellow)) + except: + imageView = QImage("icons:Offline.png") + pen.setColor(QColor(Qt.red)) + + QIcon(QPixmap.fromImage(imageView)).paint(painter, r, + Qt.AlignCenter) + painter.setPen(pen) + painter.drawRoundedRect(r, 1, 1) + painter.restore() + return True + + return False + + def createEditor(self, row, column, item, view): + """ + Create an editing widget for a custom cell + """ + self.currentView = view + + currentColumn = self.gCustomColumnList[column] + if currentColumn['cellType'] == 'readonly': + cle = QLabel() + cle.setEnabled(False) + cle.setVisible(False) + return cle + + if currentColumn['name'] == 'Colourspace': + cb = QComboBox() + for colourspace in self.gColourSpaces: + cb.addItem(colourspace) + cb.currentIndexChanged.connect(self.colourspaceChanged) + return cb + + if currentColumn['name'] == 'Shot Status': + cb = QComboBox() + cb.addItem('') + for key in gStatusTags.keys(): + cb.addItem(QIcon(gStatusTags[key]), key) + cb.addItem('--') + cb.currentIndexChanged.connect(self.statusChanged) + + return cb + + if currentColumn['name'] == 'Artist': + cb = QComboBox() + cb.addItem('') + for artist in gArtistList: + cb.addItem(artist['artistName']) + cb.addItem('--') + cb.currentIndexChanged.connect(self.artistNameChanged) + return cb + return None + + def setModelData(self, row, column, item, editor): + return False + + def dropMimeData(self, row, column, item, data, items): + """ + Handle a drag and drop operation - adds a Dragged Tag to the shot + """ + for thing in items: + if isinstance(thing, hiero.core.Tag): + item.addTag(thing) + return None + + def colourspaceChanged(self, index): + """ + This method is called when Colourspace widget changes index. + """ + index = self.sender().currentIndex() + colourspace = self.gColourSpaces[index] + selection = self.currentView.selection() + project = selection[0].project() + with project.beginUndo("Set Colourspace"): + items = [ + item for item in selection + if (item.mediaType() == hiero.core.TrackItem.MediaType.kVideo) + ] + for trackItem in items: + trackItem.setSourceMediaColourTransform(colourspace) + + def statusChanged(self, arg): + """ + This method is called when Shot Status widget changes index. + """ + view = hiero.ui.activeView() + selection = view.selection() + status = self.sender().currentText() + project = selection[0].project() + with project.beginUndo("Set Status"): + # A string of '--' characters denotes clear the status + if status != '--': + for trackItem in selection: + trackItem.setStatus(status) + else: + for trackItem in selection: + tTags = trackItem.tags() + for tag in tTags: + if tag.metadata().hasKey('tag.status'): + trackItem.removeTag(tag) + break + + def artistNameChanged(self, arg): + """ + This method is called when Artist widget changes index. + """ + view = hiero.ui.activeView() + selection = view.selection() + name = self.sender().currentText() + project = selection[0].project() + with project.beginUndo("Assign Artist"): + # A string of '--' denotes clear the assignee... + if name != '--': + for trackItem in selection: + trackItem.setArtistByName(name) + else: + for trackItem in selection: + tTags = trackItem.tags() + for tag in tTags: + if tag.metadata().hasKey('tag.artistID'): + trackItem.removeTag(tag) + break + + +def _getArtistFromID(self, artistID): + """ getArtistFromID -> returns an artist dictionary, by their given ID""" + global gArtistList + artist = [ + element for element in gArtistList + if element['artistID'] == int(artistID) + ] + if not artist: + return None + return artist[0] + + +def _getArtistFromName(self, artistName): + """ getArtistFromID -> returns an artist dictionary, by their given ID """ + global gArtistList + artist = [ + element for element in gArtistList + if element['artistName'] == artistName + ] + if not artist: + return None + return artist[0] + + +def _artist(self): + """_artist -> Returns the artist dictionary assigned to this shot""" + artist = None + tags = self.tags() + for tag in tags: + if tag.metadata().hasKey('tag.artistID'): + artistID = tag.metadata().value('tag.artistID') + artist = self.getArtistFromID(artistID) + return artist + + +def _updateArtistTag(self, artistDict): + # A shot will only have one artist assigned. Check if one exists and set accordingly + + artistTag = None + tags = self.tags() + for tag in tags: + if tag.metadata().hasKey('tag.artistID'): + artistTag = tag + break + + if not artistTag: + artistTag = hiero.core.Tag('Artist') + artistTag.setIcon(artistDict['artistIcon']) + artistTag.metadata().setValue('tag.artistID', + str(artistDict['artistID'])) + artistTag.metadata().setValue('tag.artistName', + str(artistDict['artistName'])) + artistTag.metadata().setValue('tag.artistDepartment', + str(artistDict['artistDepartment'])) + self.sequence().editFinished() + self.addTag(artistTag) + self.sequence().editFinished() + return + + artistTag.setIcon(artistDict['artistIcon']) + artistTag.metadata().setValue('tag.artistID', str(artistDict['artistID'])) + artistTag.metadata().setValue('tag.artistName', + str(artistDict['artistName'])) + artistTag.metadata().setValue('tag.artistDepartment', + str(artistDict['artistDepartment'])) + self.sequence().editFinished() + return + + +def _setArtistByName(self, artistName): + """ setArtistByName(artistName) -> sets the artist tag on a TrackItem by a given artistName string""" + global gArtistList + + artist = self.getArtistFromName(artistName) + if not artist: + print 'Artist name: %s was not found in the gArtistList.' % str( + artistName) + return + + # Do the update. + self.updateArtistTag(artist) + + +def _setArtistByID(self, artistID): + """ setArtistByID(artistID) -> sets the artist tag on a TrackItem by a given artistID integer""" + global gArtistList + + artist = self.getArtistFromID(artistID) + if not artist: + print 'Artist name: %s was not found in the gArtistList.' % str( + artistID) + return + + # Do the update. + self.updateArtistTag(artist) + + +# Inject status getter and setter methods into hiero.core.TrackItem +hiero.core.TrackItem.artist = _artist +hiero.core.TrackItem.setArtistByName = _setArtistByName +hiero.core.TrackItem.setArtistByID = _setArtistByID +hiero.core.TrackItem.getArtistFromName = _getArtistFromName +hiero.core.TrackItem.getArtistFromID = _getArtistFromID +hiero.core.TrackItem.updateArtistTag = _updateArtistTag + + +def _status(self): + """status -> Returns the Shot status. None if no Status is set.""" + + status = None + tags = self.tags() + for tag in tags: + if tag.metadata().hasKey('tag.status'): + status = tag.metadata().value('tag.status') + return status + + +def _setStatus(self, status): + """setShotStatus(status) -> Method to set the Status of a Shot. + Adds a special kind of status Tag to a TrackItem + Example: myTrackItem.setStatus('Final') + + @param status - a string, corresponding to the Status name + """ + global gStatusTags + + # Get a valid Tag object from the Global list of statuses + if not status in gStatusTags.keys(): + print 'Status requested was not a valid Status string.' + return + + # A shot should only have one status. Check if one exists and set accordingly + statusTag = None + tags = self.tags() + for tag in tags: + if tag.metadata().hasKey('tag.status'): + statusTag = tag + break + + if not statusTag: + statusTag = hiero.core.Tag('Status') + statusTag.setIcon(gStatusTags[status]) + statusTag.metadata().setValue('tag.status', status) + self.addTag(statusTag) + + statusTag.setIcon(gStatusTags[status]) + statusTag.metadata().setValue('tag.status', status) + + self.sequence().editFinished() + return + + +# Inject status getter and setter methods into hiero.core.TrackItem +hiero.core.TrackItem.setStatus = _setStatus +hiero.core.TrackItem.status = _status + + +# This is a convenience method for returning QActions with a triggered method based on the title string +def titleStringTriggeredAction(title, method, icon=None): + action = QAction(title, None) + action.setIcon(QIcon(icon)) + + # We do this magic, so that the title string from the action is used to set the status + def methodWrapper(): + method(title) + + action.triggered.connect(methodWrapper) + return action + + +# Menu which adds a Set Status Menu to Timeline and Spreadsheet Views +class SetStatusMenu(QMenu): + def __init__(self): + QMenu.__init__(self, "Set Status", None) + + global gStatusTags + self.statuses = gStatusTags + self._statusActions = self.createStatusMenuActions() + + # Add the Actions to the Menu. + for act in self.menuActions: + self.addAction(act) + + hiero.core.events.registerInterest("kShowContextMenu/kTimeline", + self.eventHandler) + hiero.core.events.registerInterest("kShowContextMenu/kSpreadsheet", + self.eventHandler) + + def createStatusMenuActions(self): + self.menuActions = [] + for status in self.statuses: + self.menuActions += [ + titleStringTriggeredAction( + status, + self.setStatusFromMenuSelection, + icon=gStatusTags[status]) + ] + + def setStatusFromMenuSelection(self, menuSelectionStatus): + selectedShots = [ + item for item in self._selection + if (isinstance(item, hiero.core.TrackItem)) + ] + selectedTracks = [ + item for item in self._selection + if (isinstance(item, (hiero.core.VideoTrack, + hiero.core.AudioTrack))) + ] + + # If we have a Track Header Selection, no shots could be selected, so create shotSelection list + if len(selectedTracks) >= 1: + for track in selectedTracks: + selectedShots += [ + item for item in track.items() + if (isinstance(item, hiero.core.TrackItem)) + ] + + # It's possible no shots exist on the Track, in which case nothing is required + if len(selectedShots) == 0: + return + + currentProject = selectedShots[0].project() + + with currentProject.beginUndo("Set Status"): + # Shots selected + for shot in selectedShots: + shot.setStatus(menuSelectionStatus) + + # This handles events from the Project Bin View + def eventHandler(self, event): + if not hasattr(event.sender, 'selection'): + # Something has gone wrong, we should only be here if raised + # by the Timeline/Spreadsheet view which gives a selection. + return + + # Set the current selection + self._selection = event.sender.selection() + + # Return if there's no Selection. We won't add the Menu. + if len(self._selection) == 0: + return + + event.menu.addMenu(self) + + +# Menu which adds a Set Status Menu to Timeline and Spreadsheet Views +class AssignArtistMenu(QMenu): + def __init__(self): + QMenu.__init__(self, "Assign Artist", None) + + global gArtistList + self.artists = gArtistList + self._artistsActions = self.createAssignArtistMenuActions() + + # Add the Actions to the Menu. + for act in self.menuActions: + self.addAction(act) + + hiero.core.events.registerInterest("kShowContextMenu/kTimeline", + self.eventHandler) + hiero.core.events.registerInterest("kShowContextMenu/kSpreadsheet", + self.eventHandler) + + def createAssignArtistMenuActions(self): + self.menuActions = [] + for artist in self.artists: + self.menuActions += [ + titleStringTriggeredAction( + artist['artistName'], + self.setArtistFromMenuSelection, + icon=artist['artistIcon']) + ] + + def setArtistFromMenuSelection(self, menuSelectionArtist): + selectedShots = [ + item for item in self._selection + if (isinstance(item, hiero.core.TrackItem)) + ] + selectedTracks = [ + item for item in self._selection + if (isinstance(item, (hiero.core.VideoTrack, + hiero.core.AudioTrack))) + ] + + # If we have a Track Header Selection, no shots could be selected, so create shotSelection list + if len(selectedTracks) >= 1: + for track in selectedTracks: + selectedShots += [ + item for item in track.items() + if (isinstance(item, hiero.core.TrackItem)) + ] + + # It's possible no shots exist on the Track, in which case nothing is required + if len(selectedShots) == 0: + return + + currentProject = selectedShots[0].project() + + with currentProject.beginUndo("Assign Artist"): + # Shots selected + for shot in selectedShots: + shot.setArtistByName(menuSelectionArtist) + + # This handles events from the Project Bin View + def eventHandler(self, event): + if not hasattr(event.sender, 'selection'): + # Something has gone wrong, we should only be here if raised + # by the Timeline/Spreadsheet view which gives a selection. + return + + # Set the current selection + self._selection = event.sender.selection() + + # Return if there's no Selection. We won't add the Menu. + if len(self._selection) == 0: + return + + event.menu.addMenu(self) + + +# Add the 'Set Status' context menu to Timeline and Spreadsheet +if kAddStatusMenu: + setStatusMenu = SetStatusMenu() + +if kAssignArtistMenu: + assignArtistMenu = AssignArtistMenu() + +# Register our custom columns +hiero.ui.customColumn = CustomSpreadsheetColumns() diff --git a/setup/nukestudio/hiero_plugin_path/Python/StartupUI/Purge.py b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/Purge.py new file mode 100644 index 0000000000..4d2ab255ad --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/Purge.py @@ -0,0 +1,142 @@ +# Purge Unused Clips - Removes any unused Clips from a Project +# Usage: Copy to ~/.hiero/Python/StartupUI +# Demonstrates the use of hiero.core.find_items module. +# Usage: Right-click on an item in the Bin View > "Purge Unused Clips" +# Result: Any Clips not used in a Sequence in the active project will be removed +# Requires Hiero 1.5v1 or later. +# Version 1.1 + +import hiero +import hiero.core.find_items +try: + from PySide.QtGui import * + from PySide.QtCore import * +except: + from PySide2.QtGui import * + from PySide2.QtWidgets import * + from PySide2.QtCore import * + + +class PurgeUnusedAction(QAction): + def __init__(self): + QAction.__init__(self, "Purge Unused Clips", None) + self.triggered.connect(self.PurgeUnused) + hiero.core.events.registerInterest("kShowContextMenu/kBin", + self.eventHandler) + self.setIcon(QIcon('icons:TagDelete.png')) + + # Method to return whether a Bin is empty... + def binIsEmpty(self, b): + numBinItems = 0 + bItems = b.items() + empty = False + + if len(bItems) == 0: + empty = True + return empty + else: + for b in bItems: + if isinstance(b, hiero.core.BinItem) or isinstance( + b, hiero.core.Bin): + numBinItems += 1 + if numBinItems == 0: + empty = True + + return empty + + def PurgeUnused(self): + + #Get selected items + item = self.selectedItem + proj = item.project() + + # Build a list of Projects + SEQS = hiero.core.findItems(proj, "Sequences") + + # Build a list of Clips + CLIPSTOREMOVE = hiero.core.findItems(proj, "Clips") + + if len(SEQS) == 0: + # Present Dialog Asking if User wants to remove Clips + msgBox = QMessageBox() + msgBox.setText("Purge Unused Clips") + msgBox.setInformativeText( + "You have no Sequences in this Project. Do you want to remove all Clips (%i) from Project: %s?" + % (len(CLIPSTOREMOVE), proj.name())) + msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + msgBox.setDefaultButton(QMessageBox.Ok) + ret = msgBox.exec_() + if ret == QMessageBox.Cancel: + print 'Not purging anything.' + elif ret == QMessageBox.Ok: + with proj.beginUndo('Purge Unused Clips'): + BINS = [] + for clip in CLIPSTOREMOVE: + BI = clip.binItem() + B = BI.parentBin() + BINS += [B] + print 'Removing:', BI + try: + B.removeItem(BI) + except: + print 'Unable to remove: ' + BI + return + + # For each sequence, iterate through each track Item, see if the Clip is in the CLIPS list. + # Remaining items in CLIPS will be removed + + for seq in SEQS: + + #Loop through selected and make folders + for track in seq: + for trackitem in track: + + if trackitem.source() in CLIPSTOREMOVE: + CLIPSTOREMOVE.remove(trackitem.source()) + + # Present Dialog Asking if User wants to remove Clips + msgBox = QMessageBox() + msgBox.setText("Purge Unused Clips") + msgBox.setInformativeText("Remove %i unused Clips from Project %s?" % + (len(CLIPSTOREMOVE), proj.name())) + msgBox.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) + msgBox.setDefaultButton(QMessageBox.Ok) + ret = msgBox.exec_() + + if ret == QMessageBox.Cancel: + print 'Cancel' + return + elif ret == QMessageBox.Ok: + BINS = [] + with proj.beginUndo('Purge Unused Clips'): + # Delete the rest of the Clips + for clip in CLIPSTOREMOVE: + BI = clip.binItem() + B = BI.parentBin() + BINS += [B] + print 'Removing:', BI + try: + B.removeItem(BI) + except: + print 'Unable to remove: ' + BI + + def eventHandler(self, event): + if not hasattr(event.sender, 'selection'): + # Something has gone wrong, we shouldn't only be here if raised + # by the Bin view which will give a selection. + return + + self.selectedItem = None + s = event.sender.selection() + + if len(s) >= 1: + self.selectedItem = s[0] + title = "Purge Unused Clips" + self.setText(title) + event.menu.addAction(self) + + return + + +# Instantiate the action to get it to register itself. +PurgeUnusedAction = PurgeUnusedAction() diff --git a/setup/nukestudio/hiero_plugin_path/Python/StartupUI/nukeStyleKeyboardShortcuts.py b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/nukeStyleKeyboardShortcuts.py new file mode 100644 index 0000000000..41c192ab15 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/nukeStyleKeyboardShortcuts.py @@ -0,0 +1,33 @@ +# nukeStyleKeyboardShortcuts, v1, 30/07/2012, Ant Nasce. +# A few Nuke-Style File menu shortcuts for those whose muscle memory has set in... +# Usage: Copy this file to ~/.hiero/Python/StartupUI/ + +import hiero.ui +try: + from PySide.QtGui import * + from PySide.QtCore import * +except: + from PySide2.QtGui import * + from PySide2.QtWidgets import * + from PySide2.QtCore import * + +#---------------------------------------------- +a = hiero.ui.findMenuAction('Import File(s)...') +# Note: You probably best to make this 'Ctrl+R' - currently conflicts with 'Red' in the Viewer! +a.setShortcut(QKeySequence('R')) +#---------------------------------------------- +a = hiero.ui.findMenuAction('Import Folder(s)...') +a.setShortcut(QKeySequence('Shift+R')) +#---------------------------------------------- +a = hiero.ui.findMenuAction('Import EDL/XML/AAF...') +a.setShortcut(QKeySequence('Ctrl+Shift+O')) +#---------------------------------------------- +a = hiero.ui.findMenuAction('Metadata View') +a.setShortcut(QKeySequence('I')) +#---------------------------------------------- +a = hiero.ui.findMenuAction('Edit Settings') +a.setShortcut(QKeySequence('S')) +#---------------------------------------------- +a = hiero.ui.findMenuAction('Monitor Output') +a.setShortcut(QKeySequence('Ctrl+U')) +#---------------------------------------------- diff --git a/setup/nukestudio/hiero_plugin_path/Python/StartupUI/otioimporter/OTIOImport.py b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/otioimporter/OTIOImport.py new file mode 100644 index 0000000000..f506333a67 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/otioimporter/OTIOImport.py @@ -0,0 +1,435 @@ +# MIT License +# +# Copyright (c) 2018 Daniel Flehner Heen (Storm Studios) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import os +import sys +import hiero.core +import hiero.ui + +try: + from urllib import unquote + +except ImportError: + from urllib.parse import unquote # lint:ok + +import opentimelineio as otio + + +def get_transition_type(otio_item, otio_track): + _in, _out = otio_track.neighbors_of(otio_item) + + if isinstance(_in, otio.schema.Gap): + _in = None + + if isinstance(_out, otio.schema.Gap): + _out = None + + if _in and _out: + return 'dissolve' + + elif _in and not _out: + return 'fade_out' + + elif not _in and _out: + return 'fade_in' + + else: + return 'unknown' + + +def find_trackitem(name, hiero_track): + for item in hiero_track.items(): + if item.name() == name: + return item + + return None + + +def get_neighboring_trackitems(otio_item, otio_track, hiero_track): + _in, _out = otio_track.neighbors_of(otio_item) + trackitem_in = None + trackitem_out = None + + if _in: + trackitem_in = find_trackitem(_in.name, hiero_track) + + if _out: + trackitem_out = find_trackitem(_out.name, hiero_track) + + return trackitem_in, trackitem_out + + +def apply_transition(otio_track, otio_item, track): + # Figure out type of transition + transition_type = get_transition_type(otio_item, otio_track) + + # Figure out track kind for getattr below + if isinstance(track, hiero.core.VideoTrack): + kind = '' + + else: + kind = 'Audio' + + try: + # Gather TrackItems involved in trasition + item_in, item_out = get_neighboring_trackitems( + otio_item, + otio_track, + track + ) + + # Create transition object + if transition_type == 'dissolve': + transition_func = getattr( + hiero.core.Transition, + 'create{kind}DissolveTransition'.format(kind=kind) + ) + + transition = transition_func( + item_in, + item_out, + otio_item.in_offset.value, + otio_item.out_offset.value + ) + + elif transition_type == 'fade_in': + transition_func = getattr( + hiero.core.Transition, + 'create{kind}FadeInTransition'.format(kind=kind) + ) + transition = transition_func( + item_out, + otio_item.out_offset.value + ) + + elif transition_type == 'fade_out': + transition_func = getattr( + hiero.core.Transition, + 'create{kind}FadeOutTransition'.format(kind=kind) + ) + transition = transition_func( + item_in, + otio_item.in_offset.value + ) + + else: + # Unknown transition + return + + # Apply transition to track + track.addTransition(transition) + + except Exception, e: + sys.stderr.write( + 'Unable to apply transition "{t}": "{e}"\n'.format( + t=otio_item, + e=e + ) + ) + + +def prep_url(url_in): + url = unquote(url_in) + + if url.startswith('file://localhost/'): + return url.replace('file://localhost/', '') + + url = '{url}'.format( + sep=url.startswith(os.sep) and '' or os.sep, + url=url.startswith(os.sep) and url[1:] or url + ) + + return url + + +def create_offline_mediasource(otio_clip, path=None): + hiero_rate = hiero.core.TimeBase( + otio_clip.source_range.start_time.rate + ) + + if isinstance(otio_clip.media_reference, otio.schema.ExternalReference): + source_range = otio_clip.available_range() + + else: + source_range = otio_clip.source_range + + if path is None: + path = otio_clip.name + + media = hiero.core.MediaSource.createOfflineVideoMediaSource( + prep_url(path), + source_range.start_time.value, + source_range.duration.value, + hiero_rate, + source_range.start_time.value + ) + + return media + + +def load_otio(otio_file): + otio_timeline = otio.adapters.read_from_file(otio_file) + build_sequence(otio_timeline) + + +marker_color_map = { + "PINK": "Magenta", + "RED": "Red", + "ORANGE": "Yellow", + "YELLOW": "Yellow", + "GREEN": "Green", + "CYAN": "Cyan", + "BLUE": "Blue", + "PURPLE": "Magenta", + "MAGENTA": "Magenta", + "BLACK": "Blue", + "WHITE": "Green" +} + + +def get_tag(tagname, tagsbin): + for tag in tagsbin.items(): + if tag.name() == tagname: + return tag + + if isinstance(tag, hiero.core.Bin): + tag = get_tag(tagname, tag) + + if tag is not None: + return tag + + return None + + +def add_metadata(metadata, hiero_item): + for key, value in metadata.items(): + if isinstance(value, dict): + add_metadata(value, hiero_item) + continue + + if value is not None: + if not key.startswith('tag.'): + key = 'tag.' + key + + hiero_item.metadata().setValue(key, str(value)) + + +def add_markers(otio_item, hiero_item, tagsbin): + if isinstance(otio_item, (otio.schema.Stack, otio.schema.Clip)): + markers = otio_item.markers + + elif isinstance(otio_item, otio.schema.Timeline): + markers = otio_item.tracks.markers + + else: + markers = [] + + for marker in markers: + marker_color = marker.color + + _tag = get_tag(marker.name, tagsbin) + if _tag is None: + _tag = get_tag(marker_color_map[marker_color], tagsbin) + + if _tag is None: + _tag = hiero.core.Tag(marker_color_map[marker.color]) + + start = marker.marked_range.start_time.value + end = ( + marker.marked_range.start_time.value + + marker.marked_range.duration.value + ) + + tag = hiero_item.addTagToRange(_tag, start, end) + tag.setName(marker.name or marker_color_map[marker_color]) + + # Add metadata + add_metadata(marker.metadata, tag) + + +def create_track(otio_track, tracknum, track_kind): + # Add track kind when dealing with nested stacks + if isinstance(otio_track, otio.schema.Stack): + otio_track.kind = track_kind + + # Create a Track + if otio_track.kind == otio.schema.TrackKind.Video: + track = hiero.core.VideoTrack( + otio_track.name or 'Video{n}'.format(n=tracknum) + ) + + else: + track = hiero.core.AudioTrack( + otio_track.name or 'Audio{n}'.format(n=tracknum) + ) + + return track + + +def create_clip(otio_clip, tagsbin): + # Create MediaSource + otio_media = otio_clip.media_reference + if isinstance(otio_media, otio.schema.ExternalReference): + url = prep_url(otio_media.target_url) + media = hiero.core.MediaSource(url) + if media.isOffline(): + media = create_offline_mediasource(otio_clip, url) + + else: + media = create_offline_mediasource(otio_clip) + + # Create Clip + clip = hiero.core.Clip(media) + + # Add markers + add_markers(otio_clip, clip, tagsbin) + + return clip + + +def create_trackitem(playhead, track, otio_clip, clip): + source_range = otio_clip.source_range + + trackitem = track.createTrackItem(otio_clip.name) + trackitem.setPlaybackSpeed(source_range.start_time.rate) + trackitem.setSource(clip) + + # Check for speed effects and adjust playback speed accordingly + for effect in otio_clip.effects: + if isinstance(effect, otio.schema.LinearTimeWarp): + trackitem.setPlaybackSpeed( + trackitem.playbackSpeed() * + effect.time_scalar + ) + + # If reverse playback speed swap source in and out + if trackitem.playbackSpeed() < 0: + source_out = source_range.start_time.value + source_in = ( + source_range.start_time.value + + source_range.duration.value + ) - 1 + timeline_in = playhead + source_out + timeline_out = ( + timeline_in + + source_range.duration.value + ) - 1 + else: + # Normal playback speed + source_in = source_range.start_time.value + source_out = ( + source_range.start_time.value + + source_range.duration.value + ) - 1 + timeline_in = playhead + timeline_out = ( + timeline_in + + source_range.duration.value + ) - 1 + + # Set source and timeline in/out points + trackitem.setSourceIn(source_in) + trackitem.setSourceOut(source_out) + trackitem.setTimelineIn(timeline_in) + trackitem.setTimelineOut(timeline_out) + + return trackitem + + +def build_sequence(otio_timeline, project=None, track_kind=None): + if project is None: + # TODO: Find a proper way for active project + project = hiero.core.projects(hiero.core.Project.kUserProjects)[-1] + + # Create a Sequence + sequence = hiero.core.Sequence(otio_timeline.name or 'OTIOSequence') + + # Create a Bin to hold clips + projectbin = project.clipsBin() + projectbin.addItem(hiero.core.BinItem(sequence)) + sequencebin = hiero.core.Bin(sequence.name()) + projectbin.addItem(sequencebin) + + # Get tagsBin + tagsbin = hiero.core.project("Tag Presets").tagsBin() + + # Add timeline markers + add_markers(otio_timeline, sequence, tagsbin) + + # TODO: Set sequence settings from otio timeline if available + if isinstance(otio_timeline, otio.schema.Timeline): + tracks = otio_timeline.tracks + + else: + # otio.schema.Stack + tracks = otio_timeline + + for tracknum, otio_track in enumerate(tracks): + playhead = 0 + _transitions = [] + + # Add track to sequence + track = create_track(otio_track, tracknum, track_kind) + sequence.addTrack(track) + + # iterate over items in track + for itemnum, otio_clip in enumerate(otio_track): + if isinstance(otio_clip, otio.schema.Stack): + bar = hiero.ui.mainWindow().statusBar() + bar.showMessage( + 'Nested sequences are created separately.', + timeout=3000 + ) + build_sequence(otio_clip, project, otio_track.kind) + + elif isinstance(otio_clip, otio.schema.Clip): + # Create a Clip + clip = create_clip(otio_clip, tagsbin) + + # Add Clip to a Bin + sequencebin.addItem(hiero.core.BinItem(clip)) + + # Create TrackItem + trackitem = create_trackitem( + playhead, + track, + otio_clip, + clip + ) + + # Add trackitem to track + track.addTrackItem(trackitem) + + # Update playhead + playhead = trackitem.timelineOut() + 1 + + elif isinstance(otio_clip, otio.schema.Transition): + # Store transitions for when all clips in the track are created + _transitions.append((otio_track, otio_clip)) + + elif isinstance(otio_clip, otio.schema.Gap): + # Hiero has no fillers, slugs or blanks at the moment + playhead += otio_clip.source_range.duration.value + + # Apply transitions we stored earlier now that all clips are present + for otio_track, otio_item in _transitions: + apply_transition(otio_track, otio_item, track) diff --git a/setup/nukestudio/hiero_plugin_path/Python/StartupUI/otioimporter/__init__.py b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/otioimporter/__init__.py new file mode 100644 index 0000000000..1503a9e9ac --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/otioimporter/__init__.py @@ -0,0 +1,57 @@ +# MIT License +# +# Copyright (c) 2018 Daniel Flehner Heen (Storm Studios) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import hiero.ui +import hiero.core + +from otioimporter.OTIOImport import load_otio + + +def OTIO_menu_action(event): + otio_action = hiero.ui.createMenuAction( + 'Import OTIO', + open_otio_file, + icon=None + ) + hiero.ui.registerAction(otio_action) + for action in event.menu.actions(): + if action.text() == 'Import': + action.menu().addAction(otio_action) + break + + +def open_otio_file(): + files = hiero.ui.openFileBrowser( + caption='Please select an OTIO file of choice', + pattern='*.otio', + requiredExtension='.otio' + ) + for otio_file in files: + load_otio(otio_file) + + +# HieroPlayer is quite limited and can't create transitions etc. +if not hiero.core.isHieroPlayer(): + hiero.core.events.registerInterest( + "kShowContextMenu/kBin", + OTIO_menu_action + ) diff --git a/setup/nukestudio/hiero_plugin_path/Python/StartupUI/setPosterFrame.py b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/setPosterFrame.py new file mode 100644 index 0000000000..18398aa119 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Python/StartupUI/setPosterFrame.py @@ -0,0 +1,45 @@ +import hiero.core +import hiero.ui +try: + from PySide.QtGui import * + from PySide.QtCore import * +except: + from PySide2.QtGui import * + from PySide2.QtWidgets import * + from PySide2.QtCore import * + + +def setPosterFrame(posterFrame=.5): + ''' + Update the poster frame of the given clipItmes + posterFrame = .5 uses the centre frame, a value of 0 uses the first frame, a value of 1 uses the last frame + ''' + view = hiero.ui.activeView() + + selectedBinItems = view.selection() + selectedClipItems = [(item.activeItem() + if hasattr(item, 'activeItem') else item) + for item in selectedBinItems] + + for clip in selectedClipItems: + centreFrame = int(clip.duration() * posterFrame) + clip.setPosterFrame(centreFrame) + + +class SetPosterFrameAction(QAction): + def __init__(self): + QAction.__init__(self, "Set Poster Frame (centre)", None) + self._selection = None + + self.triggered.connect(lambda: setPosterFrame(.5)) + hiero.core.events.registerInterest("kShowContextMenu/kBin", + self.eventHandler) + + def eventHandler(self, event): + view = event.sender + # Add the Menu to the right-click menu + event.menu.addAction(self) + + +# The act of initialising the action adds it to the right-click menu... +SetPosterFrameAction() diff --git a/setup/nukestudio/hiero_plugin_path/TaskPresets/10.5/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml b/setup/nukestudio/hiero_plugin_path/TaskPresets/10.5/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml new file mode 100644 index 0000000000..e24a4dbe4e --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/TaskPresets/10.5/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml @@ -0,0 +1,198 @@ + + 991 + //10.11.0.184/171001_ftrack/tgbvfx/editorial/nukestudio/workspace/ + 1 + True + 3 + + + {shot}/editorial_raw.%04d.{fileext} + + + default + exr + False + all + False + False + False + False + True + + + 8 bit + (auto detect) + True + False + + False + + None + None + None + None + None + None + None + None + None + + + Zip (16 scanline) + 32 bit float + False + False + False + channels, layers and views + 45.0 + False + all metadata + + Write_{ext} + + Cubic + None + 1.0 +
True
+ width +
+ False + Blend +
+
+
+ + {shot}/editorial.%04d.{ext} + + + default + exr + False + all + False + False + False + False + True + + + 8 bit + (auto detect) + True + False + + True + + None + None + None + None + None + None + None + None + None + + + Zip (16 scanline) + 16 bit half + False + False + False + channels, layers and views + 45.0 + False + all metadata + + Write_{ext} + + Cubic + To Sequence Resolution + 1.0 +
True
+ width +
+ False + Blend +
+
+
+ + {shot}/editorial.nk + + + True + default + mov + + rgb + False + + False + False + False + + True + True + + {shot}/editorial_raw.%04d.{fileext} + + + Cubic + None + 1.0 +
True
+ width +
+ False + Blend + False + True + True + + 0 + 40000000 + 12 + 31 + 2 + avc1 H.264 + Auto + mov32 + 20000 + + False + True + True + False + False + {shot}/editorial_raw.%04d.{fileext} + + None + None + None + None + None + None + None + None + None + + + 8 bit + (auto detect) + True + False + + Write_{ext} + False +
+
+
+
+ + + False + Custom + True + 10 +
diff --git a/setup/nukestudio/hiero_plugin_path/TaskPresets/11.1/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml b/setup/nukestudio/hiero_plugin_path/TaskPresets/11.1/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml new file mode 100644 index 0000000000..e24a4dbe4e --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/TaskPresets/11.1/Processors/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml @@ -0,0 +1,198 @@ + + 991 + //10.11.0.184/171001_ftrack/tgbvfx/editorial/nukestudio/workspace/ + 1 + True + 3 + + + {shot}/editorial_raw.%04d.{fileext} + + + default + exr + False + all + False + False + False + False + True + + + 8 bit + (auto detect) + True + False + + False + + None + None + None + None + None + None + None + None + None + + + Zip (16 scanline) + 32 bit float + False + False + False + channels, layers and views + 45.0 + False + all metadata + + Write_{ext} + + Cubic + None + 1.0 +
True
+ width +
+ False + Blend +
+
+
+ + {shot}/editorial.%04d.{ext} + + + default + exr + False + all + False + False + False + False + True + + + 8 bit + (auto detect) + True + False + + True + + None + None + None + None + None + None + None + None + None + + + Zip (16 scanline) + 16 bit half + False + False + False + channels, layers and views + 45.0 + False + all metadata + + Write_{ext} + + Cubic + To Sequence Resolution + 1.0 +
True
+ width +
+ False + Blend +
+
+
+ + {shot}/editorial.nk + + + True + default + mov + + rgb + False + + False + False + False + + True + True + + {shot}/editorial_raw.%04d.{fileext} + + + Cubic + None + 1.0 +
True
+ width +
+ False + Blend + False + True + True + + 0 + 40000000 + 12 + 31 + 2 + avc1 H.264 + Auto + mov32 + 20000 + + False + True + True + False + False + {shot}/editorial_raw.%04d.{fileext} + + None + None + None + None + None + None + None + None + None + + + 8 bit + (auto detect) + True + False + + Write_{ext} + False +
+
+
+
+ + + False + Custom + True + 10 +
diff --git a/setup/nukestudio/hiero_plugin_path/TaskPresets/11.2/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml b/setup/nukestudio/hiero_plugin_path/TaskPresets/11.2/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml new file mode 100644 index 0000000000..e24a4dbe4e --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/TaskPresets/11.2/hiero.exporters.FnShotProcessor.ShotProcessor/pipeline.xml @@ -0,0 +1,198 @@ + + 991 + //10.11.0.184/171001_ftrack/tgbvfx/editorial/nukestudio/workspace/ + 1 + True + 3 + + + {shot}/editorial_raw.%04d.{fileext} + + + default + exr + False + all + False + False + False + False + True + + + 8 bit + (auto detect) + True + False + + False + + None + None + None + None + None + None + None + None + None + + + Zip (16 scanline) + 32 bit float + False + False + False + channels, layers and views + 45.0 + False + all metadata + + Write_{ext} + + Cubic + None + 1.0 +
True
+ width +
+ False + Blend +
+
+
+ + {shot}/editorial.%04d.{ext} + + + default + exr + False + all + False + False + False + False + True + + + 8 bit + (auto detect) + True + False + + True + + None + None + None + None + None + None + None + None + None + + + Zip (16 scanline) + 16 bit half + False + False + False + channels, layers and views + 45.0 + False + all metadata + + Write_{ext} + + Cubic + To Sequence Resolution + 1.0 +
True
+ width +
+ False + Blend +
+
+
+ + {shot}/editorial.nk + + + True + default + mov + + rgb + False + + False + False + False + + True + True + + {shot}/editorial_raw.%04d.{fileext} + + + Cubic + None + 1.0 +
True
+ width +
+ False + Blend + False + True + True + + 0 + 40000000 + 12 + 31 + 2 + avc1 H.264 + Auto + mov32 + 20000 + + False + True + True + False + False + {shot}/editorial_raw.%04d.{fileext} + + None + None + None + None + None + None + None + None + None + + + 8 bit + (auto detect) + True + False + + Write_{ext} + False +
+
+
+
+ + + False + Custom + True + 10 +
diff --git a/setup/nukestudio/hiero_plugin_path/Templates/SharedTags.hrox b/setup/nukestudio/hiero_plugin_path/Templates/SharedTags.hrox new file mode 100644 index 0000000000..128bde5456 --- /dev/null +++ b/setup/nukestudio/hiero_plugin_path/Templates/SharedTags.hrox @@ -0,0 +1,472 @@ + + + + + + + 2 + 70 + 0 + 0 + 13 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + 0 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 70 + 0 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2 + 70 + 0 + 0 + + + 2 + 70 + 0 + 0 + 17 + + + 2 + 70 + 0 + 0 + 2 + + + + + + + + + diff --git a/setup/nukestudio/hiero_plugin_path/Templates/fusion.png b/setup/nukestudio/hiero_plugin_path/Templates/fusion.png new file mode 100644 index 0000000000..208c1279cf Binary files /dev/null and b/setup/nukestudio/hiero_plugin_path/Templates/fusion.png differ diff --git a/setup/nukestudio/hiero_plugin_path/Templates/houdini.png b/setup/nukestudio/hiero_plugin_path/Templates/houdini.png new file mode 100644 index 0000000000..128eac262a Binary files /dev/null and b/setup/nukestudio/hiero_plugin_path/Templates/houdini.png differ diff --git a/setup/nukestudio/hiero_plugin_path/Templates/maya.png b/setup/nukestudio/hiero_plugin_path/Templates/maya.png new file mode 100644 index 0000000000..7dd1453c60 Binary files /dev/null and b/setup/nukestudio/hiero_plugin_path/Templates/maya.png differ diff --git a/setup/nukestudio/hiero_plugin_path/Templates/nuke.png b/setup/nukestudio/hiero_plugin_path/Templates/nuke.png new file mode 100644 index 0000000000..9d9dc4104c Binary files /dev/null and b/setup/nukestudio/hiero_plugin_path/Templates/nuke.png differ diff --git a/setup/nukestudio/hiero_plugin_path/Templates/volume.png b/setup/nukestudio/hiero_plugin_path/Templates/volume.png new file mode 100644 index 0000000000..47119dc98b Binary files /dev/null and b/setup/nukestudio/hiero_plugin_path/Templates/volume.png differ