# ##### BEGIN GPL LICENSE BLOCK ##### # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # ##### END GPL LICENSE BLOCK ##### """Blender-specific code. Separated from __init__.py so that we can import & run from non-Blender environments. """ import functools import logging import os.path import tempfile import bpy from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty import rna_prop_ui from . import pillar, async_loop, flamenco, project_specific from .utils import pyside_cache, redraw PILLAR_WEB_SERVER_URL = os.environ.get('BCLOUD_SERVER', 'https://cloud.blender.org/') PILLAR_SERVER_URL = '%sapi/' % PILLAR_WEB_SERVER_URL ADDON_NAME = 'blender_cloud' log = logging.getLogger(__name__) icons = None if bpy.app.version < (2, 80): SYNC_SELECT_VERSION_ICON = 'DOTSDOWN' else: SYNC_SELECT_VERSION_ICON = 'DOWNARROW_HLT' @functools.lru_cache() def factor(factor: float) -> dict: """Construct keyword argument for UILayout.split(). On Blender 2.8 this returns {'factor': factor}, and on earlier Blenders it returns {'percentage': factor}. """ if bpy.app.version < (2, 80, 0): return {'percentage': factor} return {'factor': factor} @pyside_cache('version') def blender_syncable_versions(self, context): """Returns the list of items used by SyncStatusProperties.version EnumProperty.""" bss = context.window_manager.blender_sync_status versions = bss.available_blender_versions if not versions: return [('', 'No settings stored in your Blender Cloud', '')] return [(v, v, '') for v in versions] class SyncStatusProperties(PropertyGroup): status = EnumProperty( items=[ ('NONE', 'NONE', 'We have done nothing at all yet.'), ('IDLE', 'IDLE', 'User requested something, which is done, and we are now idle.'), ('SYNCING', 'SYNCING', 'Synchronising with Blender Cloud.'), ], name='status', description='Current status of Blender Sync', update=redraw) version = EnumProperty( items=blender_syncable_versions, name='Version of Blender from which to pull', description='Version of Blender from which to pull') message = StringProperty(name='message', update=redraw) level = EnumProperty( items=[ ('INFO', 'INFO', ''), ('WARNING', 'WARNING', ''), ('ERROR', 'ERROR', ''), ('SUBSCRIBE', 'SUBSCRIBE', ''), ], name='level', update=redraw) def report(self, level: set, message: str): assert len(level) == 1, 'level should be a set of one string, not %r' % level self.level = level.pop() self.message = message # Message can also be empty, just to erase it from the GUI. # No need to actually log those. if message: try: loglevel = logging._nameToLevel[self.level] except KeyError: loglevel = logging.WARNING log.log(loglevel, message) # List of syncable versions is stored in 'available_blender_versions' ID property, # because I don't know how to store a variable list of strings in a proper RNA property. @property def available_blender_versions(self) -> list: return self.get('available_blender_versions', []) @available_blender_versions.setter def available_blender_versions(self, new_versions): self['available_blender_versions'] = new_versions @pyside_cache('project') def bcloud_available_projects(self, context): """Returns the list of items used by BlenderCloudProjectGroup.project EnumProperty.""" projs = preferences().project.available_projects if not projs: return [('', 'No projects available in your Blender Cloud', '')] return [(p['_id'], p['name'], '') for p in projs] @functools.lru_cache(1) def project_extensions(project_id) -> set: """Returns the extensions the project is enabled for. At the moment of writing these are 'attract' and 'flamenco'. """ log.debug('Finding extensions for project %s', project_id) # We can't use our @property, since the preferences may be loaded from a # preferences blend file, in which case it is not constructed from Python code. available_projects = preferences().project.get('available_projects', []) if not available_projects: log.debug('No projects available.') return set() proj = next((p for p in available_projects if p['_id'] == project_id), None) if proj is None: log.debug('Project %s not found in available projects.', project_id) return set() return set(proj.get('enabled_for', ())) class BlenderCloudProjectGroup(PropertyGroup): status = EnumProperty( items=[ ('NONE', 'NONE', 'We have done nothing at all yet'), ('IDLE', 'IDLE', 'User requested something, which is done, and we are now idle'), ('FETCHING', 'FETCHING', 'Fetching available projects from Blender Cloud'), ], name='status', update=redraw) project = EnumProperty( items=bcloud_available_projects, name='Cloud project', description='Which Blender Cloud project to work with', update=project_specific.handle_project_update ) # List of projects is stored in 'available_projects' ID property, # because I don't know how to store a variable list of strings in a proper RNA property. @property def available_projects(self) -> list: return self.get('available_projects', []) @available_projects.setter def available_projects(self, new_projects): self['available_projects'] = new_projects project_specific.handle_project_update() class BlenderCloudPreferences(AddonPreferences): bl_idname = ADDON_NAME # The following property is read-only to limit the scope of the # addon and allow for proper testing within this scope. pillar_server = StringProperty( name='Blender Cloud Server', description='URL of the Blender Cloud backend server', default=PILLAR_SERVER_URL, get=lambda self: PILLAR_SERVER_URL ) local_texture_dir = StringProperty( name='Default Blender Cloud Texture Storage Directory', subtype='DIR_PATH', default='//textures') open_browser_after_share = BoolProperty( name='Open Browser after Sharing File', description='When enabled, Blender will open a webbrowser', default=True ) # TODO: store project-dependent properties with the project, so that people # can switch projects and the Attract and Flamenco properties switch with it. project = PointerProperty(type=BlenderCloudProjectGroup) cloud_project_local_path = StringProperty( name='Local Project Path', description='Local path of your Attract project, used to search for blend files; ' 'usually best to set to an absolute path', subtype='DIR_PATH', default='//../', update=project_specific.store, ) flamenco_manager = PointerProperty(type=flamenco.FlamencoManagerGroup) flamenco_exclude_filter = StringProperty( name='File Exclude Filter', description='Space-separated list of filename filters, like "*.abc *.mkv", to prevent ' 'matching files from being packed into the output directory', default='', update=project_specific.store, ) flamenco_job_file_path = StringProperty( name='Job Storage Path', description='Path where to store job files, should be accesible for Workers too', subtype='DIR_PATH', default=tempfile.gettempdir(), update=project_specific.store, ) flamenco_job_output_path = StringProperty( name='Job Output Path', description='Path where to store output files, should be accessible for Workers', subtype='DIR_PATH', default=tempfile.gettempdir(), update=project_specific.store, ) flamenco_job_output_strip_components = IntProperty( name='Job Output Path Strip Components', description='The final output path comprises of the job output path, and the blend file ' 'path relative to the project with this many path components stripped off ' 'the front', min=0, default=0, soft_max=4, update=project_specific.store, ) flamenco_relative_only = BoolProperty( name='Relative Paths Only', description='When enabled, only assets that are referred to with a relative path are ' 'packed, and assets referred to by an absolute path are excluded from the ' 'BAT pack. When disabled, all assets are packed.', default=False, update=project_specific.store, ) flamenco_open_browser_after_submit = BoolProperty( name='Open Browser after Submitting Job', description='When enabled, Blender will open a webbrowser', default=True, ) flamenco_show_quit_after_submit_button = BoolProperty( name='Show "Submit & Quit" button', description='When enabled, next to the "Render on Flamenco" button there will be a button ' '"Submit & Quit" that silently quits Blender after submitting the render job ' 'to Flamenco', default=False, ) def draw(self, context): import textwrap layout = self.layout # Carefully try and import the Blender ID addon try: import blender_id except ImportError: blender_id = None blender_id_profile = None else: blender_id_profile = blender_id.get_active_profile() if blender_id is None: msg_icon = 'ERROR' text = 'This add-on requires Blender ID' help_text = 'Make sure that the Blender ID add-on is installed and activated' elif not blender_id_profile: msg_icon = 'ERROR' text = 'You are logged out.' help_text = 'To login, go to the Blender ID add-on preferences.' elif bpy.app.debug and pillar.SUBCLIENT_ID not in blender_id_profile.subclients: msg_icon = 'QUESTION' text = 'No Blender Cloud credentials.' help_text = ('You are logged in on Blender ID, but your credentials have not ' 'been synchronized with Blender Cloud yet. Press the Update ' 'Credentials button.') else: msg_icon = 'WORLD_DATA' text = 'You are logged in as %s.' % blender_id_profile.username help_text = ('To logout or change profile, ' 'go to the Blender ID add-on preferences.') # Authentication stuff auth_box = layout.box() auth_box.label(text=text, icon=msg_icon) help_lines = textwrap.wrap(help_text, 80) for line in help_lines: auth_box.label(text=line) if bpy.app.debug: auth_box.operator("pillar.credentials_update") # Texture browser stuff texture_box = layout.box() texture_box.enabled = msg_icon != 'ERROR' sub = texture_box.column() sub.label(text='Local directory for downloaded textures', icon_value=icon('CLOUD')) sub.prop(self, "local_texture_dir", text='Default') sub.prop(context.scene, "local_texture_dir", text='Current scene') # Blender Sync stuff bss = context.window_manager.blender_sync_status bsync_box = layout.box() bsync_box.enabled = msg_icon != 'ERROR' row = bsync_box.row().split(**factor(0.33)) row.label(text='Blender Sync with Blender Cloud', icon_value=icon('CLOUD')) icon_for_level = { 'INFO': 'NONE', 'WARNING': 'INFO', 'ERROR': 'ERROR', 'SUBSCRIBE': 'ERROR', } msg_icon = icon_for_level[bss.level] if bss.message else 'NONE' message_container = row.row() message_container.label(text=bss.message, icon=msg_icon) sub = bsync_box.column() if bss.level == 'SUBSCRIBE': self.draw_subscribe_button(sub) self.draw_sync_buttons(sub, bss) # Image Share stuff share_box = layout.box() share_box.label(text='Image Sharing on Blender Cloud', icon_value=icon('CLOUD')) share_box.prop(self, 'open_browser_after_share') # Project selector project_box = layout.box() project_box.enabled = self.project.status in {'NONE', 'IDLE'} self.draw_project_selector(project_box, self.project) extensions = project_extensions(self.project.project) # Flamenco stuff if 'flamenco' in extensions: flamenco_box = project_box.column() self.draw_flamenco_buttons(flamenco_box, self.flamenco_manager, context) def draw_subscribe_button(self, layout): layout.operator('pillar.subscribe', icon='WORLD') def draw_sync_buttons(self, layout, bss): layout.enabled = bss.status in {'NONE', 'IDLE'} buttons = layout.column() row_buttons = buttons.row().split(**factor(0.5)) row_push = row_buttons.row() row_pull = row_buttons.row(align=True) row_push.operator('pillar.sync', text='Save %i.%i settings' % bpy.app.version[:2], icon='TRIA_UP').action = 'PUSH' versions = bss.available_blender_versions version = bss.version if bss.status in {'NONE', 'IDLE'}: if not versions or not version: row_pull.operator('pillar.sync', text='Find version to load', icon='TRIA_DOWN').action = 'REFRESH' else: props = row_pull.operator('pillar.sync', text='Load %s settings' % version, icon='TRIA_DOWN') props.action = 'PULL' props.blender_version = version row_pull.operator('pillar.sync', text='', icon=SYNC_SELECT_VERSION_ICON).action = 'SELECT' else: row_pull.label(text='Cloud Sync is running.') def draw_project_selector(self, project_box, bcp: BlenderCloudProjectGroup): project_row = project_box.row(align=True) project_row.label(text='Project settings', icon_value=icon('CLOUD')) row_buttons = project_row.row(align=True) projects = bcp.available_projects project = bcp.project if bcp.status in {'NONE', 'IDLE'}: if not projects: row_buttons.operator('pillar.projects', text='Find project to load', icon='FILE_REFRESH') else: row_buttons.prop(bcp, 'project') row_buttons.operator('pillar.projects', text='', icon='FILE_REFRESH') props = row_buttons.operator('pillar.project_open_in_browser', text='', icon='WORLD') props.project_id = project else: row_buttons.label(text='Fetching available projects.') enabled_for = project_extensions(project) if not project: return if not enabled_for: project_box.label(text='This project is not set up for Attract or Flamenco') return project_box.label(text='This project is set up for: %s' % ', '.join(sorted(enabled_for))) # This is only needed when the project is set up for either Attract or Flamenco. project_box.prop(self, 'cloud_project_local_path', text='Local Project Path') def draw_flamenco_buttons(self, flamenco_box, bcp: flamenco.FlamencoManagerGroup, context): header_row = flamenco_box.row(align=True) header_row.label(text='Flamenco:', icon_value=icon('CLOUD')) manager_split = flamenco_box.split(**factor(0.32), align=True) manager_split.label(text='Manager:') manager_box = manager_split.row(align=True) if bcp.status in {'NONE', 'IDLE'}: if not bcp.available_managers or not bcp.manager: manager_box.operator('flamenco.managers', text='Find Flamenco Managers', icon='FILE_REFRESH') else: manager_box.prop(bcp, 'manager', text='') manager_box.operator('flamenco.managers', text='', icon='FILE_REFRESH') else: manager_box.label(text='Fetching available managers.') path_split = flamenco_box.split(**factor(0.32), align=True) path_split.label(text='Job File Path:') path_box = path_split.row(align=True) path_box.prop(self, 'flamenco_job_file_path', text='') props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE') props.path = self.flamenco_job_file_path job_output_box = flamenco_box.column(align=True) path_split = job_output_box.split(**factor(0.32), align=True) path_split.label(text='Job Output Path:') path_box = path_split.row(align=True) path_box.prop(self, 'flamenco_job_output_path', text='') props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE') props.path = self.flamenco_job_output_path job_output_box.prop(self, 'flamenco_exclude_filter') prop_split = job_output_box.split(**factor(0.32), align=True) prop_split.label(text='Strip Components:') prop_split.prop(self, 'flamenco_job_output_strip_components', text='') from .flamenco import render_output_path path_box = job_output_box.row(align=True) output_path = render_output_path(context) if output_path: path_box.label(text=str(output_path)) props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE') props.path = str(output_path.parent) else: path_box.label(text='Blend file is not in your project path, ' 'unable to give output path example.') flamenco_box.prop(self, 'flamenco_relative_only') flamenco_box.prop(self, 'flamenco_open_browser_after_submit') flamenco_box.prop(self, 'flamenco_show_quit_after_submit_button') class PillarCredentialsUpdate(pillar.PillarOperatorMixin, Operator): """Updates the Pillar URL and tests the new URL.""" bl_idname = 'pillar.credentials_update' bl_label = 'Update credentials' bl_description = 'Resynchronises your Blender ID login with Blender Cloud' log = logging.getLogger('bpy.ops.%s' % bl_idname) @classmethod def poll(cls, context): # Only allow activation when the user is actually logged in. return cls.is_logged_in(context) @classmethod def is_logged_in(cls, context): try: import blender_id except ImportError: return False return blender_id.is_logged_in() def execute(self, context): import blender_id import asyncio # Only allow activation when the user is actually logged in. if not self.is_logged_in(context): self.report({'ERROR'}, 'No active profile found') return {'CANCELLED'} try: loop = asyncio.get_event_loop() loop.run_until_complete(self.check_credentials(context, set())) except blender_id.BlenderIdCommError as ex: log.exception('Error sending subclient-specific token to Blender ID') self.report({'ERROR'}, 'Failed to sync Blender ID to Blender Cloud') return {'CANCELLED'} except Exception as ex: log.exception('Error in test call to Pillar') self.report({'ERROR'}, 'Failed test connection to Blender Cloud') return {'CANCELLED'} self.report({'INFO'}, 'Blender Cloud credentials & endpoint URL updated.') return {'FINISHED'} class PILLAR_OT_subscribe(Operator): """Opens a browser to subscribe the user to the Cloud.""" bl_idname = 'pillar.subscribe' bl_label = 'Subscribe to the Cloud' bl_description = "Opens a page in a web browser to subscribe to the Blender Cloud" def execute(self, context): import webbrowser webbrowser.open_new_tab('https://cloud.blender.org/join') self.report({'INFO'}, 'We just started a browser for you.') return {'FINISHED'} class PILLAR_OT_project_open_in_browser(Operator): bl_idname = 'pillar.project_open_in_browser' bl_label = 'Open in Browser' bl_description = 'Opens a webbrowser to show the project' project_id = StringProperty(name='Project ID') def execute(self, context): if not self.project_id: return {'CANCELLED'} import webbrowser import urllib.parse import pillarsdk from .pillar import sync_call project = sync_call(pillarsdk.Project.find, self.project_id, {'projection': {'url': True}}) if log.isEnabledFor(logging.DEBUG): import pprint log.debug('found project: %s', pprint.pformat(project.to_dict())) url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, 'p/' + project.url) webbrowser.open_new_tab(url) self.report({'INFO'}, 'Opened a browser at %s' % url) return {'FINISHED'} class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin, pillar.AuthenticatedPillarOperatorMixin, Operator): """Fetches the projects available to the user""" bl_idname = 'pillar.projects' bl_label = 'Fetch available projects' stop_upon_exception = True _log = logging.getLogger('bpy.ops.%s' % bl_idname) async def async_execute(self, context): if not await self.authenticate(context): return import pillarsdk from .pillar import pillar_call self.log.info('Going to fetch projects for user %s', self.user_id) preferences().project.status = 'FETCHING' # Get all projects, except the home project. projects_user = await pillar_call( pillarsdk.Project.all, {'where': {'user': self.user_id, 'category': {'$ne': 'home'}}, 'sort': '-name', 'projection': {'_id': True, 'name': True, 'extension_props': True}, }) projects_shared = await pillar_call( pillarsdk.Project.all, {'where': {'user': {'$ne': self.user_id}, 'permissions.groups.group': {'$in': self.db_user.groups}}, 'sort': '-name', 'projection': {'_id': True, 'name': True, 'extension_props': True}, }) # We need to convert to regular dicts before storing in ID properties. # Also don't store more properties than we need. def reduce_properties(project_list): for p in project_list: p = p.to_dict() extension_props = p.get('extension_props', {}) enabled_for = list(extension_props.keys()) self._log.debug('Project %r is enabled for %s', p['name'], enabled_for) yield { '_id': p['_id'], 'name': p['name'], 'enabled_for': enabled_for, } projects = list(reduce_properties(projects_user['_items'])) + \ list(reduce_properties(projects_shared['_items'])) def proj_sort_key(project): return project.get('name') preferences().project.available_projects = sorted(projects, key=proj_sort_key) self.quit() def quit(self): preferences().project.status = 'IDLE' super().quit() class PILLAR_PT_image_custom_properties(rna_prop_ui.PropertyPanel, bpy.types.Panel): """Shows custom properties in the image editor.""" bl_space_type = 'IMAGE_EDITOR' bl_region_type = 'UI' bl_label = 'Custom Properties' _context_path = 'edit_image' _property_type = bpy.types.Image def ctx_preferences(): """Returns bpy.context.preferences in a 2.79-compatible way.""" try: return bpy.context.preferences except AttributeError: return bpy.context.user_preferences def preferences() -> BlenderCloudPreferences: return ctx_preferences().addons[ADDON_NAME].preferences def load_custom_icons(): global icons if icons is not None: # Already loaded return import bpy.utils.previews icons = bpy.utils.previews.new() my_icons_dir = os.path.join(os.path.dirname(__file__), 'icons') icons.load('CLOUD', os.path.join(my_icons_dir, 'icon-cloud.png'), 'IMAGE') def unload_custom_icons(): global icons if icons is None: # Already unloaded return bpy.utils.previews.remove(icons) icons = None def icon(icon_name: str) -> int: """Returns the icon ID for the named icon. Use with layout.operator('pillar.image_share', icon_value=icon('CLOUD')) """ return icons[icon_name].icon_id def register(): bpy.utils.register_class(BlenderCloudProjectGroup) bpy.utils.register_class(BlenderCloudPreferences) bpy.utils.register_class(PillarCredentialsUpdate) bpy.utils.register_class(SyncStatusProperties) bpy.utils.register_class(PILLAR_OT_subscribe) bpy.utils.register_class(PILLAR_OT_projects) bpy.utils.register_class(PILLAR_OT_project_open_in_browser) bpy.utils.register_class(PILLAR_PT_image_custom_properties) addon_prefs = preferences() WindowManager.last_blender_cloud_location = StringProperty( name="Last Blender Cloud browser location", default="/") def default_if_empty(scene, context): """The scene's local_texture_dir, if empty, reverts to the addon prefs.""" if not scene.local_texture_dir: scene.local_texture_dir = addon_prefs.local_texture_dir Scene.local_texture_dir = StringProperty( name='Blender Cloud texture storage directory for current scene', subtype='DIR_PATH', default=addon_prefs.local_texture_dir, update=default_if_empty) WindowManager.blender_sync_status = PointerProperty(type=SyncStatusProperties) load_custom_icons() def unregister(): unload_custom_icons() bpy.utils.unregister_class(BlenderCloudProjectGroup) bpy.utils.unregister_class(PillarCredentialsUpdate) bpy.utils.unregister_class(BlenderCloudPreferences) bpy.utils.unregister_class(SyncStatusProperties) bpy.utils.unregister_class(PILLAR_OT_subscribe) bpy.utils.unregister_class(PILLAR_OT_projects) bpy.utils.unregister_class(PILLAR_OT_project_open_in_browser) bpy.utils.unregister_class(PILLAR_PT_image_custom_properties) del WindowManager.last_blender_cloud_location del WindowManager.blender_sync_status