diff --git a/pype/clockify/clockify.py b/pype/clockify/clockify.py index 17be642be5..0b84bf3953 100644 --- a/pype/clockify/clockify.py +++ b/pype/clockify/clockify.py @@ -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 96% rename from pype/ftrack/actions/action_clockify_start.py rename to pype/clockify/ftrack_actions/action_clockify_start.py index 594ec21b78..e09d0b76e6 100644 --- a/pype/ftrack/actions/action_clockify_start.py +++ b/pype/clockify/ftrack_actions/action_clockify_start.py @@ -1,3 +1,4 @@ +import os import sys import argparse import logging @@ -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 97% rename from pype/ftrack/actions/action_clockify_sync.py rename to pype/clockify/ftrack_actions/action_clockify_sync.py index 4cc00225e2..695f7581c0 100644 --- a/pype/ftrack/actions/action_clockify_sync.py +++ b/pype/clockify/ftrack_actions/action_clockify_sync.py @@ -1,3 +1,4 @@ +import os import sys import argparse import logging @@ -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 86% rename from pype/plugins/launcher/actions/ClockifyStart.py rename to pype/clockify/launcher_actions/ClockifyStart.py index 9183805c7f..6a9ceaec73 100644 --- a/pype/plugins/launcher/actions/ClockifyStart.py +++ b/pype/clockify/launcher_actions/ClockifyStart.py @@ -1,9 +1,7 @@ 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().get_logger(__name__, "clockify_start") @@ -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 87% rename from pype/plugins/launcher/actions/ClockifySync.py rename to pype/clockify/launcher_actions/ClockifySync.py index 0895da555d..3bf389796f 100644 --- a/pype/plugins/launcher/actions/ClockifySync.py +++ b/pype/clockify/launcher_actions/ClockifySync.py @@ -1,8 +1,5 @@ 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().get_logger(__name__, "clockify_sync") @@ -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/ftrack/actions/action_application_loader.py b/pype/ftrack/actions/action_application_loader.py index 50714e4535..1b0f48f9be 100644 --- a/pype/ftrack/actions/action_application_loader.py +++ b/pype/ftrack/actions/action_application_loader.py @@ -1,3 +1,4 @@ +import os import toml import time from pype.ftrack import AppAction @@ -35,10 +36,15 @@ def registerApp(app, session): label = apptoml.get('ftrack_label', app.get('label', name)) icon = apptoml.get('ftrack_icon', None) 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, icon, description + session, label, name, executable, variant, + icon, description, preactions ).register() diff --git a/pype/ftrack/actions/action_component_open.py b/pype/ftrack/actions/action_component_open.py index c40a04b2fd..d3213c555a 100644 --- a/pype/ftrack/actions/action_component_open.py +++ b/pype/ftrack/actions/action_component_open.py @@ -1,8 +1,8 @@ +import os import sys import argparse import logging import subprocess -import os 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 9f2564406a..7dd8335ecc 100644 --- a/pype/ftrack/actions/action_create_cust_attrs.py +++ b/pype/ftrack/actions/action_create_cust_attrs.py @@ -114,10 +114,8 @@ 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): diff --git a/pype/ftrack/actions/action_create_folders.py b/pype/ftrack/actions/action_create_folders.py index 4426fb9650..2a777911b4 100644 --- a/pype/ftrack/actions/action_create_folders.py +++ b/pype/ftrack/actions/action_create_folders.py @@ -3,11 +3,9 @@ import sys import logging import argparse import re -# import json 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 @@ -24,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): @@ -239,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 66e2e153e6..3ccdb08714 100644 --- a/pype/ftrack/actions/action_create_project_folders.py +++ b/pype/ftrack/actions/action_create_project_folders.py @@ -20,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('\[.*\]') @@ -42,7 +41,7 @@ class CreateProjectFolders(BaseAction): else: project = entity['project'] - presets = config.load_presets()['tools']['project_folder_structure'] + presets = config.get_presets()['tools']['project_folder_structure'] try: # Get paths based on presets basic_paths = self.get_path_items(presets) @@ -142,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 838a77570f..eabadecee6 100644 --- a/pype/ftrack/actions/action_delete_asset.py +++ b/pype/ftrack/actions/action_delete_asset.py @@ -1,3 +1,4 @@ +import os import sys import logging from bson.objectid import ObjectId @@ -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'] diff --git a/pype/ftrack/actions/action_delete_asset_byname.py b/pype/ftrack/actions/action_delete_asset_byname.py index 9da60ce763..fa966096a8 100644 --- a/pype/ftrack/actions/action_delete_asset_byname.py +++ b/pype/ftrack/actions/action_delete_asset_byname.py @@ -1,3 +1,4 @@ +import os import sys import logging import argparse @@ -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_djvview.py b/pype/ftrack/actions/action_djvview.py index 942aa7a327..e0c0334e5f 100644 --- a/pype/ftrack/actions/action_djvview.py +++ b/pype/ftrack/actions/action_djvview.py @@ -16,16 +16,16 @@ 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() @@ -54,22 +54,6 @@ class DJVViewAction(BaseAction): return True return False - def load_config_data(self): - # path_items = [pypelib.get_presets_path(), 'djv_view', 'config.json'] - path_items = config.get_presets()['djv_view']['config'] - 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): diff --git a/pype/ftrack/actions/action_job_killer.py b/pype/ftrack/actions/action_job_killer.py index 25c0c6a489..44acb24d55 100644 --- a/pype/ftrack/actions/action_job_killer.py +++ b/pype/ftrack/actions/action_job_killer.py @@ -1,6 +1,8 @@ +import os import sys import argparse import logging +import json from pype.vendor import ftrack_api from pype.ftrack import BaseAction @@ -17,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): @@ -37,14 +38,18 @@ class JobKiller(BaseAction): ).all() items = [] - import json + item_splitter = {'type': 'label', 'value': '---'} for job in jobs: - data = json.loads(job['data']) + try: + data = json.loads(job['data']) + desctiption = data['description'] + except Exception: + desctiption = '*No description*' user = job['user']['username'] created = job['created_at'].strftime('%d.%m.%Y %H:%M:%S') label = '{} - {} - {}'.format( - data['description'], created, user + desctiption, created, user ) item_label = { 'type': 'label', diff --git a/pype/ftrack/actions/action_multiple_notes.py b/pype/ftrack/actions/action_multiple_notes.py index 2d93f64242..338083fe47 100644 --- a/pype/ftrack/actions/action_multiple_notes.py +++ b/pype/ftrack/actions/action_multiple_notes.py @@ -1,3 +1,4 @@ +import os import sys import argparse import logging @@ -15,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 15689ae811..3832dffae4 100644 --- a/pype/ftrack/actions/action_rv.py +++ b/pype/ftrack/actions/action_rv.py @@ -17,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): @@ -39,7 +41,6 @@ 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() @@ -61,21 +62,6 @@ class RVAction(BaseAction): return True return False - def load_config_data(self): - path_items = config.get_presets['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_start_timer.py b/pype/ftrack/actions/action_start_timer.py new file mode 100644 index 0000000000..d27908541e --- /dev/null +++ b/pype/ftrack/actions/action_start_timer.py @@ -0,0 +1,79 @@ +from pype.vendor import ftrack_api +from pype.ftrack import BaseAction + + +class StartTimer(BaseAction): + '''Starts timer.''' + + identifier = 'start.timer' + label = 'Start timer' + description = 'Starts timer' + + def discover(self, session, entities, event): + return False + + def _handle_result(*arg): + return + + def launch(self, session, entities, event): + entity = entities[0] + if entity.entity_type.lower() != 'task': + return + self.start_ftrack_timer(entity) + try: + self.start_clockify_timer(entity) + except Exception: + self.log.warning( + 'Failed starting Clockify timer for task: ' + entity['name'] + ) + return + + def start_ftrack_timer(self, task): + user_query = 'User where username is "{}"'.format(self.session.api_user) + user = self.session.query(user_query).one() + self.log.info('Starting Ftrack timer for task: ' + task['name']) + user.start_timer(task, force=True) + self.session.commit() + + def start_clockify_timer(self, task): + # Validate Clockify settings if Clockify is required + clockify_timer = os.environ.get('CLOCKIFY_WORKSPACE', None) + if clockify_timer is None: + return + + from pype.clockify import ClockifyAPI + clockapi = ClockifyAPI() + if clockapi.verify_api() is False: + return + task_type = task['type']['name'] + project_name = task['project']['full_name'] + + def get_parents(entity): + output = [] + if entity.entity_type.lower() == 'project': + return output + output.extend(get_parents(entity['parent'])) + output.append(entity['name']) + + return output + + desc_items = get_parents(task['parent']) + desc_items.append(task['name']) + description = '/'.join(desc_items) + + project_id = clockapi.get_project_id(project_name) + tag_ids = [] + tag_ids.append(clockapi.get_tag_id(task_type)) + clockapi.start_time_entry( + description, project_id, tag_ids=tag_ids + ) + self.log.info('Starting Clockify timer for task: ' + task['name']) + + +def register(session, **kw): + '''Register plugin. Called when used as an plugin.''' + + if not isinstance(session, ftrack_api.session.Session): + return + + StartTimer(session).register() diff --git a/pype/ftrack/actions/action_sync_to_avalon_local.py b/pype/ftrack/actions/action_sync_to_avalon_local.py index 1056b5ee55..54fd0b47f8 100644 --- a/pype/ftrack/actions/action_sync_to_avalon_local.py +++ b/pype/ftrack/actions/action_sync_to_avalon_local.py @@ -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 36adb99074..dcb9dd32d0 100644 --- a/pype/ftrack/actions/action_test.py +++ b/pype/ftrack/actions/action_test.py @@ -1,8 +1,8 @@ +import os import sys import argparse import logging import collections -import os import json import re @@ -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 5b63ec264f..4e7f1298f5 100644 --- a/pype/ftrack/actions/action_thumbToChildern.py +++ b/pype/ftrack/actions/action_thumbToChildern.py @@ -1,3 +1,4 @@ +import os import sys import argparse import logging @@ -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 eb5623328e..632d2a50b2 100644 --- a/pype/ftrack/actions/action_thumbToParent.py +++ b/pype/ftrack/actions/action_thumbToParent.py @@ -1,3 +1,4 @@ +import os import sys import argparse import logging @@ -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/actions/event_collect_entities.py b/pype/ftrack/actions/event_collect_entities.py deleted file mode 100644 index 71f2d26ff3..0000000000 --- a/pype/ftrack/actions/event_collect_entities.py +++ /dev/null @@ -1,72 +0,0 @@ -from pype.vendor import ftrack_api -from pype.ftrack import BaseEvent - - -class CollectEntities(BaseEvent): - - priority = 1 - - def _launch(self, event): - entities = self.translate_event(event) - event['data']['entities_object'] = entities - - return - - def translate_event(self, event): - selection = event['data'].get('selection', []) - - entities = list() - for entity in selection: - ent = self.session.get( - self.get_entity_type(entity), - entity.get('entityId') - ) - entities.append(ent) - - return entities - - def get_entity_type(self, entity): - '''Return translated entity type tht can be used with API.''' - # Get entity type and make sure it is lower cased. Most places except - # the component tab in the Sidebar will use lower case notation. - entity_type = entity.get('entityType').replace('_', '').lower() - - for schema in self.session.schemas: - alias_for = schema.get('alias_for') - - if ( - alias_for and isinstance(alias_for, str) and - alias_for.lower() == entity_type - ): - return schema['id'] - - for schema in self.session.schemas: - if schema['id'].lower() == entity_type: - return schema['id'] - - raise ValueError( - 'Unable to translate entity type: {0}.'.format(entity_type) - ) - - def register(self): - self.session.event_hub.subscribe( - 'topic=ftrack.action.discover' - ' and source.user.username={0}'.format(self.session.api_user), - self._launch, - priority=self.priority - ) - - self.session.event_hub.subscribe( - 'topic=ftrack.action.launch' - ' and source.user.username={0}'.format(self.session.api_user), - self._launch, - priority=self.priority - ) - - -def register(session, **kw): - '''Register plugin. Called when used as an plugin.''' - if not isinstance(session, ftrack_api.session.Session): - return - - CollectEntities(session).register() diff --git a/pype/ftrack/events/action_sync_to_avalon.py b/pype/ftrack/events/action_sync_to_avalon.py index 1627170688..87d79d0948 100644 --- a/pype/ftrack/events/action_sync_to_avalon.py +++ b/pype/ftrack/events/action_sync_to_avalon.py @@ -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/ftrack_module.py b/pype/ftrack/ftrack_module.py index 127b39d2fc..fdce0535e8 100644 --- a/pype/ftrack/ftrack_module.py +++ b/pype/ftrack/ftrack_module.py @@ -153,6 +153,7 @@ class FtrackModule: parent_menu.addMenu(self.menu) + def tray_start(self): self.validate() # Definition of visibility of each menu actions diff --git a/pype/ftrack/lib/ftrack_action_handler.py b/pype/ftrack/lib/ftrack_action_handler.py index c6d6181c1f..7a25155718 100644 --- a/pype/ftrack/lib/ftrack_action_handler.py +++ b/pype/ftrack/lib/ftrack_action_handler.py @@ -66,6 +66,10 @@ class BaseAction(BaseHandler): self.session, event ) + preactions_launched = self._handle_preactions(self.session, event) + if preactions_launched is False: + return + interface = self._interface( self.session, *args ) diff --git a/pype/ftrack/lib/ftrack_app_handler.py b/pype/ftrack/lib/ftrack_app_handler.py index e4075e9a19..3c2bc418a8 100644 --- a/pype/ftrack/lib/ftrack_app_handler.py +++ b/pype/ftrack/lib/ftrack_app_handler.py @@ -23,10 +23,11 @@ class AppAction(BaseHandler): ''' type = 'Application' + preactions = ['start.timer'] def __init__( self, session, label, name, executable, - variant=None, icon=None, description=None + variant=None, icon=None, description=None, preactions=[] ): super().__init__(session) '''Expects a ftrack_api.Session instance''' @@ -44,6 +45,7 @@ class AppAction(BaseHandler): self.variant = variant self.icon = icon self.description = description + self.preactions.extend(preactions) def register(self): '''Registers the action, subscribing the discover and launch topics.''' @@ -117,6 +119,12 @@ class AppAction(BaseHandler): self.session, event ) + preactions_launched = self._handle_preactions( + self.session, event + ) + if preactions_launched is False: + return + response = self.launch( self.session, *args ) @@ -148,25 +156,6 @@ class AppAction(BaseHandler): entity = entities[0] project_name = entity['project']['full_name'] - # Validate Clockify settings if Clockify is required - clockify_timer = os.environ.get('CLOCKIFY_WORKSPACE', None) - if clockify_timer is not None: - from pype.clockify import ClockifyAPI - clockapi = ClockifyAPI() - if clockapi.verify_api() is False: - title = 'Launch message' - header = '# You Can\'t launch **any Application**' - message = ( - '
You don\'t have set Clockify API' - ' key in Clockify settings
' - ) - items = [ - {'type': 'label', 'value': header}, - {'type': 'label', 'value': message} - ] - self.show_interface(event, items, title) - return False - database = pypelib.get_avalon_database() # Get current environments @@ -335,39 +324,6 @@ class AppAction(BaseHandler): } pass - # RUN TIMER IN FTRACK - username = event['source']['user']['username'] - user_query = 'User where username is "{}"'.format(username) - user = session.query(user_query).one() - task = session.query('Task where id is {}'.format(entity['id'])).one() - self.log.info('Starting timer for task: ' + task['name']) - user.start_timer(task, force=True) - - # RUN TIMER IN Clockify - if clockify_timer is not None: - task_type = task['type']['name'] - project_name = task['project']['full_name'] - - def get_parents(entity): - output = [] - if entity.entity_type.lower() == 'project': - return output - output.extend(get_parents(entity['parent'])) - output.append(entity['name']) - - return output - - desc_items = get_parents(task['parent']) - desc_items.append(task['name']) - description = '/'.join(desc_items) - - project_id = clockapi.get_project_id(project_name) - tag_ids = [] - tag_ids.append(clockapi.get_tag_id(task_type)) - clockapi.start_time_entry( - description, project_id, tag_ids=tag_ids - ) - # Change status of task to In progress config = get_config_data() diff --git a/pype/ftrack/lib/ftrack_base_handler.py b/pype/ftrack/lib/ftrack_base_handler.py index 7a04ba329c..24ece4f11d 100644 --- a/pype/ftrack/lib/ftrack_base_handler.py +++ b/pype/ftrack/lib/ftrack_base_handler.py @@ -25,6 +25,7 @@ class BaseHandler(object): priority = 100 # Type is just for logging purpose (e.g.: Action, Event, Application,...) type = 'No-type' + preactions = [] def __init__(self, session): '''Expects a ftrack_api.Session instance''' @@ -46,18 +47,7 @@ class BaseHandler(object): else: label = '{} {}'.format(self.label, self.variant) try: - if hasattr(self, "role_list") and len(self.role_list) > 0: - username = self.session.api_user - user = self.session.query( - 'User where username is "{}"'.format(username) - ).one() - available = False - for role in user['user_security_roles']: - if role['security_role']['name'] in self.role_list: - available = True - break - if available is False: - raise MissingPermision + self._preregister() start_time = time.perf_counter() func(*args, **kwargs) @@ -119,6 +109,36 @@ class BaseHandler(object): def reset_session(self): self.session.reset() + def _preregister(self): + if hasattr(self, "role_list") and len(self.role_list) > 0: + username = self.session.api_user + user = self.session.query( + 'User where username is "{}"'.format(username) + ).one() + available = False + for role in user['user_security_roles']: + if role['security_role']['name'] in self.role_list: + available = True + break + if available is False: + raise MissingPermision + + # Custom validations + result = self.preregister() + if result is True: + return + msg = "Pre-register conditions were not met" + if isinstance(result, str): + msg = result + raise Exception(msg) + + def preregister(self): + ''' + Preregister conditions. + Registration continues if returns True. + ''' + return True + def register(self): ''' Registers the action, subscribing the discover and launch topics. @@ -227,6 +247,10 @@ class BaseHandler(object): self.session, event ) + preactions_launched = self._handle_preactions(self.session, event) + if preactions_launched is False: + return + interface = self._interface( self.session, *args ) @@ -263,6 +287,47 @@ class BaseHandler(object): ''' raise NotImplementedError() + def _handle_preactions(self, session, event): + # If preactions are not set + if len(self.preactions) == 0: + return True + # If no selection + selection = event.get('data', {}).get('selection', None) + if (selection is None): + return False + # If preactions were already started + if event['data'].get('preactions_launched', None) is True: + return True + + # Launch preactions + for preaction in self.preactions: + event = ftrack_api.event.base.Event( + topic='ftrack.action.launch', + data=dict( + actionIdentifier=preaction, + selection=selection + ), + source=dict( + user=dict(username=session.api_user) + ) + ) + session.event_hub.publish(event, on_error='ignore') + # Relaunch this action + event = ftrack_api.event.base.Event( + topic='ftrack.action.launch', + data=dict( + actionIdentifier=self.identifier, + selection=selection, + preactions_launched=True + ), + source=dict( + user=dict(username=session.api_user) + ) + ) + session.event_hub.publish(event, on_error='ignore') + + return False + def _interface(self, *args): interface = self.interface(*args) if interface: 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/plugins/global/publish/collect_assumed_destination.py b/pype/plugins/global/publish/collect_assumed_destination.py index 7458db6aa7..fa6a3d9423 100644 --- a/pype/plugins/global/publish/collect_assumed_destination.py +++ b/pype/plugins/global/publish/collect_assumed_destination.py @@ -4,14 +4,18 @@ 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): + 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 @@ -19,7 +23,6 @@ class CollectAssumedDestination(pyblish.api.InstancePlugin): self.create_destination_template(instance) template_data = instance.data["assumedTemplateData"] - template = instance.data["template"] anatomy = instance.context.data['anatomy'] # self.log.info(anatomy.anatomy()) diff --git a/pype/services/idle_manager/__init__.py b/pype/services/idle_manager/__init__.py index 7c07d3ebee..f1a87bef41 100644 --- a/pype/services/idle_manager/__init__.py +++ b/pype/services/idle_manager/__init__.py @@ -2,6 +2,4 @@ from .idle_manager import IdleManager def tray_init(tray_widget, main_widget): - manager = IdleManager() - manager.start() - return manager + return IdleManager() diff --git a/pype/services/idle_manager/idle_manager.py b/pype/services/idle_manager/idle_manager.py index e8ba246121..f7d7f2b34e 100644 --- a/pype/services/idle_manager/idle_manager.py +++ b/pype/services/idle_manager/idle_manager.py @@ -17,8 +17,12 @@ class IdleManager(QtCore.QThread): super(IdleManager, self).__init__() self.log = Logger().get_logger(self.__class__.__name__) self.signal_reset_timer.connect(self._reset_time) + self._failed = False self._is_running = False + 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 @@ -30,6 +34,10 @@ class IdleManager(QtCore.QThread): self.time_signals[emit_time] = [] self.time_signals[emit_time].append(signal) + @property + def failed(self): + return self._failed + @property def is_running(self): return self._is_running @@ -60,6 +68,8 @@ class IdleManager(QtCore.QThread): thread_keyboard.signal_stop.emit() thread_keyboard.terminate() thread_keyboard.wait() + self._failed = True + self._is_running = False self.log.info('IdleManager has stopped') 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..90048d2ee2 --- /dev/null +++ b/pype/services/statics_server/statics_server.py @@ -0,0 +1,190 @@ +import os +import socket +import http.server +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): + directory=DIRECTORY + def __init__(self, *args, **kwargs): + 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 = 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._is_running = False + self._failed = 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 tray_start(self): + self.start() + + @property + def is_running(self): + return self._is_running + + @property + def failed(self): + return self._failed + + 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._failed = True + self._is_running = False + + 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/timers_manager.py b/pype/services/timers_manager/timers_manager.py index 6f10a0ec68..319e4c6910 100644 --- a/pype/services/timers_manager/timers_manager.py +++ b/pype/services/timers_manager/timers_manager.py @@ -25,6 +25,7 @@ class TimersManager(metaclass=Singleton): when user idles for a long time (set in presets). """ modules = [] + failed = False is_running = False last_task = None 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 @@ + + + +