Compare commits
20 Commits
version-1.
...
version-1.
Author | SHA1 | Date | |
---|---|---|---|
9272e22129 | |||
e14a0aa53c | |||
51cf097c8f | |||
4608204f1d | |||
3f95249196 | |||
9df016da09 | |||
64e29e695b | |||
d5f285a381 | |||
e39429272d | |||
bdb00eeaaa | |||
3ef2ca0c07 | |||
35d4f85010 | |||
8151b952b9 | |||
2d2585b8d7 | |||
65204db228 | |||
570b1d4bfe | |||
68b046c714 | |||
cb20d6ee03 | |||
![]() |
645bdd950f | ||
![]() |
74a5830dae |
57
README-flamenco.md
Normal file
57
README-flamenco.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 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`
|
@@ -21,7 +21,7 @@
|
||||
bl_info = {
|
||||
'name': 'Blender Cloud',
|
||||
"author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis",
|
||||
'version': (1, 5, 1),
|
||||
'version': (1, 5, 9999),
|
||||
'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 '
|
||||
@@ -79,13 +79,15 @@ def register():
|
||||
settings_sync = reload_mod('settings_sync')
|
||||
image_sharing = reload_mod('image_sharing')
|
||||
attract = reload_mod('attract')
|
||||
flamenco = reload_mod('flamenco')
|
||||
else:
|
||||
from . import (blender, texture_browser, async_loop, settings_sync, blendfile, home_project,
|
||||
image_sharing, attract)
|
||||
image_sharing, attract, flamenco)
|
||||
|
||||
async_loop.setup_asyncio_executor()
|
||||
async_loop.register()
|
||||
|
||||
flamenco.register()
|
||||
texture_browser.register()
|
||||
blender.register()
|
||||
settings_sync.register()
|
||||
@@ -111,7 +113,8 @@ def _monkey_patch_requests():
|
||||
|
||||
|
||||
def unregister():
|
||||
from . import blender, texture_browser, async_loop, settings_sync, image_sharing, attract
|
||||
from . import (blender, texture_browser, async_loop, settings_sync, image_sharing, attract,
|
||||
flamenco)
|
||||
|
||||
image_sharing.unregister()
|
||||
attract.unregister()
|
||||
@@ -119,3 +122,4 @@ def unregister():
|
||||
blender.unregister()
|
||||
texture_browser.unregister()
|
||||
async_loop.unregister()
|
||||
flamenco.unregister()
|
||||
|
@@ -39,9 +39,18 @@ def setup_asyncio_executor():
|
||||
calls that could be performed in parallel are queued, and thus we can
|
||||
reliably cancel them.
|
||||
"""
|
||||
import sys
|
||||
|
||||
executor = concurrent.futures.ThreadPoolExecutor()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if sys.platform == 'win32':
|
||||
# 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()
|
||||
loop.set_default_executor(executor)
|
||||
# loop.set_debug(True)
|
||||
|
||||
|
@@ -70,8 +70,12 @@ def active_strip(context):
|
||||
|
||||
def selected_shots(context):
|
||||
"""Generator, yields selected strips if they are Attract shots."""
|
||||
selected_sequences = context.selected_sequences
|
||||
|
||||
for strip in context.selected_sequences:
|
||||
if selected_sequences is None:
|
||||
return
|
||||
|
||||
for strip in selected_sequences:
|
||||
atc_object_id = getattr(strip, 'atc_object_id')
|
||||
if not atc_object_id:
|
||||
continue
|
||||
@@ -81,6 +85,11 @@ def selected_shots(context):
|
||||
|
||||
def all_shots(context):
|
||||
"""Generator, yields all strips if they are Attract shots."""
|
||||
sequence_editor = context.scene.sequence_editor
|
||||
|
||||
if sequence_editor is None:
|
||||
# we should throw an exception, but at least this change prevents an error
|
||||
return []
|
||||
|
||||
for strip in context.scene.sequence_editor.sequences_all:
|
||||
atc_object_id = getattr(strip, 'atc_object_id')
|
||||
@@ -195,7 +204,7 @@ class ToolsPanel(Panel):
|
||||
if bpy.ops.attract.submit_selected.poll():
|
||||
row.operator('attract.submit_selected',
|
||||
text='Submit %s' % noun,
|
||||
icon='MOVE_UP_VEC')
|
||||
icon='TRIA_UP')
|
||||
else:
|
||||
row.operator(ATTRACT_OT_submit_all.bl_idname)
|
||||
row.operator(AttractShotFetchUpdate.bl_idname,
|
||||
|
@@ -26,10 +26,11 @@ import os.path
|
||||
|
||||
import bpy
|
||||
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
|
||||
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty
|
||||
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
|
||||
import rna_prop_ui
|
||||
|
||||
from . import pillar, async_loop
|
||||
from . import pillar, async_loop, flamenco
|
||||
from .utils import pyside_cache, redraw
|
||||
|
||||
PILLAR_WEB_SERVER_URL = 'https://cloud.blender.org/'
|
||||
# PILLAR_WEB_SERVER_URL = 'http://pillar-web:5001/'
|
||||
@@ -41,39 +42,6 @@ log = logging.getLogger(__name__)
|
||||
icons = None
|
||||
|
||||
|
||||
def redraw(self, context):
|
||||
context.area.tag_redraw()
|
||||
|
||||
|
||||
def pyside_cache(propname):
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pyside_cache('version')
|
||||
def blender_syncable_versions(self, context):
|
||||
"""Returns the list of items used by SyncStatusProperties.version EnumProperty."""
|
||||
@@ -207,6 +175,32 @@ class BlenderCloudPreferences(AddonPreferences):
|
||||
subtype='DIR_PATH',
|
||||
default='//../')
|
||||
|
||||
flamenco_manager = PointerProperty(type=flamenco.FlamencoManagerGroup)
|
||||
# TODO: before making Flamenco public, change the defaults to something less Institute-specific.
|
||||
# NOTE: The assumption is that the workers can also find the files in the same path.
|
||||
# This assumption is true for the Blender Institute.
|
||||
flamenco_job_file_path = StringProperty(
|
||||
name='Job file path',
|
||||
description='Path where to store job files, should be accesible for Workers too',
|
||||
subtype='DIR_PATH',
|
||||
default='/render/_flamenco/storage')
|
||||
|
||||
# TODO: before making Flamenco public, change the defaults to something less Institute-specific.
|
||||
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='/render/_flamenco/output')
|
||||
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,
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
import textwrap
|
||||
|
||||
@@ -291,6 +285,10 @@ class BlenderCloudPreferences(AddonPreferences):
|
||||
attract_box = layout.box()
|
||||
self.draw_attract_buttons(attract_box, self.attract_project)
|
||||
|
||||
# Flamenco stuff
|
||||
flamenco_box = layout.box()
|
||||
self.draw_flamenco_buttons(flamenco_box, self.flamenco_manager, context)
|
||||
|
||||
def draw_subscribe_button(self, layout):
|
||||
layout.operator('pillar.subscribe', icon='WORLD')
|
||||
|
||||
@@ -349,6 +347,53 @@ class BlenderCloudPreferences(AddonPreferences):
|
||||
|
||||
attract_box.prop(self, 'attract_project_local_path')
|
||||
|
||||
def draw_flamenco_buttons(self, flamenco_box, bcp: flamenco.FlamencoManagerGroup, context):
|
||||
flamenco_row = flamenco_box.row(align=True)
|
||||
flamenco_row.label('Flamenco', icon_value=icon('CLOUD'))
|
||||
|
||||
flamenco_row.enabled = bcp.status in {'NONE', 'IDLE'}
|
||||
row_buttons = flamenco_row.row(align=True)
|
||||
|
||||
if bcp.status in {'NONE', 'IDLE'}:
|
||||
if not bcp.available_managers or not bcp.manager:
|
||||
row_buttons.operator('flamenco.managers',
|
||||
text='Find Flamenco Managers',
|
||||
icon='FILE_REFRESH')
|
||||
else:
|
||||
row_buttons.prop(bcp, 'manager', text='Manager')
|
||||
row_buttons.operator('flamenco.managers',
|
||||
text='',
|
||||
icon='FILE_REFRESH')
|
||||
else:
|
||||
row_buttons.label('Fetching available managers.')
|
||||
|
||||
path_box = flamenco_box.row(align=True)
|
||||
path_box.prop(self, 'flamenco_job_file_path')
|
||||
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_box = job_output_box.row(align=True)
|
||||
path_box.prop(self, 'flamenco_job_output_path')
|
||||
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_job_output_strip_components',
|
||||
text='Strip components')
|
||||
|
||||
from .flamenco import render_output_path
|
||||
|
||||
path_box = job_output_box.row(align=True)
|
||||
output_path = render_output_path(context)
|
||||
path_box.label(str(output_path))
|
||||
props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE')
|
||||
props.path = str(output_path.parent)
|
||||
|
||||
# TODO: make a reusable way to select projects, and use that for Attract and Flamenco.
|
||||
note_box = flamenco_box.column(align=True)
|
||||
note_box.label('NOTE: For now, Flamenco uses the same project as Attract.')
|
||||
note_box.label('This will change in a future version of the add-on.')
|
||||
|
||||
|
||||
class PillarCredentialsUpdate(pillar.PillarOperatorMixin,
|
||||
Operator):
|
||||
@@ -414,7 +459,7 @@ class PILLAR_OT_subscribe(Operator):
|
||||
|
||||
|
||||
class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin,
|
||||
pillar.PillarOperatorMixin,
|
||||
pillar.AuthenticatedPillarOperatorMixin,
|
||||
Operator):
|
||||
"""Fetches the projects available to the user"""
|
||||
bl_idname = 'pillar.projects'
|
||||
@@ -424,27 +469,20 @@ class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin,
|
||||
_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('Checking credentials')
|
||||
try:
|
||||
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
|
||||
|
||||
user_id = db_user['_id']
|
||||
self.log.info('Going to fetch projects for user %s', user_id)
|
||||
self.log.info('Going to fetch projects for user %s', self.user_id)
|
||||
|
||||
preferences().attract_project.status = 'FETCHING'
|
||||
|
||||
# Get all projects, except the home project.
|
||||
projects_user = await pillar_call(
|
||||
pillarsdk.Project.all,
|
||||
{'where': {'user': user_id,
|
||||
{'where': {'user': self.user_id,
|
||||
'category': {'$ne': 'home'}},
|
||||
'sort': '-_created',
|
||||
'projection': {'_id': True,
|
||||
@@ -453,8 +491,8 @@ class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin,
|
||||
|
||||
projects_shared = await pillar_call(
|
||||
pillarsdk.Project.all,
|
||||
{'where': {'user': {'$ne': user_id},
|
||||
'permissions.groups.group': {'$in': db_user.groups}},
|
||||
{'where': {'user': {'$ne': self.user_id},
|
||||
'permissions.groups.group': {'$in': self.db_user.groups}},
|
||||
'sort': '-_created',
|
||||
'projection': {'_id': True,
|
||||
'name': True},
|
||||
|
518
blender_cloud/flamenco/__init__.py
Normal file
518
blender_cloud/flamenco/__init__.py
Normal file
@@ -0,0 +1,518 @@
|
||||
# ##### 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
|
||||
from pathlib import Path, PurePath
|
||||
import typing
|
||||
|
||||
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
|
||||
from ..utils import pyside_cache, redraw
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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')
|
||||
|
||||
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
|
||||
|
||||
|
||||
class FLAMENCO_OT_fmanagers(async_loop.AsyncModalOperatorMixin,
|
||||
pillar.AuthenticatedPillarOperatorMixin,
|
||||
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
|
||||
|
||||
self.log.info('Going to fetch managers for user %s', self.user_id)
|
||||
|
||||
self.mypref.status = 'FETCHING'
|
||||
managers = await pillar_call(Manager.all)
|
||||
|
||||
# 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,
|
||||
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):
|
||||
if not await self.authenticate(context):
|
||||
return
|
||||
|
||||
context.window_manager.progress_begin(0, 4)
|
||||
context.window_manager.progress_update(1)
|
||||
|
||||
from pillarsdk import exceptions as sdk_exceptions
|
||||
from ..blender import preferences
|
||||
|
||||
filepath = Path(context.blend_data.filepath)
|
||||
scene = context.scene
|
||||
|
||||
# The file extension should be determined by the render settings, not necessarily
|
||||
# by the setttings in the output panel.
|
||||
scene.render.use_file_extension = True
|
||||
bpy.ops.wm.save_mainfile()
|
||||
|
||||
# Determine where the render output will be stored.
|
||||
render_output = render_output_path(context)
|
||||
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)
|
||||
|
||||
# BAM-pack the files to the destination directory.
|
||||
outfile, missing_sources = await self.bam_pack(filepath)
|
||||
if not outfile:
|
||||
return
|
||||
|
||||
context.window_manager.progress_update(3)
|
||||
|
||||
# Create the job at Flamenco Server.
|
||||
prefs = preferences()
|
||||
|
||||
settings = {'blender_cmd': '{blender}',
|
||||
'chunk_size': scene.flamenco_render_chunk_size,
|
||||
'filepath': str(outfile),
|
||||
'frames': scene.flamenco_render_frame_range,
|
||||
'render_output': str(render_output),
|
||||
}
|
||||
try:
|
||||
job_info = await create_job(self.user_id,
|
||||
prefs.attract_project.project,
|
||||
prefs.flamenco_manager.manager,
|
||||
scene.flamenco_render_job_type,
|
||||
settings,
|
||||
'Render %s' % filepath.name,
|
||||
priority=scene.flamenco_render_job_priority)
|
||||
except sdk_exceptions.ResourceInvalid 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(outfile.parent / '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)
|
||||
|
||||
# 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):
|
||||
super().quit()
|
||||
bpy.context.window_manager.progress_end()
|
||||
|
||||
async def bam_pack(self, filepath: Path) -> (typing.Optional[Path], typing.List[Path]):
|
||||
"""BAM-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 blend file, or None if there were errors BAM-packing,
|
||||
and a list of missing paths.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from ..blender import preferences
|
||||
from . import bam_interface
|
||||
|
||||
prefs = preferences()
|
||||
|
||||
# Create a unique directory that is still more or less identifyable.
|
||||
# This should work better than a random ID.
|
||||
# BAM doesn't like output directories that end in '.blend'.
|
||||
unique_dir = '%s-%s-%s' % (datetime.now().isoformat('-').replace(':', ''),
|
||||
self.db_user['username'],
|
||||
filepath.stem)
|
||||
outdir = Path(prefs.flamenco_job_file_path) / unique_dir
|
||||
outfile = outdir / filepath.name
|
||||
|
||||
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 None, []
|
||||
|
||||
try:
|
||||
missing_sources = await bam_interface.bam_copy(filepath, outfile)
|
||||
except bam_interface.CommandExecutionError as ex:
|
||||
self.log.exception('Unable to execute BAM pack')
|
||||
self.report({'ERROR'}, 'Unable to execute BAM pack: %s' % ex)
|
||||
self.quit()
|
||||
return None, []
|
||||
|
||||
return outfile, missing_sources
|
||||
|
||||
|
||||
class FLAMENCO_OT_scene_to_frame_range(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):
|
||||
s = context.scene
|
||||
s.flamenco_render_frame_range = '%i-%i' % (s.frame_start, s.frame_end)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class FLAMENCO_OT_copy_files(Operator,
|
||||
async_loop.AsyncModalOperatorMixin):
|
||||
"""Uses BAM to copy the current blendfile + dependencies to the target directory."""
|
||||
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):
|
||||
from pathlib import Path
|
||||
from . import bam_interface
|
||||
from ..blender import preferences
|
||||
|
||||
missing_sources = await bam_interface.bam_copy(
|
||||
Path(context.blend_data.filepath),
|
||||
Path(preferences().flamenco_job_file_path),
|
||||
)
|
||||
|
||||
if missing_sources:
|
||||
names = (ms.name for ms in missing_sources)
|
||||
self.report({'ERROR'}, 'Missing source files: %s' % '; '.join(names))
|
||||
|
||||
self.quit()
|
||||
|
||||
|
||||
class FLAMENCO_OT_explore_file_path(Operator):
|
||||
"""Opens the Flamenco job storage path in a file explorer."""
|
||||
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 subprocess
|
||||
import os
|
||||
|
||||
if platform.system() == "Windows":
|
||||
os.startfile(self.path)
|
||||
elif platform.system() == "Darwin":
|
||||
subprocess.Popen(["open", self.path])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", self.path])
|
||||
|
||||
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) -> 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
|
||||
|
||||
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: str,
|
||||
flamenco_job_output_strip_components: int,
|
||||
flamenco_job_output_path: str,
|
||||
render_image_format: str,
|
||||
flamenco_render_frame_range: str,
|
||||
) -> typing.Optional[PurePath]:
|
||||
"""Cached version of render_output_path()
|
||||
|
||||
This ensures that redraws of the Flamenco Render and Add-on preferences panels
|
||||
is fast.
|
||||
"""
|
||||
|
||||
project_path = Path(bpy.path.abspath(local_project_path)).resolve()
|
||||
blendfile = Path(blend_filepath)
|
||||
|
||||
try:
|
||||
proj_rel = blendfile.parent.relative_to(project_path)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
rel_parts = proj_rel.parts[flamenco_job_output_strip_components:]
|
||||
output_top = Path(flamenco_job_output_path)
|
||||
dir_components = output_top.joinpath(*rel_parts) / blendfile.stem
|
||||
|
||||
# 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) -> typing.Optional[PurePath]:
|
||||
"""Returns the render output path to be sent to Flamenco.
|
||||
|
||||
Returns None when the current blend file is outside the project path.
|
||||
"""
|
||||
|
||||
from ..blender import preferences
|
||||
|
||||
scene = context.scene
|
||||
prefs = preferences()
|
||||
|
||||
return _render_output_path(
|
||||
prefs.attract_project_local_path,
|
||||
context.blend_data.filepath,
|
||||
prefs.flamenco_job_output_strip_components,
|
||||
prefs.flamenco_job_output_path,
|
||||
scene.render.image_settings.file_format,
|
||||
scene.flamenco_render_frame_range,
|
||||
)
|
||||
|
||||
|
||||
class FLAMENCO_PT_render(bpy.types.Panel):
|
||||
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()
|
||||
|
||||
layout.prop(context.scene, 'flamenco_render_job_priority')
|
||||
layout.prop(context.scene, 'flamenco_render_chunk_size')
|
||||
|
||||
labeled_row = layout.split(0.2, align=True)
|
||||
labeled_row.label('Job type:')
|
||||
labeled_row.prop(context.scene, 'flamenco_render_job_type', text='')
|
||||
|
||||
labeled_row = layout.split(0.2, align=True)
|
||||
labeled_row.label('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')
|
||||
|
||||
readonly_stuff = layout.column(align=True)
|
||||
labeled_row = readonly_stuff.split(0.2, align=True)
|
||||
labeled_row.label('Storage:')
|
||||
prop_btn_row = labeled_row.row(align=True)
|
||||
prop_btn_row.label(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
|
||||
|
||||
labeled_row = readonly_stuff.split(0.2, align=True)
|
||||
labeled_row.label('Output:')
|
||||
prop_btn_row = labeled_row.row(align=True)
|
||||
render_output = render_output_path(context)
|
||||
if render_output is None:
|
||||
prop_btn_row.label('Unable to render with Flamenco, outside of project directory.')
|
||||
else:
|
||||
prop_btn_row.label(str(render_output))
|
||||
props = prop_btn_row.operator(FLAMENCO_OT_explore_file_path.bl_idname,
|
||||
text='', icon='DISK_DRIVE')
|
||||
props.path = str(render_output.parent)
|
||||
|
||||
layout.operator(FLAMENCO_OT_render.bl_idname,
|
||||
text='Render on Flamenco',
|
||||
icon='RENDER_ANIMATION')
|
||||
|
||||
if not context.scene.render.use_overwrite:
|
||||
warnbox = layout.box().column(align=True)
|
||||
warnbox.label('Please enable "Overwrite" in the Output panel,')
|
||||
warnbox.label('or re-queueing this job might not do anything.')
|
||||
|
||||
|
||||
def register():
|
||||
bpy.utils.register_class(FlamencoManagerGroup)
|
||||
bpy.utils.register_class(FLAMENCO_OT_fmanagers)
|
||||
bpy.utils.register_class(FLAMENCO_OT_render)
|
||||
bpy.utils.register_class(FLAMENCO_OT_scene_to_frame_range)
|
||||
bpy.utils.register_class(FLAMENCO_OT_copy_files)
|
||||
bpy.utils.register_class(FLAMENCO_OT_explore_file_path)
|
||||
bpy.utils.register_class(FLAMENCO_PT_render)
|
||||
|
||||
scene = bpy.types.Scene
|
||||
scene.flamenco_render_chunk_size = IntProperty(
|
||||
name='Chunk size',
|
||||
description='Maximum number of frames to render per task',
|
||||
default=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 Blender render', 'Not tiled, not resumable, just render'),
|
||||
],
|
||||
description='Flamenco render job type',
|
||||
)
|
||||
scene.flamenco_render_job_priority = IntProperty(
|
||||
name='Job priority',
|
||||
min=0,
|
||||
default=50,
|
||||
max=100,
|
||||
description='Higher numbers mean higher priority'
|
||||
)
|
||||
|
||||
|
||||
def unregister():
|
||||
bpy.utils.unregister_module(__name__)
|
||||
|
||||
try:
|
||||
del bpy.types.Scene.flamenco_render_chunk_size
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
del bpy.types.Scene.flamenco_render_frame_range
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
del bpy.types.Scene.flamenco_render_job_type
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
del bpy.types.Scene.flamenco_render_job_priority
|
||||
except AttributeError:
|
||||
pass
|
144
blender_cloud/flamenco/bam_interface.py
Normal file
144
blender_cloud/flamenco/bam_interface.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""BAM packing interface for Flamenco."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import typing
|
||||
|
||||
# Timeout of the BAM subprocess, in seconds.
|
||||
SUBPROC_READLINE_TIMEOUT = 600
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommandExecutionError(Exception):
|
||||
"""Raised when there was an error executing a BAM command."""
|
||||
pass
|
||||
|
||||
|
||||
async def bam_copy(base_blendfile: Path, target_blendfile: Path) -> typing.List[Path]:
|
||||
"""Uses BAM to copy the given file and dependencies to the target blendfile.
|
||||
|
||||
Due to the way blendfile_pack.py is programmed/structured, we cannot import it
|
||||
and call a function; it has to be run in a subprocess.
|
||||
|
||||
:raises: asyncio.CanceledError if the task was cancelled.
|
||||
:raises: asyncio.TimeoutError if reading a line from the BAM process timed out.
|
||||
:raises: CommandExecutionError if the subprocess failed or output invalid UTF-8.
|
||||
:returns: a list of missing sources; hopefully empty.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
import bpy
|
||||
import io_blend_utils
|
||||
|
||||
args = [
|
||||
bpy.app.binary_path_python,
|
||||
'-m', 'bam.pack',
|
||||
'--input', str(base_blendfile),
|
||||
'--output', str(target_blendfile),
|
||||
'--mode', 'FILE',
|
||||
]
|
||||
|
||||
cmd_to_log = ' '.join(shlex.quote(s) for s in args)
|
||||
log.info('Executing %s', cmd_to_log)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
env={'PYTHONPATH': io_blend_utils.pythonpath()},
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
missing_sources = []
|
||||
|
||||
try:
|
||||
while not proc.stdout.at_eof():
|
||||
line = await asyncio.wait_for(proc.stdout.readline(),
|
||||
SUBPROC_READLINE_TIMEOUT)
|
||||
if not line:
|
||||
# EOF received, so let's bail.
|
||||
break
|
||||
|
||||
try:
|
||||
line = line.decode('utf8')
|
||||
except UnicodeDecodeError as ex:
|
||||
raise CommandExecutionError('Command produced non-UTF8 output, '
|
||||
'aborting: %s' % ex)
|
||||
|
||||
line = line.rstrip()
|
||||
if 'source missing:' in line:
|
||||
path = parse_missing_source(line)
|
||||
missing_sources.append(path)
|
||||
log.warning('Source is missing: %s', path)
|
||||
|
||||
log.info(' %s', line)
|
||||
finally:
|
||||
if proc.returncode is None:
|
||||
# Always wait for the process, to avoid zombies.
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
# The process is already stopped, so killing is impossible. That's ok.
|
||||
log.debug("The process was already stopped, aborting is impossible. That's ok.")
|
||||
await proc.wait()
|
||||
log.info('The process stopped with status code %i', proc.returncode)
|
||||
|
||||
if proc.returncode:
|
||||
raise CommandExecutionError('Process stopped with status %i' % proc.returncode)
|
||||
|
||||
return missing_sources
|
||||
|
||||
|
||||
def parse_missing_source(line: str) -> Path:
|
||||
r"""Parses a "missing source" line into a pathlib.Path.
|
||||
|
||||
>>> parse_missing_source(r" source missing: b'D\xc3\xaffficult \xc3\x9cTF-8 filename'")
|
||||
PosixPath('Dïfficult ÜTF-8 filename')
|
||||
>>> parse_missing_source(r" source missing: b'D\xfffficult Win1252 f\xeflen\xe6me'")
|
||||
PosixPath('D<EFBFBD>fficult Win1252 f<>len<65>me')
|
||||
"""
|
||||
|
||||
_, missing_source = line.split(': ', 1)
|
||||
missing_source_as_bytes = parse_byte_literal(missing_source.strip())
|
||||
|
||||
# The file could originate from any platform, so UTF-8 and the current platform's
|
||||
# filesystem encodings are just guesses.
|
||||
try:
|
||||
missing_source = missing_source_as_bytes.decode('utf8')
|
||||
except UnicodeDecodeError:
|
||||
import sys
|
||||
try:
|
||||
missing_source = missing_source_as_bytes.decode(sys.getfilesystemencoding())
|
||||
except UnicodeDecodeError:
|
||||
missing_source = missing_source_as_bytes.decode('ascii', errors='replace')
|
||||
|
||||
path = Path(missing_source)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def parse_byte_literal(bytes_literal: str) -> bytes:
|
||||
r"""Parses a repr(bytes) output into a bytes object.
|
||||
|
||||
>>> parse_byte_literal(r"b'D\xc3\xaffficult \xc3\x9cTF-8 filename'")
|
||||
b'D\xc3\xaffficult \xc3\x9cTF-8 filename'
|
||||
>>> parse_byte_literal(r"b'D\xeffficult Win1252 f\xeflen\xe6me'")
|
||||
b'D\xeffficult Win1252 f\xeflen\xe6me'
|
||||
"""
|
||||
|
||||
# Some very basic assertions to make sure we have a proper bytes literal.
|
||||
assert bytes_literal[0] == "b"
|
||||
assert bytes_literal[1] in {'"', "'"}
|
||||
assert bytes_literal[-1] == bytes_literal[1]
|
||||
|
||||
import ast
|
||||
return ast.literal_eval(bytes_literal)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import doctest
|
||||
|
||||
doctest.testmod()
|
13
blender_cloud/flamenco/sdk.py
Normal file
13
blender_cloud/flamenco/sdk.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pillarsdk.resource import List, Find, Create
|
||||
|
||||
|
||||
class Manager(List, Find):
|
||||
"""Manager class wrapping the REST nodes endpoint"""
|
||||
path = 'flamenco/managers'
|
||||
|
||||
|
||||
class Job(List, Find, Create):
|
||||
"""Job class wrapping the REST nodes endpoint
|
||||
"""
|
||||
path = 'flamenco/jobs'
|
||||
ensure_query_projections = {'project': 1}
|
@@ -840,6 +840,32 @@ class PillarOperatorMixin:
|
||||
'Please subscribe to the blender cloud at https://cloud.blender.org/join')
|
||||
|
||||
|
||||
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
|
||||
|
||||
self.user_id = self.db_user['_id']
|
||||
return True
|
||||
|
||||
|
||||
|
||||
async def find_or_create_node(where: dict,
|
||||
additional_create_props: dict = None,
|
||||
projection: dict = None,
|
||||
|
@@ -62,3 +62,41 @@ def find_in_path(path: pathlib.Path, filename: str) -> pathlib.Path:
|
||||
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):
|
||||
context.area.tag_redraw()
|
||||
|
35
setup.py
35
setup.py
@@ -18,11 +18,13 @@
|
||||
# ##### END GPL LICENSE BLOCK #####
|
||||
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
import re
|
||||
import pathlib
|
||||
import zipfile
|
||||
|
||||
from distutils import log
|
||||
from distutils.core import Command
|
||||
@@ -168,6 +170,34 @@ 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."""
|
||||
@@ -191,16 +221,17 @@ 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.5.1',
|
||||
version='1.5.9999',
|
||||
author='Sybren A. Stüvel',
|
||||
author_email='sybren@stuvel.eu',
|
||||
packages=find_packages('.'),
|
||||
data_files=[('blender_cloud', ['README.md']),
|
||||
data_files=[('blender_cloud', ['README.md', 'README-flamenco.md']),
|
||||
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
|
||||
scripts=[],
|
||||
url='https://developer.blender.org/diffusion/BCA/',
|
||||
|
Reference in New Issue
Block a user