Compare commits

..

2 Commits

Author SHA1 Message Date
e777d67922 Bumped version to 1.4.4 to release some bug fixes.
This branch doesn't contain Flamenco or Attract, since the project
selection still needs work.
2017-02-21 10:51:23 +01:00
7edeff5ee1 Added script to bump versions in all the right places.
Must be called with major.minor.micro revision number (so 3 components).

Signed-off-by: Sybren A. Stüvel <sybren@stuvel.eu>
2017-02-21 10:49:02 +01:00
26 changed files with 125 additions and 3533 deletions

View File

@@ -1,104 +0,0 @@
# Blender Cloud changelog
## Version 1.9 (2018-09-05)
- Last version to support Blender versions before 2.80!
- Replace BAM with BAT🦇.
- Don't crash the texture browser when an invalid texture is seen.
- Support colour strips as Attract shots.
- Flamenco: allow jobs to be created in 'paused' state.
- Flamenco: only show Flamenco Managers that are linked to the currently selected project.
## Version 1.8 (2018-01-03)
- Distinguish between 'please subscribe' (to get a new subscription) and 'please renew' (to renew an
existing subscription).
- When re-opening the Texture Browser it now opens in the same folder as where it was when closed.
- In the texture browser, draw the components of the texture (i.e. which map types are available),
such as 'bump, normal, specular'.
- Use Interface Scale setting from user preferences to draw the Texture Browser text.
- Store project-specific settings in the preferences, such as filesystem paths, for each project,
and restore those settings when the project is selected again. Does not touch settings that
haven't been set for the newly selected project. These settings are only saved when a setting
is updated, so to save your current settings need to update a single setting; this saves all
settings for the project.
- Added button in the User Preferences to open a Cloud project in your webbrowser.
## Version 1.7.5 (2017-10-06)
- Sorting the project list alphabetically.
- Renamed 'Job File Path' to 'Job Storage Path' so it's more explicit.
- Allow overriding the render output path on a per-scene basis.
## Version 1.7.4 (2017-09-05)
- Fix [T52621](https://developer.blender.org/T52621): Fixed class name collision upon add-on
registration. This is checked since Blender 2.79.
- Fix [T48852](https://developer.blender.org/T48852): Screenshot no longer shows "Communicating with
Blender Cloud".
## Version 1.7.3 (2017-08-08)
- Default to scene frame range when no frame range is given.
- Refuse to render on Flamenco before blend file is saved at least once.
- Fixed some Windows-specific issues.
## Version 1.7.2 (2017-06-22)
- Fixed compatibility with Blender 2.78c.
## Version 1.7.1 (2017-06-13)
- Fixed asyncio issues on Windows
## Version 1.7.0 (2017-06-09)
- Fixed reloading after upgrading from 1.4.4 (our last public release).
- Fixed bug handling a symlinked project path.
- Added support for Manager-defined path replacement variables.
## Version 1.6.4 (2017-04-21)
- Added file exclusion filter for Flamenco. A filter like `*.abc;*.mkv;*.mov` can be
used to prevent certain files from being copied to the job storage directory.
Requires a Blender that is bundled with BAM 1.1.7 or newer.
## Version 1.6.3 (2017-03-21)
- Fixed bug where local project path wasn't shown for projects only set up for Flamenco
(and not Attract).
- Added this CHANGELOG.md file, which will contain user-relevant changes.
## Version 1.6.2 (2017-03-17)
- Flamenco: when opening non-existing file path, open parent instead
- Fix T50954: Improve Blender Cloud add-on project selector
## Version 1.6.1 (2017-03-07)
- Show error in GUI when Blender Cloud is unreachable
- Fixed sample count when using branched path tracing
## Version 1.6.0 (2017-02-14)
- Default to frame chunk size of 1 (instead of 10).
- Turn off "use overwrite" and "use placeholder" for Flamenco blend files.
- Fixed bugs when blendfile is outside the project directory
## Older versions
For the history of older versions, please refer to the
[Git history](https://developer.blender.org/diffusion/BCA/)

View File

@@ -1,57 +0,0 @@
# Flamenco
The Blender Cloud add-on has preliminary support for [Flamenco](https://flamenco.io/).
It requires a project on the [Blender Cloud](https://cloud.blender.org/) that is set up for
Flamenco, and it requires you to be logged in as a user with rights to use Flamenco.
## Quirks
Flamenco support is unpolished, so it has some quirks.
- Project selection happens through the Attract project selector. As a result, you can only
select Attract-enabled projects (even when they are not set up for Flamenco). Be careful
which project you select.
- The top level directory of the project is also set through the Attract properties.
- Defaults are heavily biased for our use in the Blender Institute.
- Settings that should be project-specific are not, i.e. are regular add-on preferences.
- Sending a project to Flamenco will check the "File Extensions" setting in the Output panel,
and save the blend file to the current filename.
## Render job file locations
Rendering via Flamenco roughly comprises of two steps:
1. Packing the file to render with its dependencies, and placing them in the "job file path".
2. Rendering, and placing the output files in the "job output path".
### Job file path
The "job file path" consists of the following path components:
1. The add-on preference "job file path", e.g. `/render/_flamenco/storage`
2. The current date and time, your Blender Cloud username, and the name of the current blend file.
3. The name of the current blend file.
For example:
`/render/_flamenco/storage/2017-01-18-104841.931387-sybren-03_02_A.layout/03_02_A.layout.blend`
### Job output path
The file path of output files consists of the following path components:
1. The add-on preference "job file path", e.g. `/render/agent327/frames`
2. The path of the current blend file, relative to the project directory. The first N components
of this path can be stripped; when N=1 it turns `scenes/03-searching/03_02_A-snooping/` into
`03-searching/03_02_A-snooping/`.
3. The name of the current blend file, without `.blend`.
4. The file name depends on the type of output:
- When rendering to image files: A 5-digit frame number with the required file extension.
- When rendering to a video file: The frame range with the required file extension.
For example:
`/render/agent327/frames/03-searching/03_02_A-snooping/03_02_A.layout/00441.exr`
`/render/agent327/frames/03-searching/03_02_A-snooping/03_02_A.layout/14-51,60-133.mkv`

View File

@@ -20,13 +20,13 @@
bl_info = {
'name': 'Blender Cloud',
"author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis",
'version': (1, 9, 0),
'blender': (2, 80, 0),
'author': 'Sybren A. Stüvel and Francesco Siddi',
'version': (1, 4, 4),
'blender': (2, 77, 0),
'location': 'Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser',
'description': 'Texture library browser and Blender Sync. Requires the Blender ID addon '
'and Blender 2.77a or newer.',
'wiki_url': 'https://wiki.blender.org/index.php/Extensions:2.6/Py/'
'wiki_url': 'http://wiki.blender.org/index.php/Extensions:2.6/Py/'
'Scripts/System/BlenderCloud',
'category': 'System',
}
@@ -65,45 +65,30 @@ def register():
def reload_mod(name):
modname = '%s.%s' % (__name__, name)
try:
old_module = sys.modules[modname]
except KeyError:
# Wasn't loaded before -- can happen after an upgrade.
new_module = importlib.import_module(modname)
else:
new_module = importlib.reload(old_module)
sys.modules[modname] = new_module
return new_module
module = importlib.reload(sys.modules[modname])
sys.modules[modname] = module
return module
reload_mod('blendfile')
reload_mod('home_project')
reload_mod('utils')
reload_mod('pillar')
blender = reload_mod('blender')
async_loop = reload_mod('async_loop')
flamenco = reload_mod('flamenco')
attract = reload_mod('attract')
texture_browser = reload_mod('texture_browser')
settings_sync = reload_mod('settings_sync')
image_sharing = reload_mod('image_sharing')
blender = reload_mod('blender')
project_specific = reload_mod('project_specific')
else:
from . import (blender, texture_browser, async_loop, settings_sync, blendfile, home_project,
image_sharing, attract, flamenco, project_specific)
image_sharing)
async_loop.setup_asyncio_executor()
async_loop.register()
flamenco.register()
attract.register()
texture_browser.register()
blender.register()
settings_sync.register()
image_sharing.register()
blender.register()
project_specific.handle_project_update()
def _monkey_patch_requests():
@@ -124,13 +109,10 @@ def _monkey_patch_requests():
def unregister():
from . import (blender, texture_browser, async_loop, settings_sync, image_sharing, attract,
flamenco)
from . import blender, texture_browser, async_loop, settings_sync, image_sharing
image_sharing.unregister()
attract.unregister()
settings_sync.unregister()
blender.unregister()
texture_browser.unregister()
async_loop.unregister()
flamenco.unregister()

View File

@@ -33,28 +33,18 @@ _loop_kicking_operator_running = False
def setup_asyncio_executor():
"""Sets up AsyncIO to run properly on each platform."""
"""Sets up AsyncIO to run on a single thread.
import sys
This ensures that only one Pillar HTTP call is performed at the same time. Other
calls that could be performed in parallel are queued, and thus we can
reliably cancel them.
"""
if sys.platform == 'win32':
asyncio.get_event_loop().close()
# On Windows, the default event loop is SelectorEventLoop, which does
# not support subprocesses. ProactorEventLoop should be used instead.
# Source: https://docs.python.org/3/library/asyncio-subprocess.html
loop = asyncio.ProactorEventLoop()
asyncio.set_event_loop(loop)
else:
loop = asyncio.get_event_loop()
executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
executor = concurrent.futures.ThreadPoolExecutor()
loop = asyncio.get_event_loop()
loop.set_default_executor(executor)
# loop.set_debug(True)
from . import pillar
# No more than this many Pillar calls should be made simultaneously
pillar.pillar_semaphore = asyncio.Semaphore(3, loop=loop)
def kick_async_loop(*args) -> bool:
"""Performs a single iteration of the asyncio event loop.
@@ -153,7 +143,7 @@ class AsyncLoopModalOperator(bpy.types.Operator):
_loop_kicking_operator_running = True
wm = context.window_manager
self.timer = wm.event_timer_add(0.00001, window=context.window)
self.timer = wm.event_timer_add(0.00001, context.window)
return {'RUNNING_MODAL'}
@@ -192,7 +182,7 @@ class AsyncModalOperatorMixin:
def invoke(self, context, event):
context.window_manager.modal_handler_add(self)
self.timer = context.window_manager.event_timer_add(1 / 15, window=context.window)
self.timer = context.window_manager.event_timer_add(1 / 15, context.window)
self.log.info('Starting')
self._new_async_task(self.async_execute(context))

File diff suppressed because it is too large Load Diff

View File

@@ -1,182 +0,0 @@
# ##### 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 #####
# <pep8 compliant>
import bpy
import logging
import collections
log = logging.getLogger(__name__)
strip_status_colour = {
None: (0.7, 0.7, 0.7),
'approved': (0.6392156862745098, 0.8784313725490196, 0.30196078431372547),
'final': (0.9058823529411765, 0.9607843137254902, 0.8274509803921568),
'in_progress': (1.0, 0.7450980392156863, 0.0),
'on_hold': (0.796078431372549, 0.6196078431372549, 0.08235294117647059),
'review': (0.8941176470588236, 0.9607843137254902, 0.9764705882352941),
'todo': (1.0, 0.5019607843137255, 0.5019607843137255)
}
CONFLICT_COLOUR = (0.576, 0.118, 0.035) # RGB tuple
def get_strip_rectf(strip):
# Get x and y in terms of the grid's frames and channels
x1 = strip.frame_final_start
x2 = strip.frame_final_end
y1 = strip.channel + 0.2
y2 = strip.channel - 0.2 + 1
return x1, y1, x2, y2
def draw_underline_in_strip(strip_coords, pixel_size_x, color):
from bgl import glColor4f, glRectf, glEnable, glDisable, GL_BLEND
import bgl
context = bpy.context
# Strip coords
s_x1, s_y1, s_x2, s_y2 = strip_coords
# be careful not to draw over the current frame line
cf_x = context.scene.frame_current_final
bgl.glPushAttrib(bgl.GL_COLOR_BUFFER_BIT | bgl.GL_LINE_BIT)
glColor4f(*color)
glEnable(GL_BLEND)
bgl.glLineWidth(2)
bgl.glBegin(bgl.GL_LINES)
bgl.glVertex2f(s_x1, s_y1)
if s_x1 < cf_x < s_x2:
# Bad luck, the line passes our strip
bgl.glVertex2f(cf_x - pixel_size_x, s_y1)
bgl.glVertex2f(cf_x + pixel_size_x, s_y1)
bgl.glVertex2f(s_x2, s_y1)
bgl.glEnd()
bgl.glPopAttrib()
def draw_strip_conflict(strip_coords, pixel_size_x):
"""Draws conflicting states between strips."""
import bgl
s_x1, s_y1, s_x2, s_y2 = strip_coords
bgl.glPushAttrib(bgl.GL_COLOR_BUFFER_BIT | bgl.GL_LINE_BIT)
# Always draw the full rectangle, the conflict should be resolved and thus stand out.
bgl.glColor3f(*CONFLICT_COLOUR)
bgl.glLineWidth(2)
bgl.glBegin(bgl.GL_LINE_LOOP)
bgl.glVertex2f(s_x1, s_y1)
bgl.glVertex2f(s_x2, s_y1)
bgl.glVertex2f(s_x2, s_y2)
bgl.glVertex2f(s_x1, s_y2)
bgl.glEnd()
bgl.glPopAttrib()
def draw_callback_px():
context = bpy.context
if not context.scene.sequence_editor:
return
from . import shown_strips
region = context.region
xwin1, ywin1 = region.view2d.region_to_view(0, 0)
xwin2, ywin2 = region.view2d.region_to_view(region.width, region.height)
one_pixel_further_x, one_pixel_further_y = region.view2d.region_to_view(1, 1)
pixel_size_x = one_pixel_further_x - xwin1
strips = shown_strips(context)
for strip in strips:
if not strip.atc_object_id:
continue
# Get corners (x1, y1), (x2, y2) of the strip rectangle in px region coords
strip_coords = get_strip_rectf(strip)
# check if any of the coordinates are out of bounds
if strip_coords[0] > xwin2 or strip_coords[2] < xwin1 or strip_coords[1] > ywin2 or \
strip_coords[3] < ywin1:
continue
# Draw
status = strip.atc_status
if status in strip_status_colour:
color = strip_status_colour[status]
else:
color = strip_status_colour[None]
alpha = 1.0 if strip.atc_is_synced else 0.5
draw_underline_in_strip(strip_coords, pixel_size_x, color + (alpha,))
if strip.atc_is_synced and strip.atc_object_id_conflict:
draw_strip_conflict(strip_coords, pixel_size_x)
def tag_redraw_all_sequencer_editors():
context = bpy.context
# Py cant access notifiers
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.type == 'SEQUENCE_EDITOR':
for region in area.regions:
if region.type == 'WINDOW':
region.tag_redraw()
# This is a list so it can be changed instead of set
# if it is only changed, it does not have to be declared as a global everywhere
cb_handle = []
def callback_enable():
if cb_handle:
return
cb_handle[:] = bpy.types.SpaceSequenceEditor.draw_handler_add(
draw_callback_px, (), 'WINDOW', 'POST_VIEW'),
tag_redraw_all_sequencer_editors()
def callback_disable():
if not cb_handle:
return
try:
bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW')
except ValueError:
# Thrown when already removed.
pass
cb_handle.clear()
tag_redraw_all_sequencer_editors()

View File

@@ -20,43 +20,31 @@
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
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty
import rna_prop_ui
from . import pillar, async_loop, flamenco, project_specific
from .utils import pyside_cache, redraw
from . import pillar
PILLAR_WEB_SERVER_URL = os.environ.get('BCLOUD_SERVER', 'https://cloud.blender.org/')
PILLAR_SERVER_URL = '%sapi/' % PILLAR_WEB_SERVER_URL
PILLAR_SERVER_URL = 'https://cloud.blender.org/api/'
# PILLAR_SERVER_URL = 'http://pillar:5001/api/'
ADDON_NAME = 'blender_cloud'
log = logging.getLogger(__name__)
icons = None
@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}
def redraw(self, context):
context.area.tag_redraw()
@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:
@@ -116,74 +104,10 @@ class SyncStatusProperties(PropertyGroup):
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
# The following two properties are read-only to limit the scope of the
# addon and allow for proper testing within this scope.
pillar_server = StringProperty(
name='Blender Cloud Server',
@@ -193,67 +117,16 @@ class BlenderCloudPreferences(AddonPreferences):
)
local_texture_dir = StringProperty(
name='Default Blender Cloud Texture Storage Directory',
name='Default Blender Cloud texture storage directory',
subtype='DIR_PATH',
default='//textures')
open_browser_after_share = BoolProperty(
name='Open Browser after Sharing File',
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='Filter like "*.abc;*.mkv" to prevent certain files to be 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_open_browser_after_submit = BoolProperty(
name='Open Browser after Submitting Job',
description='When enabled, Blender will open a webbrowser',
default=True,
)
def draw(self, context):
import textwrap
@@ -267,8 +140,8 @@ class BlenderCloudPreferences(AddonPreferences):
blender_id_profile = None
else:
blender_id_profile = blender_id.get_active_profile()
if blender_id is None:
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'
@@ -310,8 +183,8 @@ class BlenderCloudPreferences(AddonPreferences):
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'))
row = bsync_box.row().split(percentage=0.33)
row.label('Blender Sync with Blender Cloud', icon_value=icon('CLOUD'))
icon_for_level = {
'INFO': 'NONE',
@@ -321,7 +194,7 @@ class BlenderCloudPreferences(AddonPreferences):
}
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)
message_container.label(bss.message, icon=msg_icon)
sub = bsync_box.column()
@@ -331,21 +204,10 @@ class BlenderCloudPreferences(AddonPreferences):
# Image Share stuff
share_box = layout.box()
share_box.label(text='Image Sharing on Blender Cloud', icon_value=icon('CLOUD'))
share_box.label('Image Sharing on Blender Cloud', icon_value=icon('CLOUD'))
texture_box.enabled = msg_icon != 'ERROR'
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')
@@ -353,7 +215,7 @@ class BlenderCloudPreferences(AddonPreferences):
layout.enabled = bss.status in {'NONE', 'IDLE'}
buttons = layout.column()
row_buttons = buttons.row().split(**factor(0.5))
row_buttons = buttons.row().split(percentage=0.5)
row_push = row_buttons.row()
row_pull = row_buttons.row(align=True)
@@ -378,102 +240,7 @@ class BlenderCloudPreferences(AddonPreferences):
text='',
icon='DOTSDOWN').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 or not project:
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_open_browser_after_submit')
row_pull.label('Cloud Sync is running.')
class PillarCredentialsUpdate(pillar.PillarOperatorMixin,
@@ -481,7 +248,6 @@ class PillarCredentialsUpdate(pillar.PillarOperatorMixin,
"""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)
@@ -528,7 +294,6 @@ 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
@@ -539,108 +304,6 @@ class PILLAR_OT_subscribe(Operator):
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."""
@@ -690,13 +353,10 @@ def icon(icon_name: str) -> int:
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()
@@ -725,13 +385,10 @@ def register():
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

View File

@@ -1,943 +0,0 @@
# ##### 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 #####
"""Flamenco interface.
The preferences are managed blender.py, the rest of the Flamenco-specific stuff is here.
"""
import functools
import logging
import os
from pathlib import Path, PurePath
import typing
if "bpy" in locals():
import importlib
try:
bat_interface = importlib.reload(bat_interface)
sdk = importlib.reload(sdk)
blender = importlib.reload(blender)
except NameError:
from . import bat_interface, sdk
from .. import blender
else:
from . import bat_interface, sdk
from .. import blender
import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
from .. import async_loop, pillar, project_specific
from ..utils import pyside_cache, redraw
log = logging.getLogger(__name__)
# Global flag used to determine whether panels etc. can be drawn.
flamenco_is_active = False
@pyside_cache('manager')
def available_managers(self, context):
"""Returns the list of items used by a manager-selector EnumProperty."""
from ..blender import preferences
mngrs = preferences().flamenco_manager.available_managers
if not mngrs:
return [('', 'No managers available in your Blender Cloud', '')]
return [(p['_id'], p['name'], '') for p in mngrs]
class FlamencoManagerGroup(PropertyGroup):
manager = EnumProperty(
items=available_managers,
name='Flamenco Manager',
description='Which Flamenco Manager to use for jobs',
update=project_specific.store,
)
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 Flamenco managers from Blender Cloud'),
],
name='status',
update=redraw)
# List of managers is stored in 'available_managers' ID property,
# because I don't know how to store a variable list of strings in a proper RNA property.
@property
def available_managers(self) -> list:
return self.get('available_managers', [])
@available_managers.setter
def available_managers(self, new_managers):
self['available_managers'] = new_managers
project_specific.store()
class FlamencoPollMixin:
@classmethod
def poll(cls, context):
return flamenco_is_active
class FLAMENCO_OT_fmanagers(async_loop.AsyncModalOperatorMixin,
pillar.AuthenticatedPillarOperatorMixin,
FlamencoPollMixin,
Operator):
"""Fetches the Flamenco Managers available to the user"""
bl_idname = 'flamenco.managers'
bl_label = 'Fetch available Flamenco Managers'
stop_upon_exception = True
log = logging.getLogger('%s.FLAMENCO_OT_fmanagers' % __name__)
@property
def mypref(self) -> FlamencoManagerGroup:
from ..blender import preferences
return preferences().flamenco_manager
async def async_execute(self, context):
if not await self.authenticate(context):
return
from .sdk import Manager
from ..pillar import pillar_call
from ..blender import preferences
prefs = preferences()
self.log.info('Going to fetch managers for user %s', self.user_id)
self.mypref.status = 'FETCHING'
params = {'where': '{"projects" : "%s"}' % prefs.project.project}
managers = await pillar_call(Manager.all, params)
# We need to convert to regular dicts before storing in ID properties.
# Also don't store more properties than we need.
as_list = [{'_id': p['_id'], 'name': p['name']} for p in managers['_items']]
self.mypref.available_managers = as_list
self.quit()
def quit(self):
self.mypref.status = 'IDLE'
super().quit()
class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
pillar.AuthenticatedPillarOperatorMixin,
FlamencoPollMixin,
Operator):
"""Performs a Blender render on Flamenco."""
bl_idname = 'flamenco.render'
bl_label = 'Render on Flamenco'
bl_description = __doc__.rstrip('.')
stop_upon_exception = True
log = logging.getLogger('%s.FLAMENCO_OT_render' % __name__)
async def async_execute(self, context):
# Refuse to start if the file hasn't been saved. It's okay if
# it's dirty, but we do need a filename and a location.
if not os.path.exists(context.blend_data.filepath):
self.report({'ERROR'}, 'Please save your Blend file before using '
'the Blender Cloud addon.')
self.quit()
return
if not await self.authenticate(context):
return
import pillarsdk.exceptions
from .sdk import Manager
from ..pillar import pillar_call
from ..blender import preferences
scene = context.scene
# Save to a different file, specifically for Flamenco.
context.window_manager.flamenco_status = 'SAVING'
filepath = await self._save_blendfile(context)
# Determine where the render output will be stored.
render_output = render_output_path(context, filepath)
if render_output is None:
self.report({'ERROR'}, 'Current file is outside of project path.')
self.quit()
return
self.log.info('Will output render files to %s', render_output)
# BAT-pack the files to the destination directory.
outdir, outfile, missing_sources = await self.bat_pack(filepath)
if not outfile:
return
# Fetch Manager for doing path replacement.
self.log.info('Going to fetch manager %s', self.user_id)
prefs = preferences()
manager_id = prefs.flamenco_manager.manager
try:
manager = await pillar_call(Manager.find, manager_id)
except pillarsdk.exceptions.ResourceNotFound:
self.report({'ERROR'}, 'Manager %s not found, refresh your managers in '
'the Blender Cloud add-on settings.' % manager_id)
self.quit()
return
# Create the job at Flamenco Server.
context.window_manager.flamenco_status = 'COMMUNICATING'
frame_range = scene.flamenco_render_frame_range.strip() or scene_frame_range(context)
settings = {'blender_cmd': '{blender}',
'chunk_size': scene.flamenco_render_fchunk_size,
'filepath': manager.replace_path(outfile),
'frames': frame_range,
'render_output': manager.replace_path(render_output),
}
# Add extra settings specific to the job type
if scene.flamenco_render_job_type == 'blender-render-progressive':
if scene.cycles.progressive == 'BRANCHED_PATH':
samples = scene.cycles.aa_samples
else:
samples = scene.cycles.samples
if scene.cycles.use_square_samples:
samples **= 2
settings['cycles_num_chunks'] = scene.flamenco_render_schunk_count
settings['cycles_sample_count'] = samples
settings['format'] = 'EXR'
try:
job_info = await create_job(self.user_id,
prefs.project.project,
manager_id,
scene.flamenco_render_job_type,
settings,
'Render %s' % filepath.name,
priority=scene.flamenco_render_job_priority,
start_paused=scene.flamenco_start_paused)
except Exception as ex:
self.report({'ERROR'}, 'Error creating Flamenco job: %s' % ex)
self.quit()
return
# Store the job ID in a file in the output dir.
with open(str(outdir / 'jobinfo.json'), 'w', encoding='utf8') as outfile:
import json
job_info['missing_files'] = [str(mf) for mf in missing_sources]
json.dump(job_info, outfile, sort_keys=True, indent=4)
# We can now remove the local copy we made with bpy.ops.wm.save_as_mainfile().
# Strictly speaking we can already remove it after the BAT-pack, but it may come in
# handy in case of failures.
try:
self.log.info('Removing temporary file %s', filepath)
filepath.unlink()
except Exception as ex:
self.report({'ERROR'}, 'Unable to remove file: %s' % ex)
self.quit()
return
if prefs.flamenco_open_browser_after_submit:
import webbrowser
from urllib.parse import urljoin
from ..blender import PILLAR_WEB_SERVER_URL
url = urljoin(PILLAR_WEB_SERVER_URL, '/flamenco/jobs/%s/redir' % job_info['_id'])
webbrowser.open_new_tab(url)
# Do a final report.
if missing_sources:
names = (ms.name for ms in missing_sources)
self.report({'WARNING'}, 'Flamenco job created with missing files: %s' %
'; '.join(names))
else:
self.report({'INFO'}, 'Flamenco job created.')
self.quit()
def quit(self):
if bpy.context.window_manager.flamenco_status != 'ABORTED':
bpy.context.window_manager.flamenco_status = 'DONE'
super().quit()
async def _save_blendfile(self, context):
"""Save to a different file, specifically for Flamenco.
We shouldn't overwrite the artist's file.
We can compress, since this file won't be managed by SVN and doesn't need diffability.
"""
render = context.scene.render
# Remember settings we need to restore after saving.
old_use_file_extension = render.use_file_extension
old_use_overwrite = render.use_overwrite
old_use_placeholder = render.use_placeholder
try:
# The file extension should be determined by the render settings, not necessarily
# by the setttings in the output panel.
render.use_file_extension = True
# Rescheduling should not overwrite existing frames.
render.use_overwrite = False
render.use_placeholder = False
filepath = Path(context.blend_data.filepath).with_suffix('.flamenco.blend')
self.log.info('Saving copy to temporary file %s', filepath)
bpy.ops.wm.save_as_mainfile(filepath=str(filepath),
compress=True,
copy=True)
finally:
# Restore the settings we changed, even after an exception.
render.use_file_extension = old_use_file_extension
render.use_overwrite = old_use_overwrite
render.use_placeholder = old_use_placeholder
return filepath
async def bat_pack(self, filepath: Path) -> (Path, typing.Optional[Path], typing.List[Path]):
"""BAT-packs the blendfile to the destination directory.
Returns the path of the destination blend file.
:param filepath: the blend file to pack (i.e. the current blend file)
:returns: the destination directory, the destination blend file or None
if there were errors BAT-packing, and a list of missing paths.
"""
from datetime import datetime
from ..blender import preferences
prefs = preferences()
# Create a unique directory that is still more or less identifyable.
# This should work better than a random ID.
unique_dir = '%s-%s-%s' % (datetime.now().isoformat('-').replace(':', ''),
self.db_user['username'],
filepath.stem)
outdir = Path(prefs.flamenco_job_file_path) / unique_dir
proj_abspath = bpy.path.abspath(prefs.cloud_project_local_path)
projdir = Path(proj_abspath).resolve()
exclusion_filter = (prefs.flamenco_exclude_filter or '').strip()
self.log.debug('outdir : %s', outdir)
self.log.debug('projdir: %s', projdir)
try:
outdir.mkdir(parents=True)
except Exception as ex:
self.log.exception('Unable to create output path %s', outdir)
self.report({'ERROR'}, 'Unable to create output path: %s' % ex)
self.quit()
return outdir, None, []
try:
outfile, missing_sources = await bat_interface.copy(
bpy.context, filepath, projdir, outdir, exclusion_filter)
except bat_interface.FileTransferError as ex:
self.log.error('Could not transfer %d files, starting with %s',
len(ex.files_remaining), ex.files_remaining[0])
self.report({'ERROR'}, 'Unable to transfer %d files' % len(ex.files_remaining))
self.quit()
return outdir, None, []
except bat_interface.Aborted:
self.log.warning('BAT Pack was aborted')
self.report({'WARNING'}, 'Aborted Flamenco file packing/transferring')
self.quit()
return outdir, None, []
bpy.context.window_manager.flamenco_status = 'DONE'
return outdir, outfile, missing_sources
def scene_frame_range(context) -> str:
"""Returns the frame range string for the current scene."""
s = context.scene
return '%i-%i' % (s.frame_start, s.frame_end)
class FLAMENCO_OT_scene_to_frame_range(FlamencoPollMixin, Operator):
"""Sets the scene frame range as the Flamenco render frame range."""
bl_idname = 'flamenco.scene_to_frame_range'
bl_label = 'Sets the scene frame range as the Flamenco render frame range'
bl_description = __doc__.rstrip('.')
def execute(self, context):
context.scene.flamenco_render_frame_range = scene_frame_range(context)
return {'FINISHED'}
class FLAMENCO_OT_copy_files(Operator,
FlamencoPollMixin,
async_loop.AsyncModalOperatorMixin):
"""Uses BAT to copy the current blendfile + dependencies to the target directory.
This operator is not used directly, but can be useful for testing.
"""
bl_idname = 'flamenco.copy_files'
bl_label = 'Copy files to target'
bl_description = __doc__.rstrip('.')
stop_upon_exception = True
async def async_execute(self, context) -> None:
from pathlib import Path
from ..blender import preferences
prefs = preferences()
exclusion_filter = (prefs.flamenco_exclude_filter or '').strip()
storage_path = prefs.flamenco_job_file_path # type: str
try:
outpath, missing_sources = await bat_interface.copy(
context,
Path(context.blend_data.filepath),
Path(prefs.cloud_project_local_path),
Path(storage_path),
exclusion_filter
)
except bat_interface.FileTransferError as ex:
self.log.error('Could not transfer %d files, starting with %s',
len(ex.files_remaining), ex.files_remaining[0])
self.report({'ERROR'}, 'Unable to transfer %d files' % len(ex.files_remaining))
self.quit()
return
except bat_interface.Aborted:
self.log.warning('BAT Pack was aborted')
self.report({'WARNING'}, 'Aborted Flamenco file packing/transferring')
self.quit()
return
if missing_sources:
names = (ms.name for ms in missing_sources)
self.report({'ERROR'}, 'Missing source files: %s' % '; '.join(names))
else:
self.report({'INFO'}, 'Written %s' % outpath)
context.window_manager.flamenco_status = 'DONE'
self.quit()
class FLAMENCO_OT_abort(Operator, FlamencoPollMixin):
"""Aborts a running Flamenco file packing/transfer operation."""
bl_idname = 'flamenco.abort'
bl_label = 'Abort'
bl_description = __doc__.rstrip('.')
@classmethod
def poll(cls, context):
return super().poll(context) and context.window_manager.flamenco_status != 'ABORTING'
def execute(self, context):
context.window_manager.flamenco_status = 'ABORTING'
bat_interface.abort()
return {'FINISHED'}
class FLAMENCO_OT_explore_file_path(FlamencoPollMixin,
Operator):
"""Opens the Flamenco job storage path in a file explorer.
If the path cannot be found, this operator tries to open its parent.
"""
bl_idname = 'flamenco.explore_file_path'
bl_label = 'Open in file explorer'
bl_description = __doc__.rstrip('.')
path = StringProperty(name='Path', description='Path to explore', subtype='DIR_PATH')
def execute(self, context):
import platform
import pathlib
# Possibly open a parent of the path
to_open = pathlib.Path(self.path)
while to_open.parent != to_open: # while we're not at the root
if to_open.exists():
break
to_open = to_open.parent
else:
self.report({'ERROR'}, 'Unable to open %s or any of its parents.' % self.path)
return {'CANCELLED'}
to_open = str(to_open)
if platform.system() == "Windows":
import os
os.startfile(to_open)
elif platform.system() == "Darwin":
import subprocess
subprocess.Popen(["open", to_open])
else:
import subprocess
subprocess.Popen(["xdg-open", to_open])
return {'FINISHED'}
class FLAMENCO_OT_enable_output_path_override(Operator):
"""Enables the 'override output path' setting."""
bl_idname = 'flamenco.enable_output_path_override'
bl_label = 'Enable Overriding of Output Path'
bl_description = 'Click to specify a non-default Output Path for this particular job'
def execute(self, context):
context.scene.flamenco_do_override_output_path = True
return {'FINISHED'}
class FLAMENCO_OT_disable_output_path_override(Operator):
"""Disables the 'override output path' setting."""
bl_idname = 'flamenco.disable_output_path_override'
bl_label = 'disable Overriding of Output Path'
bl_description = 'Click to use the default Output Path'
def execute(self, context):
context.scene.flamenco_do_override_output_path = False
return {'FINISHED'}
async def create_job(user_id: str,
project_id: str,
manager_id: str,
job_type: str,
job_settings: dict,
job_name: str = None,
*,
priority: int = 50,
job_description: str = None,
start_paused=False) -> dict:
"""Creates a render job at Flamenco Server, returning the job object as dictionary."""
import json
from .sdk import Job
from ..pillar import pillar_call
job_attrs = {
'status': 'queued',
'priority': priority,
'name': job_name,
'settings': job_settings,
'job_type': job_type,
'user': user_id,
'manager': manager_id,
'project': project_id,
}
if job_description:
job_attrs['description'] = job_description
if start_paused:
job_attrs['start_paused'] = True
log.info('Going to create Flamenco job:\n%s',
json.dumps(job_attrs, indent=4, sort_keys=True))
job = Job(job_attrs)
await pillar_call(job.create)
log.info('Job created succesfully: %s', job._id)
return job.to_dict()
def is_image_type(render_output_type: str) -> bool:
"""Determines whether the render output type is an image (True) or video (False)."""
# This list is taken from rna_scene.c:273, rna_enum_image_type_items.
video_types = {'AVI_JPEG', 'AVI_RAW', 'FRAMESERVER', 'FFMPEG', 'QUICKTIME'}
return render_output_type not in video_types
@functools.lru_cache(1)
def _render_output_path(
local_project_path: str,
blend_filepath: Path,
flamenco_job_output_strip_components: int,
flamenco_job_output_path: str,
render_image_format: str,
flamenco_render_frame_range: str,
include_rel_path: bool = True,
) -> typing.Optional[PurePath]:
"""Cached version of render_output_path()
This ensures that redraws of the Flamenco Render and Add-on preferences panels
is fast.
"""
try:
project_path = Path(bpy.path.abspath(local_project_path)).resolve()
except FileNotFoundError:
# Path.resolve() will raise a FileNotFoundError if the project path doesn't exist.
return None
try:
blend_abspath = blend_filepath.resolve().absolute()
except FileNotFoundError:
# Path.resolve() will raise a FileNotFoundError if the path doesn't exist.
return None
try:
proj_rel = blend_abspath.parent.relative_to(project_path)
except ValueError:
return None
output_top = PurePath(flamenco_job_output_path)
# Strip off '.flamenco' too; we use 'xxx.flamenco.blend' as job file, but
# don't want to have all the output paths ending in '.flamenco'.
stem = blend_filepath.stem
if stem.endswith('.flamenco'):
stem = stem[:-9]
if include_rel_path:
rel_parts = proj_rel.parts[flamenco_job_output_strip_components:]
dir_components = output_top.joinpath(*rel_parts) / stem
else:
dir_components = output_top
# Blender will have to append the file extensions by itself.
if is_image_type(render_image_format):
return dir_components / '######'
return dir_components / flamenco_render_frame_range
def render_output_path(context, filepath: Path = None) -> typing.Optional[PurePath]:
"""Returns the render output path to be sent to Flamenco.
:param context: the Blender context (used to find Flamenco preferences etc.)
:param filepath: the Path of the blend file to render, or None for the current file.
Returns None when the current blend file is outside the project path.
"""
from ..blender import preferences
scene = context.scene
prefs = preferences()
if filepath is None:
filepath = Path(context.blend_data.filepath)
if scene.flamenco_do_override_output_path:
job_output_path = scene.flamenco_override_output_path
else:
job_output_path = prefs.flamenco_job_output_path
return _render_output_path(
prefs.cloud_project_local_path,
filepath,
prefs.flamenco_job_output_strip_components,
job_output_path,
scene.render.image_settings.file_format,
scene.flamenco_render_frame_range,
include_rel_path=not scene.flamenco_do_override_output_path,
)
class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin):
bl_label = "Flamenco Render"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "render"
bl_options = {'DEFAULT_CLOSED'}
def draw(self, context):
layout = self.layout
from ..blender import preferences
prefs = preferences()
labeled_row = layout.split(**blender.factor(0.25), align=True)
labeled_row.label(text='Manager:')
prop_btn_row = labeled_row.row(align=True)
bcp = prefs.flamenco_manager
if bcp.status in {'NONE', 'IDLE'}:
if not bcp.available_managers or not bcp.manager:
prop_btn_row.operator('flamenco.managers',
text='Find Flamenco Managers',
icon='FILE_REFRESH')
else:
prop_btn_row.prop(bcp, 'manager', text='')
prop_btn_row.operator('flamenco.managers',
text='',
icon='FILE_REFRESH')
else:
prop_btn_row.label(text='Fetching available managers.')
labeled_row = layout.split(**blender.factor(0.25), align=True)
labeled_row.label(text='Job Type:')
labeled_row.prop(context.scene, 'flamenco_render_job_type', text='')
labeled_row = layout.split(**blender.factor(0.25), align=True)
labeled_row.label(text='Frame Range:')
prop_btn_row = labeled_row.row(align=True)
prop_btn_row.prop(context.scene, 'flamenco_render_frame_range', text='')
prop_btn_row.operator('flamenco.scene_to_frame_range', text='', icon='ARROW_LEFTRIGHT')
layout.prop(context.scene, 'flamenco_render_job_priority')
layout.prop(context.scene, 'flamenco_render_fchunk_size')
layout.prop(context.scene, 'flamenco_start_paused')
if getattr(context.scene, 'flamenco_render_job_type', None) == 'blender-render-progressive':
layout.prop(context.scene, 'flamenco_render_schunk_count')
paths_layout = layout.column(align=True)
labeled_row = paths_layout.split(**blender.factor(0.25), align=True)
labeled_row.label(text='Storage:')
prop_btn_row = labeled_row.row(align=True)
prop_btn_row.label(text=prefs.flamenco_job_file_path)
props = prop_btn_row.operator(FLAMENCO_OT_explore_file_path.bl_idname,
text='', icon='DISK_DRIVE')
props.path = prefs.flamenco_job_file_path
render_output = render_output_path(context)
if render_output is None:
paths_layout.label(text='Unable to render with Flamenco, outside of project directory.')
return
labeled_row = paths_layout.split(**blender.factor(0.25), align=True)
labeled_row.label(text='Output:')
prop_btn_row = labeled_row.row(align=True)
if context.scene.flamenco_do_override_output_path:
prop_btn_row.prop(context.scene, 'flamenco_override_output_path', text='')
op = FLAMENCO_OT_disable_output_path_override.bl_idname
icon = 'X'
else:
prop_btn_row.label(text=str(render_output))
op = FLAMENCO_OT_enable_output_path_override.bl_idname
icon = 'GREASEPENCIL'
prop_btn_row.operator(op, icon=icon, text='')
props = prop_btn_row.operator(FLAMENCO_OT_explore_file_path.bl_idname,
text='', icon='DISK_DRIVE')
props.path = str(render_output.parent)
if context.scene.flamenco_do_override_output_path:
labeled_row = paths_layout.split(**blender.factor(0.25), align=True)
labeled_row.label(text='Effective Output Path:')
labeled_row.label(text=str(render_output))
# Show current status of Flamenco.
flamenco_status = context.window_manager.flamenco_status
if flamenco_status in {'IDLE', 'ABORTED', 'DONE'}:
layout.operator(FLAMENCO_OT_render.bl_idname,
text='Render on Flamenco',
icon='RENDER_ANIMATION')
if bpy.app.debug:
layout.operator(FLAMENCO_OT_copy_files.bl_idname)
elif flamenco_status == 'INVESTIGATING':
row = layout.row(align=True)
row.label(text='Investigating your files')
row.operator(FLAMENCO_OT_abort.bl_idname, text='', icon='CANCEL')
elif flamenco_status == 'COMMUNICATING':
layout.label(text='Communicating with Flamenco Server')
elif flamenco_status == 'ABORTING':
row = layout.row(align=True)
row.label(text='Aborting, please wait.')
row.operator(FLAMENCO_OT_abort.bl_idname, text='', icon='CANCEL')
if flamenco_status == 'TRANSFERRING':
row = layout.row(align=True)
row.prop(context.window_manager, 'flamenco_progress',
text=context.window_manager.flamenco_status_txt)
row.operator(FLAMENCO_OT_abort.bl_idname, text='', icon='CANCEL')
elif flamenco_status != 'IDLE' and context.window_manager.flamenco_status_txt:
layout.label(text=context.window_manager.flamenco_status_txt)
def activate():
"""Activates draw callbacks, menu items etc. for Flamenco."""
global flamenco_is_active
log.info('Activating Flamenco')
flamenco_is_active = True
_render_output_path.cache_clear()
def deactivate():
"""Deactivates draw callbacks, menu items etc. for Flamenco."""
global flamenco_is_active
log.info('Deactivating Flamenco')
flamenco_is_active = False
_render_output_path.cache_clear()
def flamenco_do_override_output_path_updated(scene, context):
"""Set the override paths to the default, if not yet set."""
# Only set a default when enabling the override.
if not scene.flamenco_do_override_output_path:
return
# Don't overwrite existing setting.
if scene.flamenco_override_output_path:
return
from ..blender import preferences
scene.flamenco_override_output_path = preferences().flamenco_job_output_path
log.info('Setting Override Output Path to %s', scene.flamenco_override_output_path)
# FlamencoManagerGroup needs to be registered before classes that use it.
_rna_classes = [FlamencoManagerGroup]
_rna_classes.extend(
cls for cls in locals().values()
if (isinstance(cls, type)
and cls.__name__.startswith('FLAMENCO')
and cls not in _rna_classes)
)
def register():
from ..utils import redraw
for cls in _rna_classes:
bpy.utils.register_class(cls)
scene = bpy.types.Scene
scene.flamenco_render_fchunk_size = IntProperty(
name='Frame Chunk Size',
description='Maximum number of frames to render per task',
min=1,
default=1,
)
scene.flamenco_render_schunk_count = IntProperty(
name='Number of Sample Chunks',
description='Number of Cycles samples chunks to use per frame',
min=2,
default=3,
soft_max=10,
)
scene.flamenco_render_frame_range = StringProperty(
name='Frame Range',
description='Frames to render, in "printer range" notation'
)
scene.flamenco_render_job_type = EnumProperty(
name='Job Type',
items=[
('blender-render', 'Simple Render', 'Simple frame-by-frame render'),
('blender-render-progressive', 'Progressive Render',
'Each frame is rendered multiple times with different Cycles sample chunks, then combined'),
]
)
scene.flamenco_start_paused = BoolProperty(
name='Start Paused',
description="When enabled, the job will be created in 'paused' state, rather than"
" 'queued'. The job will need manual queueing before it will start",
default=False,
)
scene.flamenco_render_job_priority = IntProperty(
name='Job Priority',
min=0,
default=50,
max=100,
description='Higher numbers mean higher priority'
)
scene.flamenco_do_override_output_path = BoolProperty(
name='Override Output Path for this Job',
description='When enabled, allows you to specify a non-default Output path '
'for this particular job',
default=False,
update=flamenco_do_override_output_path_updated
)
scene.flamenco_override_output_path = StringProperty(
name='Override Output Path',
description='Path where to store output files, should be accessible for Workers',
subtype='DIR_PATH',
default='')
bpy.types.WindowManager.flamenco_status = EnumProperty(
items=[
('IDLE', 'IDLE', 'Not doing anything.'),
('SAVING', 'SAVING', 'Saving your file.'),
('INVESTIGATING', 'INVESTIGATING', 'Finding all dependencies.'),
('TRANSFERRING', 'TRANSFERRING', 'Transferring all dependencies.'),
('COMMUNICATING', 'COMMUNICATING', 'Communicating with Flamenco Server.'),
('DONE', 'DONE', 'Not doing anything, but doing something earlier.'),
('ABORTING', 'ABORTING', 'User requested we stop doing something.'),
('ABORTED', 'ABORTED', 'We stopped doing something.'),
],
name='flamenco_status',
default='IDLE',
description='Current status of the Flamenco add-on',
update=redraw)
bpy.types.WindowManager.flamenco_status_txt = StringProperty(
name='Flamenco Status',
default='',
description='Textual description of what Flamenco is doing',
update=redraw)
bpy.types.WindowManager.flamenco_progress = IntProperty(
name='Flamenco Progress',
default=0,
description='File transfer progress',
subtype='PERCENTAGE',
min=0,
max=100,
update=redraw)
def unregister():
deactivate()
for cls in _rna_classes:
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
log.warning('Unable to unregister class %r, probably already unregistered', cls)
for name in ('flamenco_render_fchunk_size',
'flamenco_render_schunk_count',
'flamenco_render_frame_range',
'flamenco_render_job_type',
'flamenco_start_paused',
'flamenco_render_job_priority',
'flamenco_do_override_output_path',
'flamenco_override_output_path'):
try:
delattr(bpy.types.Scene, name)
except AttributeError:
pass
try:
del bpy.types.WindowManager.flamenco_status
except AttributeError:
pass

View File

@@ -1,148 +0,0 @@
"""BAT🦇 packing interface for Flamenco."""
import asyncio
import logging
import threading
import typing
import pathlib
import bpy
from blender_asset_tracer import pack
from blender_asset_tracer.pack import progress, transfer
log = logging.getLogger(__name__)
_running_packer = None # type: pack.Packer
_packer_lock = threading.RLock()
# For using in other parts of the add-on, so only this file imports BAT.
Aborted = pack.Aborted
FileTransferError = transfer.FileTransferError
class BatProgress(progress.Callback):
"""Report progress of BAT Packing to the UI.
Uses asyncio.run_coroutine_threadsafe() to ensure the UI is only updated
from the main thread. This is required since we run the BAT Pack in a
background thread.
"""
def __init__(self) -> None:
super().__init__()
self.loop = asyncio.get_event_loop()
def _set_attr(self, attr: str, value):
async def do_it():
setattr(bpy.context.window_manager, attr, value)
asyncio.run_coroutine_threadsafe(do_it(), loop=self.loop)
def _txt(self, msg: str):
"""Set a text in a thread-safe way."""
self._set_attr('flamenco_status_txt', msg)
def _status(self, status: str):
"""Set the flamenco_status property in a thread-safe way."""
self._set_attr('flamenco_status', status)
def _progress(self, progress: int):
"""Set the flamenco_progress property in a thread-safe way."""
self._set_attr('flamenco_progress', progress)
def pack_start(self) -> None:
self._txt('Starting BAT Pack operation')
def pack_done(self,
output_blendfile: pathlib.Path,
missing_files: typing.Set[pathlib.Path]) -> None:
if missing_files:
self._txt('There were %d missing files' % len(missing_files))
else:
self._txt('Pack of %s done' % output_blendfile.name)
def pack_aborted(self):
self._txt('Aborted')
self._status('ABORTED')
def trace_blendfile(self, filename: pathlib.Path) -> None:
"""Called for every blendfile opened when tracing dependencies."""
self._txt('Inspecting %s' % filename.name)
def trace_asset(self, filename: pathlib.Path) -> None:
if filename.stem == '.blend':
return
self._txt('Found asset %s' % filename.name)
def rewrite_blendfile(self, orig_filename: pathlib.Path) -> None:
self._txt('Rewriting %s' % orig_filename.name)
def transfer_file(self, src: pathlib.Path, dst: pathlib.Path) -> None:
self._txt('Transferring %s' % src.name)
def transfer_file_skipped(self, src: pathlib.Path, dst: pathlib.Path) -> None:
self._txt('Skipped %s' % src.name)
def transfer_progress(self, total_bytes: int, transferred_bytes: int) -> None:
self._progress(round(100 * transferred_bytes / total_bytes))
def missing_file(self, filename: pathlib.Path) -> None:
# TODO(Sybren): report missing files in a nice way
pass
async def copy(context,
base_blendfile: pathlib.Path,
project: pathlib.Path,
target: pathlib.Path,
exclusion_filter: str) -> typing.Tuple[pathlib.Path, typing.Set[pathlib.Path]]:
"""Use BAT🦇 to copy the given file and dependencies to the target location.
:raises: FileTransferError if a file couldn't be transferred.
:returns: the path of the packed blend file, and a set of missing sources.
"""
global _running_packer
loop = asyncio.get_event_loop()
wm = bpy.context.window_manager
with pack.Packer(base_blendfile, project, target) as packer:
with _packer_lock:
if exclusion_filter:
packer.exclude(*exclusion_filter.split())
packer.progress_cb = BatProgress()
_running_packer = packer
log.debug('awaiting strategise')
wm.flamenco_status = 'INVESTIGATING'
await loop.run_in_executor(None, packer.strategise)
log.debug('awaiting execute')
wm.flamenco_status = 'TRANSFERRING'
await loop.run_in_executor(None, packer.execute)
log.debug('done')
wm.flamenco_status = 'DONE'
with _packer_lock:
_running_packer = None
return packer.output_path, packer.missing_files
def abort() -> None:
"""Abort a running copy() call.
No-op when there is no running copy(). Can be called from any thread.
"""
with _packer_lock:
if _running_packer is None:
log.debug('No running packer, ignoring call to bat_abort()')
return
log.info('Aborting running packer')
_running_packer.abort()

View File

@@ -1,54 +0,0 @@
import functools
import pathlib
from pillarsdk.resource import List, Find, Create
class Manager(List, Find):
"""Manager class wrapping the REST nodes endpoint"""
path = 'flamenco/managers'
PurePlatformPath = pathlib.PurePath
@functools.lru_cache()
def _sorted_path_replacements(self) -> list:
import platform
if self.path_replacement is None:
return []
items = self.path_replacement.to_dict().items()
def by_length(item):
return -len(item[0]), item[0]
this_platform = platform.system().lower()
return [(varname, platform_replacements[this_platform])
for varname, platform_replacements in sorted(items, key=by_length)
if this_platform in platform_replacements]
def replace_path(self, some_path: pathlib.PurePath) -> str:
"""Performs path variable replacement.
Tries to find platform-specific path prefixes, and replaces them with
variables.
"""
for varname, path in self._sorted_path_replacements():
replacement = self.PurePlatformPath(path)
try:
relpath = some_path.relative_to(replacement)
except ValueError:
# Not relative to each other, so no replacement possible
continue
replacement_root = self.PurePlatformPath('{%s}' % varname)
return (replacement_root / relpath).as_posix()
return some_path.as_posix()
class Job(List, Find, Create):
"""Job class wrapping the REST nodes endpoint
"""
path = 'flamenco/jobs'
ensure_query_projections = {'project': 1}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -112,11 +112,7 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
async def async_execute(self, context):
"""Entry point of the asynchronous operator."""
# We don't want to influence what is included in the screen shot.
if self.target == 'SCREENSHOT':
print('Blender Cloud add-on is communicating with Blender Cloud')
else:
self.report({'INFO'}, 'Communicating with Blender Cloud')
self.report({'INFO'}, 'Communicating with Blender Cloud')
try:
# Refresh credentials
@@ -124,8 +120,9 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_IMAGE_SHARING)
self.user_id = db_user['_id']
self.log.debug('Found user ID: %s', self.user_id)
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:
@@ -323,25 +320,15 @@ def window_menu(self, context):
props.screenshot_full = True
def get_topbar_menu():
"""Return the topbar menu in a Blender 2.79 and 2.80 compatible way."""
try:
menu = bpy.types.TOPBAR_MT_window
except AttributeError:
# Blender < 2.80
menu = bpy.types.INFO_MT_window
return menu
def register():
bpy.utils.register_class(PILLAR_OT_image_share)
bpy.types.IMAGE_MT_image.append(image_editor_menu)
get_topbar_menu().append(window_menu)
bpy.types.INFO_MT_window.append(window_menu)
def unregister():
bpy.utils.unregister_class(PILLAR_OT_image_share)
bpy.types.IMAGE_MT_image.remove(image_editor_menu)
get_topbar_menu().remove(window_menu)
bpy.types.INFO_MT_window.remove(window_menu)

138
blender_cloud/pillar.py Executable file → Normal file
View File

@@ -62,16 +62,7 @@ class CredentialsNotSyncedError(UserNotLoggedInError):
class NotSubscribedToCloudError(UserNotLoggedInError):
"""Raised when the user does not have an active Cloud subscription.
:ivar can_renew: True when the user has an inactive subscription that can be renewed,
or False when the user has no subscription at all.
"""
def __init__(self, can_renew: bool):
super().__init__()
self.can_renew = can_renew
log.warning('Not subscribed to cloud, can_renew=%s', can_renew)
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
class PillarError(RuntimeError):
@@ -124,12 +115,6 @@ def with_existing_dir(filename: str, open_mode: str, encoding=None):
yield file_object
def _shorten(somestr: str, maxlen=40) -> str:
"""Shortens strings for logging"""
return (somestr[:maxlen - 3] + '...') if len(somestr) > maxlen else somestr
def save_as_json(pillar_resource, json_filename):
with with_existing_dir(json_filename, 'w') as outfile:
log.debug('Saving metadata to %r' % json_filename)
@@ -161,13 +146,6 @@ def blender_id_subclient() -> dict:
return subclient
def pillar_user_uuid() -> str:
"""Returns the UUID of the Pillar user."""
import blender_id
return blender_id.get_subclient_user_id(SUBCLIENT_ID)
def pillar_api(pillar_endpoint: str = None, caching=True) -> pillarsdk.Api:
"""Returns the Pillar SDK API object for the current user.
@@ -215,44 +193,16 @@ def pillar_api(pillar_endpoint: str = None, caching=True) -> pillarsdk.Api:
return _pillar_api[caching]
# This is an asyncio.Semaphore object, which is late-instantiated to be sure
# the asyncio loop has been created properly. On Windows we create a new one,
# which can cause this semaphore to still be linked against the old default
# loop.
pillar_semaphore = None
# No more than this many Pillar calls should be made simultaneously
pillar_semaphore = asyncio.Semaphore(3)
async def pillar_call(pillar_func, *args, caching=True, **kwargs):
"""Calls a Pillar function.
A semaphore is used to ensure that there won't be too many
calls to Pillar simultaneously.
"""
partial = functools.partial(pillar_func, *args, api=pillar_api(caching=caching), **kwargs)
loop = asyncio.get_event_loop()
# Use explicit calls to acquire() and release() so that we have more control over
# how long we wait and how we handle timeouts.
try:
await asyncio.wait_for(pillar_semaphore.acquire(), timeout=10, loop=loop)
except asyncio.TimeoutError:
log.info('Waiting for semaphore to call %s', pillar_func.__name__)
try:
await asyncio.wait_for(pillar_semaphore.acquire(), timeout=50, loop=loop)
except asyncio.TimeoutError:
raise RuntimeError('Timeout waiting for Pillar Semaphore!')
try:
async with pillar_semaphore:
return await loop.run_in_executor(None, partial)
finally:
pillar_semaphore.release()
def sync_call(pillar_func, *args, caching=True, **kwargs):
"""Synchronous call to Pillar, ensures the correct Api object is used."""
return pillar_func(*args, api=pillar_api(caching=caching), **kwargs)
async def check_pillar_credentials(required_roles: set):
@@ -282,15 +232,14 @@ async def check_pillar_credentials(required_roles: set):
except (pillarsdk.UnauthorizedAccess, pillarsdk.ResourceNotFound, pillarsdk.ForbiddenAccess):
raise CredentialsNotSyncedError()
roles = set(db_user.roles or set())
log.getChild('check_pillar_credentials').debug('user has roles %r', roles)
if required_roles and not required_roles.intersection(roles):
roles = db_user.roles or set()
log.debug('User has roles %r', roles)
if required_roles and not required_roles.intersection(set(roles)):
# Delete the subclient info. This forces a re-check later, which can
# then pick up on the user's new status.
del profile.subclients[SUBCLIENT_ID]
profile.save_json()
raise NotSubscribedToCloudError(can_renew='has_subscription' in roles)
raise NotSubscribedToCloudError()
return db_user
@@ -473,9 +422,9 @@ async def download_to_file(url, filename, *,
log.debug('Downloading was cancelled before doing the GET')
raise asyncio.CancelledError('Downloading was cancelled')
log.debug('Performing GET %s', _shorten(url))
log.debug('Performing GET %s', url)
response = await loop.run_in_executor(None, perform_get_request)
log.debug('Status %i from GET %s', response.status_code, _shorten(url))
log.debug('Status %i from GET %s', response.status_code, url)
response.raise_for_status()
if response.status_code == 304:
@@ -489,9 +438,9 @@ async def download_to_file(url, filename, *,
log.debug('Downloading was cancelled before downloading the GET response')
raise asyncio.CancelledError('Downloading was cancelled')
log.debug('Downloading response of GET %s', _shorten(url))
log.debug('Downloading response of GET %s', url)
await loop.run_in_executor(None, download_loop)
log.debug('Done downloading response of GET %s', _shorten(url))
log.debug('Done downloading response of GET %s', url)
# We're done downloading, now we have something cached we can use.
log.debug('Saving header cache to %s', header_store)
@@ -566,8 +515,7 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str,
for texture_node in texture_nodes)
# raises any exception from failed handle_texture_node() calls.
loop = asyncio.get_event_loop()
await asyncio.gather(*coros, loop=loop)
await asyncio.gather(*coros)
log.info('fetch_texture_thumbs: Done downloading texture thumbnails')
@@ -632,11 +580,7 @@ async def download_texture_thumbnail(texture_node, desired_size: str,
# Cached headers are stored next to thumbnails in sidecar files.
header_store = '%s.headers' % thumb_path
try:
await download_to_file(thumb_url, thumb_path, header_store=header_store, future=future)
except requests.exceptions.HTTPError as ex:
log.error('Unable to download %s: %s', thumb_url, ex)
thumb_path = 'ERROR'
await download_to_file(thumb_url, thumb_path, header_store=header_store, future=future)
loop.call_soon_threadsafe(thumbnail_loaded, texture_node, file_desc, thumb_path)
@@ -784,8 +728,7 @@ async def download_texture(texture_node,
future=future)
downloaders.append(dlr)
loop = asyncio.get_event_loop()
return await asyncio.gather(*downloaders, return_exceptions=True, loop=loop)
return await asyncio.gather(*downloaders, return_exceptions=True)
async def upload_file(project_id: str, file_path: pathlib.Path, *,
@@ -811,9 +754,9 @@ async def upload_file(project_id: str, file_path: pathlib.Path, *,
log.debug('Uploading was cancelled before doing the POST')
raise asyncio.CancelledError('Uploading was cancelled')
log.debug('Performing POST %s', _shorten(url))
log.debug('Performing POST %s', url)
response = await loop.run_in_executor(None, upload)
log.debug('Status %i from POST %s', response.status_code, _shorten(url))
log.debug('Status %i from POST %s', response.status_code, url)
response.raise_for_status()
resp = response.json()
@@ -848,6 +791,7 @@ class PillarOperatorMixin:
try:
db_user = await check_pillar_credentials(required_roles)
except NotSubscribedToCloudError:
self._log_subscription_needed()
raise
except CredentialsNotSyncedError:
self.log.info('Credentials not synced, re-syncing automatically.')
@@ -858,6 +802,7 @@ class PillarOperatorMixin:
try:
db_user = await refresh_pillar_credentials(required_roles)
except NotSubscribedToCloudError:
self._log_subscription_needed()
raise
except CredentialsNotSyncedError:
self.log.info('Credentials not synced after refreshing, handling as not logged in.')
@@ -869,46 +814,11 @@ class PillarOperatorMixin:
self.log.info('Credentials refreshed and ok.')
return db_user
def _log_subscription_needed(self, *, can_renew: bool, level='ERROR'):
if can_renew:
msg = 'Please renew your Blender Cloud subscription at https://cloud.blender.org/renew'
else:
msg = 'Please subscribe to the blender cloud at https://cloud.blender.org/join'
self.log.warning(msg)
self.report({level}, msg)
class AuthenticatedPillarOperatorMixin(PillarOperatorMixin):
"""Checks credentials, to be used at the start of async_execute().
Sets self.user_id to the current user's ID, and self.db_user to the user info dict,
if authentication was succesful; sets both to None if not.
"""
async def authenticate(self, context) -> bool:
from . import pillar
self.log.info('Checking credentials')
self.user_id = None
self.db_user = None
try:
self.db_user = await self.check_credentials(context, ())
except pillar.UserNotLoggedInError as ex:
self.log.info('Not logged in error raised: %s', ex)
self.report({'ERROR'}, 'Please log in on Blender ID first.')
self.quit()
return False
except requests.exceptions.ConnectionError:
self.log.exception('Error checking pillar credentials.')
self.report({'ERROR'}, 'Unable to connect to Blender Cloud, '
'check your internet connection.')
self.quit()
return False
self.user_id = self.db_user['_id']
return True
def _log_subscription_needed(self):
self.log.warning(
'Please subscribe to the blender cloud at https://cloud.blender.org/join')
self.report({'INFO'},
'Please subscribe to the blender cloud at https://cloud.blender.org/join')
async def find_or_create_node(where: dict,

View File

@@ -1,142 +0,0 @@
"""Handle saving and loading project-specific settings."""
import contextlib
import logging
# Names of BlenderCloudPreferences properties that are both project-specific
# and simple enough to store directly in a dict.
PROJECT_SPECIFIC_SIMPLE_PROPS = (
'cloud_project_local_path',
)
log = logging.getLogger(__name__)
project_settings_loading = False
@contextlib.contextmanager
def mark_as_loading():
"""Sets project_settings_loading=True while the context is active."""
global project_settings_loading
project_settings_loading = True
try:
yield
finally:
project_settings_loading = False
def handle_project_update(_=None, _2=None):
"""Handles changing projects, which may cause extensions to be disabled/enabled.
Ignores arguments so that it can be used as property update callback.
"""
from .blender import preferences, project_extensions
with mark_as_loading():
prefs = preferences()
project_id = prefs.project.project
log.info('Updating internal state to reflect extensions enabled on current project %s.',
project_id)
project_extensions.cache_clear()
from blender_cloud import attract, flamenco
attract.deactivate()
flamenco.deactivate()
enabled_for = project_extensions(project_id)
log.info('Project extensions: %s', enabled_for)
if 'attract' in enabled_for:
attract.activate()
if 'flamenco' in enabled_for:
flamenco.activate()
# Load project-specific settings from the last time we visited this project.
ps = prefs.get('project_settings', {}).get(project_id, {})
if not ps:
log.debug('no project-specific settings are available, '
'only resetting available Flamenco Managers')
# The Flamenco Manager should really be chosen explicitly out of the available
# Managers.
prefs.flamenco_manager.available_managers = []
return
if log.isEnabledFor(logging.DEBUG):
from pprint import pformat
log.debug('loading project-specific settings:\n%s', pformat(ps.to_dict()))
# Restore simple properties.
for name in PROJECT_SPECIFIC_SIMPLE_PROPS:
if name in ps and hasattr(prefs, name):
setattr(prefs, name, ps[name])
# Restore Flamenco settings.
prefs.flamenco_manager.available_managers = ps.get('flamenco_available_managers', [])
flamenco_manager_id = ps.get('flamenco_manager_id')
if flamenco_manager_id:
log.debug('setting flamenco manager to %s', flamenco_manager_id)
try:
prefs.flamenco_manager.manager = flamenco_manager_id
except TypeError:
log.warning('manager %s for this project could not be found', flamenco_manager_id)
else:
# Load per-project, per-manager settings for the current Manager.
try:
pppm = ps['flamenco_managers_settings'][flamenco_manager_id]
except KeyError:
# No settings for this manager, so nothing to do.
pass
else:
prefs.flamenco_job_file_path = pppm['file_path']
prefs.flamenco_job_output_path = pppm['output_path']
prefs.flamenco_job_output_strip_components = pppm['output_strip_components']
else:
log.debug('Resetting Flamenco Manager to None')
prefs.flamenco_manager.manager = None
def store(_=None, _2=None):
"""Remember project-specific settings as soon as one of them changes.
Ignores arguments so that it can be used as property update callback.
No-op when project_settings_loading=True, to prevent saving project-
specific settings while they are actually being loaded.
"""
from .blender import preferences
global project_settings_loading
if project_settings_loading:
return
prefs = preferences()
project_id = prefs.project.project
all_settings = prefs.get('project_settings', {})
ps = all_settings.get(project_id, {}) # either a dict or bpy.types.IDPropertyGroup
for name in PROJECT_SPECIFIC_SIMPLE_PROPS:
ps[name] = getattr(prefs, name)
# Store project-specific Flamenco settings
ps['flamenco_manager_id'] = prefs.flamenco_manager.manager
ps['flamenco_available_managers'] = prefs.flamenco_manager.available_managers
# Store per-project, per-manager settings for the current Manager.
pppm = ps.get('flamenco_managers_settings', {})
pppm[prefs.flamenco_manager.manager] = {
'file_path': prefs.flamenco_job_file_path,
'output_path': prefs.flamenco_job_output_path,
'output_strip_components': prefs.flamenco_job_output_strip_components}
ps['flamenco_managers_settings'] = pppm # IDPropertyGroup has no setdefault() method.
# Store this project's settings in the preferences.
all_settings[project_id] = ps
prefs['project_settings'] = all_settings
if log.isEnabledFor(logging.DEBUG):
from pprint import pformat
if hasattr(all_settings, 'to_dict'):
to_log = all_settings.to_dict()
else:
to_log = all_settings
log.debug('Saving project-specific settings:\n%s', pformat(to_log))

View File

@@ -284,8 +284,9 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_SYNC)
self.user_id = db_user['_id']
log.debug('Found user ID: %s', self.user_id)
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.bss_report({'SUBSCRIBE'}, 'Please subscribe to the Blender Cloud.')
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:

View File

@@ -76,13 +76,12 @@ class MenuItem:
icon_margin_y = 4
text_margin_x = 6
text_size = 12
text_size_small = 10
text_height = 16
text_width = 72
DEFAULT_ICONS = {
'FOLDER': os.path.join(library_icons_path, 'folder.png'),
'SPINNER': os.path.join(library_icons_path, 'spinner.png'),
'ERROR': os.path.join(library_icons_path, 'error.png'),
}
FOLDER_NODE_TYPES = {'group_texture', 'group_hdri', UpNode.NODE_TYPE, ProjectNode.NODE_TYPE}
@@ -100,7 +99,6 @@ class MenuItem:
self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.label_text = label_text
self.small_text = self._small_text_from_node()
self._thumb_path = ''
self.icon = None
self._is_folder = node['node_type'] in self.FOLDER_NODE_TYPES
@@ -120,26 +118,6 @@ class MenuItem:
self.width = 0
self.height = 0
def _small_text_from_node(self) -> str:
"""Return the components of the texture (i.e. which map types are available)."""
if not self.node:
return ''
try:
node_files = self.node.properties.files
except AttributeError:
# Happens for nodes that don't have .properties.files.
return ''
if not node_files:
return ''
map_types = {f.map_type for f in node_files if f.map_type}
map_types.discard('color') # all textures have colour
if not map_types:
return ''
return ', '.join(sorted(map_types))
def sort_key(self):
"""Key for sorting lists of MenuItems."""
return self._order, self.label_text
@@ -181,11 +159,6 @@ class MenuItem:
if label_text is not None:
self.label_text = label_text
if thumb_path == 'ERROR':
self.small_text = 'This open is broken'
else:
self.small_text = self._small_text_from_node()
@property
def is_folder(self) -> bool:
return self._is_folder
@@ -212,17 +185,15 @@ class MenuItem:
bgl.glRectf(self.x, self.y, self.x + self.width, self.y + self.height)
texture = self.icon
if texture:
err = texture.gl_load(filter=bgl.GL_NEAREST, mag=bgl.GL_NEAREST)
assert not err, 'OpenGL error: %i' % err
err = texture.gl_load(filter=bgl.GL_NEAREST, mag=bgl.GL_NEAREST)
assert not err, 'OpenGL error: %i' % err
bgl.glColor4f(0.0, 0.0, 1.0, 0.5)
# bgl.glLineWidth(1.5)
# ------ TEXTURE ---------#
if texture:
bgl.glBindTexture(bgl.GL_TEXTURE_2D, texture.bindcode[0])
bgl.glEnable(bgl.GL_TEXTURE_2D)
bgl.glBindTexture(bgl.GL_TEXTURE_2D, texture.bindcode[0])
bgl.glEnable(bgl.GL_TEXTURE_2D)
bgl.glBlendFunc(bgl.GL_SRC_ALPHA, bgl.GL_ONE_MINUS_SRC_ALPHA)
bgl.glColor4f(1, 1, 1, 1)
@@ -239,24 +210,16 @@ class MenuItem:
bgl.glDisable(bgl.GL_TEXTURE_2D)
bgl.glDisable(bgl.GL_BLEND)
if texture:
texture.gl_free()
texture.gl_free()
# draw some text
font_id = 0
text_dpi = bpy.context.user_preferences.system.dpi
text_x = self.x + self.icon_margin_x + ICON_WIDTH + self.text_margin_x
text_y = self.y + ICON_HEIGHT * 0.5 - 0.25 * self.text_size
blf.position(font_id, text_x, text_y, 0)
blf.size(font_id, self.text_size, text_dpi)
blf.position(font_id,
self.x + self.icon_margin_x + ICON_WIDTH + self.text_margin_x,
self.y + ICON_HEIGHT * 0.5 - 0.25 * self.text_height, 0)
blf.size(font_id, self.text_height, self.text_width)
blf.draw(font_id, self.label_text)
# draw the small text
bgl.glColor4f(1.0, 1.0, 1.0, 0.5)
blf.size(font_id, self.text_size_small, text_dpi)
blf.position(font_id, text_x, self.y + 0.5 * self.text_size_small, 0)
blf.draw(font_id, self.small_text)
def hits(self, mouse_x: int, mouse_y: int) -> bool:
return self.x < mouse_x < self.x + self.width and self.y < mouse_y < self.y + self.height
@@ -348,8 +311,8 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.mouse_y = event.mouse_y
left_mouse_release = event.type == 'LEFTMOUSE' and event.value == 'RELEASE'
if left_mouse_release and self._state in {'PLEASE_SUBSCRIBE', 'PLEASE_RENEW'}:
self.open_browser_subscribe(renew=self._state == 'PLEASE_RENEW')
if self._state == 'PLEASE_SUBSCRIBE' and left_mouse_release:
self.open_browser_subscribe()
self._finish(context)
return {'FINISHED'}
@@ -402,9 +365,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
try:
db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew, level='INFO')
self._show_subscribe_screen(can_renew=ex.can_renew)
except pillar.NotSubscribedToCloudError:
self.log.info('User not subscribed to Blender Cloud.')
self._show_subscribe_screen()
return None
if db_user is None:
@@ -412,14 +375,10 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
await self.async_download_previews()
def _show_subscribe_screen(self, *, can_renew: bool):
def _show_subscribe_screen(self):
"""Shows the "You need to subscribe" screen."""
if can_renew:
self._state = 'PLEASE_RENEW'
else:
self._state = 'PLEASE_SUBSCRIBE'
self._state = 'PLEASE_SUBSCRIBE'
bpy.context.window.cursor_set('HAND')
def descend_node(self, menu_item: MenuItem):
@@ -498,7 +457,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
return menu_item
def update_menu_item(self, node, *args):
def update_menu_item(self, node, *args) -> MenuItem:
node_uuid = node['_id']
# Just make this thread-safe to be on the safe side.
@@ -579,7 +538,6 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.add_menu_item(node, None, 'SPINNER', texture_node['name'])
def thumbnail_loaded(node, file_desc, thumb_path):
self.log.debug('Node %s thumbnail loaded', node['_id'])
self.update_menu_item(node, file_desc, thumb_path)
await pillar.fetch_texture_thumbs(node_uuid, 's', directory,
@@ -589,20 +547,17 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
def browse_assets(self):
self.log.debug('Browsing assets at %r', self.current_path)
bpy.context.window_manager.last_blender_cloud_location = str(self.current_path)
self._new_async_task(self.async_download_previews())
def draw_menu(self, context):
"""Draws the GUI with OpenGL."""
drawers = {
'INITIALIZING': self._draw_initializing,
'CHECKING_CREDENTIALS': self._draw_checking_credentials,
'BROWSING': self._draw_browser,
'DOWNLOADING_TEXTURE': self._draw_downloading,
'EXCEPTION': self._draw_exception,
'PLEASE_SUBSCRIBE': self._draw_subscribe,
'PLEASE_RENEW': self._draw_renew,
}
if self._state in drawers:
@@ -698,13 +653,6 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'Checking login credentials',
(0.0, 0.0, 0.2, 0.6))
def _draw_initializing(self, context):
"""OpenGL drawing code for the INITIALIZING state."""
self._draw_text_on_colour(context,
'Initializing',
(0.0, 0.0, 0.2, 0.6))
def _draw_text_on_colour(self, context, text, bgcolour):
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND)
@@ -770,11 +718,6 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'Click to subscribe to the Blender Cloud',
(0.0, 0.0, 0.2, 0.6))
def _draw_renew(self, context):
self._draw_text_on_colour(context,
'Click to renew your Blender Cloud subscription',
(0.0, 0.0, 0.2, 0.6))
def get_clicked(self) -> MenuItem:
for item in self.current_display_content:
@@ -855,11 +798,11 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
future=signalling_future))
self.async_task.add_done_callback(texture_download_completed)
def open_browser_subscribe(self, *, renew: bool):
def open_browser_subscribe(self):
import webbrowser
url = 'renew' if renew else 'join'
webbrowser.open_new_tab('https://cloud.blender.org/%s' % url)
webbrowser.open_new_tab('https://cloud.blender.org/join')
self.report({'INFO'}, 'We just started a browser for you.')
def _scroll_smooth(self):
@@ -914,8 +857,9 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
try:
db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
user_id = db_user['_id']
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:
@@ -1013,8 +957,8 @@ def _hdri_download_panel(self, current_image):
current_image.name)
return
row = self.layout.row(align=True).split(**blender.factor(0.3))
row.label(text='HDRi', icon_value=blender.icon('CLOUD'))
row = self.layout.row(align=True).split(0.3)
row.label('HDRi', icon_value=blender.icon('CLOUD'))
row.prop(current_image, 'hdri_variation', text='')
if current_image.hdri_variation != current_variation:

View File

@@ -16,8 +16,6 @@
#
# ##### END GPL LICENSE BLOCK #####
import pathlib
def sizeof_fmt(num: int, suffix='B') -> str:
"""Returns a human-readable size.
@@ -31,74 +29,3 @@ def sizeof_fmt(num: int, suffix='B') -> str:
num /= 1024
return '%.1f Yi%s' % (num, suffix)
def find_in_path(path: pathlib.Path, filename: str) -> pathlib.Path:
"""Performs a breadth-first search for the filename.
Returns the path that contains the file, or None if not found.
"""
import collections
# Be lenient on our input type.
if isinstance(path, str):
path = pathlib.Path(path)
if not path.exists():
return None
assert path.is_dir()
to_visit = collections.deque([path])
while to_visit:
this_path = to_visit.popleft()
for subpath in this_path.iterdir():
if subpath.is_dir():
to_visit.append(subpath)
continue
if subpath.name == filename:
return subpath
return None
def pyside_cache(propname):
"""Decorator, stores the result of the decorated callable in Python-managed memory.
This is to work around the warning at
https://www.blender.org/api/blender_python_api_master/bpy.props.html#bpy.props.EnumProperty
"""
if callable(propname):
raise TypeError('Usage: pyside_cache("property_name")')
def decorator(wrapped):
"""Stores the result of the callable in Python-managed memory.
This is to work around the warning at
https://www.blender.org/api/blender_python_api_master/bpy.props.html#bpy.props.EnumProperty
"""
import functools
@functools.wraps(wrapped)
# We can't use (*args, **kwargs), because EnumProperty explicitly checks
# for the number of fixed positional arguments.
def wrapper(self, context):
result = None
try:
result = wrapped(self, context)
return result
finally:
rna_type, rna_info = getattr(self.bl_rna, propname)
rna_info['_cached_result'] = result
return wrapper
return decorator
def redraw(self, context):
if context.area is None:
return
context.area.tag_redraw()

View File

@@ -44,12 +44,6 @@ def load_wheel(module_name, fname_prefix):
module_name, module.__file__, fname_prefix)
return
sys.path.append(wheel_filename(fname_prefix))
module = __import__(module_name)
log.debug('Loaded %s from %s', module_name, module.__file__)
def wheel_filename(fname_prefix: str) -> str:
path_pattern = os.path.join(my_dir, '%s*.whl' % fname_prefix)
wheels = glob.glob(path_pattern)
if not wheels:
@@ -57,11 +51,12 @@ def wheel_filename(fname_prefix: str) -> str:
# If there are multiple wheels that match, load the latest one.
wheels.sort()
return wheels[-1]
sys.path.append(wheels[-1])
module = __import__(module_name)
log.debug('Loaded %s from %s', module_name, module.__file__)
def load_wheels():
load_wheel('blender_asset_tracer', 'blender_asset_tracer')
load_wheel('lockfile', 'lockfile')
load_wheel('cachecontrol', 'CacheControl')
load_wheel('pillarsdk', 'pillarsdk')

View File

@@ -1,8 +0,0 @@
-r requirements.txt
# Primary requirements
pytest==3.0.3
# Secondary requirements
py==1.4.31

View File

@@ -1,17 +1,15 @@
# Primary requirements:
-e git+https://github.com/sybrenstuvel/cachecontrol.git@sybren-filecache-delete-crash-fix#egg=CacheControl
lockfile==0.12.2
pillarsdk==1.6.1
pillarsdk==1.5.0
wheel==0.29.0
blender-asset-tracer>=0.4
# Secondary requirements:
asn1crypto==0.24.0
cffi==1.11.2
cryptography==2.1.4
idna==2.6
cffi==1.6.0
cryptography==1.3.1
idna==2.1
pyasn1==0.1.9
pycparser==2.18
pyOpenSSL==17.5.0
pycparser==2.14
pyOpenSSL==16.0.0
requests==2.10.0
six==1.11.0
six==1.10.0

View File

@@ -23,7 +23,6 @@ import shutil
import subprocess
import re
import pathlib
import zipfile
from distutils import log
from distutils.core import Command
@@ -35,11 +34,6 @@ from setuptools import setup, find_packages
requirement_re = re.compile('[><=]+')
sys.dont_write_bytecode = True
# Download wheels from pypi. The specific versions are taken from requirements.txt
wheels = [
'lockfile', 'pillarsdk', 'blender-asset-tracer',
]
def set_default_path(var, default):
"""convert CLI-arguments (string) to Paths"""
@@ -64,7 +58,6 @@ class BuildWheels(Command):
self.wheels_path = None # path that will contain the installed wheels.
self.deps_path = None # path in which dependencies are built.
self.cachecontrol_path = None # subdir of deps_path containing CacheControl
self.bat_path = None # subdir of deps_path containing Blender-Asset-Tracer
def finalize_options(self):
self.my_path = pathlib.Path(__file__).resolve().parent
@@ -74,7 +67,6 @@ class BuildWheels(Command):
self.deps_path = set_default_path(self.deps_path, self.my_path / 'build/deps')
self.cachecontrol_path = set_default_path(self.cachecontrol_path,
self.deps_path / 'cachecontrol')
self.bat_path = self.deps_path / 'bat'
def run(self):
log.info('Storing wheels in %s', self.wheels_path)
@@ -96,11 +88,16 @@ class BuildWheels(Command):
# log.info(' - %s = %s / %s', package, line, line_req[-1])
self.wheels_path.mkdir(parents=True, exist_ok=True)
for package in wheels:
pattern = package.replace('-', '_') + '*.whl'
if list(self.wheels_path.glob(pattern)):
continue
self.download_wheel(requirements[package])
# Download lockfile, as there is a suitable wheel on pypi.
if not list(self.wheels_path.glob('lockfile*.whl')):
log.info('Downloading lockfile wheel')
self.download_wheel(requirements['lockfile'])
# Download Pillar Python SDK from pypi.
if not list(self.wheels_path.glob('pillarsdk*.whl')):
log.info('Downloading Pillar Python SDK wheel')
self.download_wheel(requirements['pillarsdk'])
# Build CacheControl.
if not list(self.wheels_path.glob('CacheControl*.whl')):
@@ -171,34 +168,6 @@ class BlenderAddonBdist(bdist):
super().run()
# noinspection PyAttributeOutsideInit
class BlenderAddonFdist(BlenderAddonBdist):
"""Ensures that 'python setup.py fdist' creates a plain folder structure."""
user_options = [
('dest-path=', None, 'addon installation path'),
]
def initialize_options(self):
super().initialize_options()
self.dest_path = None # path that will contain the addon
def run(self):
super().run()
# dist_files is a list of tuples ('bdist', 'any', 'filepath')
filepath = self.distribution.dist_files[0][2]
# if dest_path is not specified use the filename as the dest_path (minus the .zip)
assert filepath.endswith('.zip')
target_folder = self.dest_path or filepath[:-4]
print('Unzipping the package on {}.'.format(target_folder))
with zipfile.ZipFile(filepath, 'r') as zip_ref:
zip_ref.extractall(target_folder)
# noinspection PyAttributeOutsideInit
class BlenderAddonInstall(install):
"""Ensures the module is placed at the root of the zip file."""
@@ -222,17 +191,16 @@ class AvoidEggInfo(install_egg_info):
setup(
cmdclass={'bdist': BlenderAddonBdist,
'fdist': BlenderAddonFdist,
'install': BlenderAddonInstall,
'install_egg_info': AvoidEggInfo,
'wheels': BuildWheels},
name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.9.0',
version='1.4.4',
author='Sybren A. Stüvel',
author_email='sybren@stuvel.eu',
packages=find_packages('.'),
data_files=[('blender_cloud', ['README.md', 'README-flamenco.md', 'CHANGELOG.md']),
data_files=[('blender_cloud', ['README.md']),
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
scripts=[],
url='https://developer.blender.org/diffusion/BCA/',

View File

@@ -1,95 +0,0 @@
"""Unittests for blender_cloud.utils.
This unittest requires bpy to be importable, so build Blender as a module and install it
into your virtualenv. See https://stuvel.eu/files/bconf2016/#/10 for notes how.
"""
import datetime
import pathlib
import unittest.mock
import pillarsdk.utils
from blender_cloud.flamenco import sdk
class PathReplacementTest(unittest.TestCase):
def setUp(self):
self.test_manager = sdk.Manager({
'_created': datetime.datetime(2017, 5, 31, 15, 12, 32, tzinfo=pillarsdk.utils.utc),
'_etag': 'c39942ee4bcc4658adcc21e4bcdfb0ae',
'_id': '592edd609837732a2a272c62',
'_updated': datetime.datetime(2017, 6, 8, 14, 51, 3, tzinfo=pillarsdk.utils.utc),
'description': 'Manager formerly known as "testman"',
'job_types': {'sleep': {'vars': {}}},
'name': '<script>alert("this is a manager")</script>',
'owner': '592edd609837732a2a272c63',
'path_replacement': {'job_storage': {'darwin': '/Volume/shared',
'linux': '/shared',
'windows': 's:/'},
'render': {'darwin': '/Volume/render/',
'linux': '/render/',
'windows': 'r:/'},
'longrender': {'darwin': '/Volume/render/long',
'linux': '/render/long',
'windows': 'r:/long'},
},
'projects': ['58cbdd5698377322d95eb55e'],
'service_account': '592edd609837732a2a272c60',
'stats': {'nr_of_workers': 3},
'url': 'http://192.168.3.101:8083/',
'user_groups': ['58cbdd5698377322d95eb55f'],
'variables': {'blender': {'darwin': '/opt/myblenderbuild/blender',
'linux': '/home/sybren/workspace/build_linux/bin/blender '
'--enable-new-depsgraph --factory-startup',
'windows': 'c:/temp/blender.exe'}}}
)
def test_linux(self):
# (expected result, input)
test_paths = [
('/doesnotexistreally', '/doesnotexistreally'),
('{render}/agent327/scenes/A_01_03_B', '/render/agent327/scenes/A_01_03_B'),
('{job_storage}/render/agent327/scenes', '/shared/render/agent327/scenes'),
('{longrender}/agent327/scenes', '/render/long/agent327/scenes'),
]
self._do_test(test_paths, 'linux', pathlib.PurePosixPath)
def test_windows(self):
# (expected result, input)
test_paths = [
('c:/doesnotexistreally', 'c:/doesnotexistreally'),
('c:/some/path', r'c:\some\path'),
('{render}/agent327/scenes/A_01_03_B', r'R:\agent327\scenes\A_01_03_B'),
('{render}/agent327/scenes/A_01_03_B', r'r:\agent327\scenes\A_01_03_B'),
('{render}/agent327/scenes/A_01_03_B', r'r:/agent327/scenes/A_01_03_B'),
('{job_storage}/render/agent327/scenes', 's:/render/agent327/scenes'),
('{longrender}/agent327/scenes', 'r:/long/agent327/scenes'),
]
self._do_test(test_paths, 'windows', pathlib.PureWindowsPath)
def test_darwin(self):
# (expected result, input)
test_paths = [
('/Volume/doesnotexistreally', '/Volume/doesnotexistreally'),
('{render}/agent327/scenes/A_01_03_B', r'/Volume/render/agent327/scenes/A_01_03_B'),
('{job_storage}/render/agent327/scenes', '/Volume/shared/render/agent327/scenes'),
('{longrender}/agent327/scenes', '/Volume/render/long/agent327/scenes'),
]
self._do_test(test_paths, 'darwin', pathlib.PurePosixPath)
def _do_test(self, test_paths, platform, pathclass):
self.test_manager.PurePlatformPath = pathclass
def mocked_system():
return platform
with unittest.mock.patch('platform.system', mocked_system):
for expected_result, input_path in test_paths:
as_path_instance = pathclass(input_path)
self.assertEqual(expected_result,
self.test_manager.replace_path(as_path_instance),
'for input %r on platform %s' % (as_path_instance, platform))

View File

@@ -1,25 +0,0 @@
"""Unittests for blender_cloud.utils."""
import pathlib
import unittest
from blender_cloud import utils
class FindInPathTest(unittest.TestCase):
def test_nonexistant_path(self):
path = pathlib.Path('/doesnotexistreally')
self.assertFalse(path.exists())
self.assertIsNone(utils.find_in_path(path, 'jemoeder.blend'))
def test_really_breadth_first(self):
"""A depth-first test might find dir_a1/dir_a2/dir_a3/find_me.txt first."""
path = pathlib.Path(__file__).parent / 'test_really_breadth_first'
found = utils.find_in_path(path, 'find_me.txt')
self.assertEqual(path / 'dir_b1' / 'dir_b2' / 'find_me.txt', found)
def test_nonexistant_file(self):
path = pathlib.Path(__file__).parent / 'test_really_breadth_first'
found = utils.find_in_path(path, 'do_not_find_me.txt')
self.assertEqual(None, found)

View File

@@ -1,19 +1,15 @@
#!/bin/bash
VERSION="${1/version-}"
if [ -z "$VERSION" ]; then
if [ -z "$1" ]; then
echo "Usage: $0 new-version" >&2
exit 1
fi
BL_INFO_VER=$(echo "$VERSION" | sed 's/\./, /g')
BL_INFO_VER=$(echo "$1" | sed 's/\./, /g')
sed "s/version='[^']*'/version='$VERSION'/" -i setup.py
sed "s/version='[^']*'/version='$1'/" -i setup.py
sed "s/'version': ([^)]*)/'version': ($BL_INFO_VER)/" -i blender_cloud/__init__.py
git diff
echo
echo "Don't forget to commit and tag:"
echo git commit -m \'Bumped version to $VERSION\' setup.py blender_cloud/__init__.py
echo git tag -a version-$VERSION -m \'Tagged version $VERSION\'
echo "Don't forget to commit!"