Compare commits

..

58 Commits

Author SHA1 Message Date
68b046c714 Bumped version to 1.5.2 2017-01-06 17:01:58 +01:00
cb20d6ee03 Fixed icon, the MOVE_UP_VEC icon was removed
This happened in Blender commit rBb2159b94bcd6c4e30c47b9970601e6f2ca2a0750
2017-01-06 17:01:06 +01:00
Dalai Felinto
645bdd950f Attract: prevent ui/console errors when no strip exists 2016-12-06 22:00:58 +01:00
Dalai Felinto
74a5830dae setup fdist option for files distribution (unzipped files)
This is the equivalent of `python setup.py bdist` and unzipping the outputted file. If no --dest-path is specified it dumps the files in the same folder as the .zip.

It is used for symlinking of the latest addon in production computers without having to deal with .zip files.

Review, design, lessons, patient orientations by Sybren Stüvel
2016-11-11 16:15:24 +01:00
da4d4df5fb Bumped version to 1.5.1 2016-11-11 09:28:10 +01:00
9c3098cc0d Attract: Added some poll methods 2016-11-11 09:26:52 +01:00
98beaf7fb7 Unlinking a single shot copies the shot ID to the clipboard.
This allows you to easily unlink one strip, and relink another. This cannot
be done for multiple shots simultaneously.
2016-11-11 09:23:39 +01:00
3364371ac6 Use '{nr of shots} Shots' instead of 'Selected Shots'
This is a bit shorter, and more concrete.
2016-11-11 09:22:59 +01:00
2723b07fa2 Nicer button layout for unlink & delete
This makes 'delete' harder to hit accidentally.
2016-11-11 09:17:25 +01:00
c2a037ca89 Added button to copy a shot ID to the clipboard 2016-11-11 09:15:04 +01:00
d3451d4de3 Icon tweaks 2016-11-11 09:14:51 +01:00
5094977614 Attract: "delete shots" now works on all selected Attract strips 2016-11-11 09:06:59 +01:00
Dalai Felinto
a11a55be22 Implement attract "Trim End" 2016-11-10 18:49:13 +01:00
68d2fc8e42 Fixed scene update post handler disappearing. 2016-11-08 15:11:27 +01:00
2de4a8e87c Removed beta warning 2016-11-08 14:09:33 +01:00
39b2bacdcc Bumped version to 1.5.0 2016-11-08 14:08:15 +01:00
a1416f99dd 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>
2016-11-08 14:08:03 +01:00
0fa7d60028 Keep Attract & Image Share prefs enabled when sync error is shown. 2016-11-08 10:14:26 +01:00
c1b6480f9a Texture browser: Also draw a dark background for INITIALIZING state 2016-11-08 10:14:26 +01:00
56353d4177 More captialisation of button labels. 2016-11-07 13:39:33 +01:00
469a9318af Also allow meta strips to be considered "shots" in Attract. 2016-11-07 13:39:14 +01:00
e265081131 Change wiki URL to HTTPS 2016-11-07 11:23:30 +01:00
115eea82c6 Prevent errors when there is no sequencer in the current scene 2016-11-04 17:47:27 +01:00
900068a6f5 Slight improvements to Attract shot delete operator 2016-11-04 17:47:13 +01:00
c8229500d1 Bugfix 2016-11-04 16:42:26 +01:00
65ff9da428 Attract: Draw conflicts more subtly 2016-11-04 16:12:13 +01:00
fcba8a2e0f Attract: Compute strip conflicts in scene update handler 2016-11-04 16:11:59 +01:00
7ef5e522f8 Attract thumbs: only use middle frame when current frame not on shot
This now also applies when rendering multiple shot thumbnails.
2016-11-04 15:45:23 +01:00
ae570e5907 Allow undeletion of shots by relinking to the edit. 2016-11-04 13:44:12 +01:00
16b90d2ea8 Bumped pillarsdk version to 1.6.1 2016-11-04 12:38:30 +01:00
875c92ee9d Bumped version 1.4.999 2016-11-04 12:34:04 +01:00
cbfc75a89c Attract: added operator to send all shots to Attract at once. 2016-11-04 12:32:21 +01:00
54e676b36f Only send 'unlink' cmd to Attract if ObjectID no longer used in edit.
Previously it would always send 'unlink', even when a duplicate strip was
removed.
2016-11-04 12:05:21 +01:00
c94b0f5f2d Slightly nicer wording of delete warning. 2016-11-04 12:04:53 +01:00
a58bfe9a76 Be more explicit in strip ObjectID conflicts.
Shows more warnings in the UI, and the conflict status is now saved on
an RNA property atc_object_id_conflict.
2016-11-04 12:04:38 +01:00
d332e1e50a Attract: Draw conflicts (two strips linked to the same shot) in red outline 2016-11-04 11:36:40 +01:00
Dalai Felinto
dd66d5ce93 UI elements should be capitalized in Blender 2016-11-04 10:20:54 +01:00
965b02cec4 Attract: Send relink/unlink shots as PATCH 2016-11-03 18:44:31 +01:00
fd67675c12 Formatting 2016-11-03 17:37:06 +01:00
06a661126b Support rendering for selection of shots.
Also chooses middle frame of shot when rendering a single shot AND the
current frame isn't on that shot.
2016-11-03 17:24:44 +01:00
191b150280 Removed redundant tempfile 2016-11-03 16:33:33 +01:00
feb62ddae0 Attract: render thumbnail button
Still a bit rough, needs GUI response to show upload is happening.
2016-11-03 14:47:54 +01:00
603159f0d1 Improved labels for some Attract buttons 2016-11-03 12:54:33 +01:00
d2ae3f9cb7 Improved pyside_cache decorator
This one should work properly when multiple properties use the same
callback function.
2016-11-03 12:53:03 +01:00
079f8ff4c3 When relinking shot, get node ID from clipboard 2016-10-18 14:31:56 +02:00
c97859ef33 Unified three 'submit to Attract' operators. 2016-10-18 14:22:37 +02:00
f1ebea8948 Added "Open shot in browser" button 2016-10-18 12:26:46 +02:00
1b82977c6e Always unregister module, even when Sequence property doesn't want to be deleted 2016-10-18 12:25:05 +02:00
a7307bf7b5 Separate URLs for web and API calls 2016-10-18 12:24:43 +02:00
11fd12e125 Fixed copy/paste error 2016-10-18 12:23:36 +02:00
54dccb20ba Prevent caching issue when refreshing shot info from Attract 2016-10-18 12:23:27 +02:00
61a8db3f96 Added workaround for EnumProperty string limitation
See https://www.blender.org/api/blender_python_api_master/bpy.props.html#bpy.props.EnumProperty
2016-10-18 11:20:06 +02:00
0b2f0a3ec1 Open in new Blender: switch to the scene that rendered the mkv 2016-10-13 10:06:15 +02:00
5117ec7cde Open in new Blender: pass all --enable-xxx CLI options to sub-blender. 2016-10-13 10:05:58 +02:00
74f61fa83a Open in new Blender: also switch to correct scene 2016-10-11 12:51:20 +02:00
f73671c4f0 Added "Open in new Blender" button for movie strips with metadata.
The metadata requires the following fields:

"BLENDER:BLEND_FILE" (to open the file)
"BLENDER:START_FRAME" and "BLENDER:END_FRAME" (just to display)
2016-10-11 11:17:24 +02:00
6f970a41e5 Added utils.find_in_path + unittest 2016-10-11 10:52:27 +02:00
ccedb7cbb1 Fix operator descriptions ending in '.'
By default, blender will take the operator class description comment as the description for the UI tooltip, which should not have a trailing dot.
I duplicated the descriptions into an explicit bl_description to have a clear distinction between user UI and coder comment, but simply removing the dot from the comment would also work
2016-10-04 23:17:50 +02:00
14 changed files with 823 additions and 119 deletions

View File

@@ -21,15 +21,14 @@
bl_info = {
'name': 'Blender Cloud',
"author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis",
'version': (1, 4, 99),
'version': (1, 5, 2),
'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': 'http://wiki.blender.org/index.php/Extensions:2.6/Py/'
'wiki_url': 'https://wiki.blender.org/index.php/Extensions:2.6/Py/'
'Scripts/System/BlenderCloud',
'category': 'System',
'warning': 'This is a beta version; the first to support Attract.'
}
import logging

View File

@@ -36,17 +36,22 @@
# "support": "TESTING"
# }
import contextlib
import functools
import logging
if "bpy" in locals():
import importlib
importlib.reload(draw)
draw = importlib.reload(draw)
pillar = importlib.reload(pillar)
async_loop = importlib.reload(async_loop)
else:
from . import draw
from .. import pillar, async_loop
import bpy
import pillarsdk
from pillarsdk.nodes import Node
from pillarsdk.projects import Project
from pillarsdk import exceptions as sdk_exceptions
@@ -63,6 +68,49 @@ def active_strip(context):
return None
def selected_shots(context):
"""Generator, yields selected strips if they are Attract shots."""
selected_sequences = 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
yield strip
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')
if not atc_object_id:
continue
yield strip
def shown_strips(context):
"""Returns the strips from the current meta-strip-stack, or top-level strips.
What is returned depends on what the user is currently editing.
"""
if context.scene.sequence_editor.meta_stack:
return context.scene.sequence_editor.meta_stack[-1].sequences
return context.scene.sequence_editor.sequences
def remove_atc_props(strip):
"""Resets the attract custom properties assigned to a VSE strip"""
@@ -72,6 +120,47 @@ def remove_atc_props(strip):
strip.atc_is_synced = False
def shot_id_use(strips):
"""Returns a mapping from shot Object ID to a list of strips that use it."""
import collections
# Count the number of uses per Object ID, so that we can highlight double use.
ids_in_use = collections.defaultdict(list)
for strip in strips:
if not getattr(strip, 'atc_is_synced', False):
continue
ids_in_use[strip.atc_object_id].append(strip)
return ids_in_use
def compute_strip_conflicts(scene):
"""Sets the strip property atc_object_id_conflict for each strip."""
if not scene or not scene.sequence_editor or not scene.sequence_editor.sequences_all:
return
tag_redraw = False
ids_in_use = shot_id_use(scene.sequence_editor.sequences_all)
for strips in ids_in_use.values():
is_conflict = len(strips) > 1
for strip in strips:
if strip.atc_object_id_conflict != is_conflict:
tag_redraw = True
strip.atc_object_id_conflict = is_conflict
if tag_redraw:
draw.tag_redraw_all_sequencer_editors()
return ids_in_use
@bpy.app.handlers.persistent
def scene_update_post_handler(scene):
compute_strip_conflicts(scene)
class ToolsPanel(Panel):
bl_label = 'Attract'
bl_space_type = 'SEQUENCE_EDITOR'
@@ -85,8 +174,21 @@ class ToolsPanel(Panel):
def draw(self, context):
strip = active_strip(context)
layout = self.layout
strip_types = {'MOVIE', 'IMAGE'}
if strip and strip.atc_object_id and strip.type in strip_types:
strip_types = {'MOVIE', 'IMAGE', 'META'}
selshots = list(selected_shots(context))
if strip and strip.type in strip_types and strip.atc_object_id:
if len(selshots) > 1:
noun = '%i Shots' % len(selshots)
else:
noun = 'This Shot'
if strip.atc_object_id_conflict:
warnbox = layout.box()
warnbox.alert = True
warnbox.label('Warning: This shot is linked to multiple sequencer strips.',
icon='ERROR')
layout.prop(strip, 'atc_name', text='Name')
layout.prop(strip, 'atc_status', text='Status')
@@ -97,23 +199,43 @@ class ToolsPanel(Panel):
ro_sub.prop(strip, 'atc_notes', text='Notes')
if strip.atc_is_synced:
row = layout.row(align=True)
row.operator('attract.shot_submit_update')
sub = layout.column(align=True)
row = sub.row(align=True)
if bpy.ops.attract.submit_selected.poll():
row.operator('attract.submit_selected',
text='Submit %s' % noun,
icon='TRIA_UP')
else:
row.operator(ATTRACT_OT_submit_all.bl_idname)
row.operator(AttractShotFetchUpdate.bl_idname,
text='', icon='FILE_REFRESH')
row.operator(ATTRACT_OT_shot_open_in_browser.bl_idname,
text='', icon='WORLD')
row.operator(ATTRACT_OT_copy_id_to_clipboard.bl_idname,
text='', icon='COPYDOWN')
sub.operator(ATTRACT_OT_make_shot_thumbnail.bl_idname,
text='Render Thumbnail for %s' % noun,
icon='RENDER_STILL')
# Group more dangerous operations.
dangerous_sub = layout.column(align=True)
dangerous_sub.operator('attract.shot_delete')
dangerous_sub.operator('attract.strip_unlink')
dangerous_sub = layout.split(0.6, align=True)
dangerous_sub.operator('attract.strip_unlink',
text='Unlink %s' % noun,
icon='PANEL_CLOSE')
dangerous_sub.operator(AttractShotDelete.bl_idname,
text='Delete %s' % noun,
icon='CANCEL')
elif strip and strip.type in strip_types:
layout.operator('attract.shot_submit_new')
elif context.selected_sequences:
if len(context.selected_sequences) > 1:
noun = 'Selected Strips'
else:
noun = 'This Strip'
layout.operator(AttractShotSubmitSelected.bl_idname,
text='Submit %s as New Shot' % noun)
layout.operator('attract.shot_relink')
else:
layout.label(text='Select a Movie or Image strip')
layout.operator(AttractShotSubmitSelected.bl_idname)
layout.operator(ATTRACT_OT_submit_all.bl_idname)
class AttractOperatorMixin:
@@ -166,7 +288,9 @@ class AttractOperatorMixin:
'description': '',
'properties': {'status': 'todo',
'notes': '',
'used_in_edit': True,
'trim_start_in_frames': strip.frame_offset_start,
'trim_end_in_frames': strip.frame_offset_end,
'duration_in_edit_in_frames': strip.frame_final_duration,
'cut_in_timeline_in_frames': strip.frame_final_start},
'order': 0,
@@ -201,9 +325,11 @@ class AttractOperatorMixin:
'$set': {
'name': strip.atc_name,
'properties.trim_start_in_frames': strip.frame_offset_start,
'properties.trim_end_in_frames': strip.frame_offset_end,
'properties.duration_in_edit_in_frames': strip.frame_final_duration,
'properties.cut_in_timeline_in_frames': strip.frame_final_start,
'properties.status': strip.atc_status,
'properties.used_in_edit': True,
}
}
@@ -211,72 +337,57 @@ class AttractOperatorMixin:
result = pillar.sync_call(node.patch, patch)
log.info('PATCH result: %s', result)
def relink(self, strip, atc_object_id):
def relink(self, strip, atc_object_id, *, refresh=False):
from .. import pillar
# The node may have been deleted, so we need to send a 'relink' before we try
# to fetch the node itself.
node = Node({'_id': atc_object_id})
pillar.sync_call(node.patch, {'op': 'relink'})
try:
node = pillar.sync_call(Node.find, atc_object_id)
node = pillar.sync_call(Node.find, atc_object_id, caching=False)
except (sdk_exceptions.ResourceNotFound, sdk_exceptions.MethodNotAllowed):
self.report({'ERROR'}, 'Shot %r not found on the Attract server, unable to relink.'
% atc_object_id)
verb = 'refresh' if refresh else 'relink'
self.report({'ERROR'}, 'Shot %r not found on the Attract server, unable to %s.'
% (atc_object_id, verb))
strip.atc_is_synced = False
return {'CANCELLED'}
strip.atc_is_synced = True
strip.atc_name = node.name
strip.atc_object_id = node['_id']
if not refresh:
strip.atc_name = node.name
strip.atc_object_id = node['_id']
# We do NOT set the position/cuts of the shot, that always has to come from Blender.
strip.atc_status = node.properties.status
strip.atc_notes = node.properties.notes or ''
strip.atc_description = node.description or ''
draw.tag_redraw_all_sequencer_editors()
class AttractShotSubmitNew(AttractOperatorMixin, Operator):
bl_idname = "attract.shot_submit_new"
bl_label = "Submit to Attract"
@classmethod
def poll(cls, context):
strip = active_strip(context)
return not strip.atc_object_id
def execute(self, context):
strip = active_strip(context)
if strip.atc_object_id:
return
node_type = self.find_node_type('attract_shot')
if isinstance(node_type, set): # in case of error
return node_type
return self.submit_new_strip(strip) or {'FINISHED'}
class AttractShotFetchUpdate(AttractOperatorMixin, Operator):
bl_idname = "attract.shot_fetch_update"
bl_label = "Fetch update from Attract"
bl_label = "Fetch Update From Attract"
bl_description = 'Update status, description & notes from Attract'
@classmethod
def poll(cls, context):
strip = active_strip(context)
return strip is not None and getattr(strip, 'atc_object_id', None)
return any(selected_shots(context))
def execute(self, context):
strip = active_strip(context)
status = self.relink(strip, strip.atc_object_id)
if isinstance(status, set):
return status
self.report({'INFO'}, "Shot {0} refreshed".format(strip.atc_name))
for strip in selected_shots(context):
status = self.relink(strip, strip.atc_object_id, refresh=True)
# We don't abort when one strip fails. All selected shots should be
# refreshed, even if one can't be found (for example).
if not isinstance(status, set):
self.report({'INFO'}, "Shot {0} refreshed".format(strip.atc_name))
return {'FINISHED'}
class AttractShotRelink(AttractShotFetchUpdate):
bl_idname = "attract.shot_relink"
bl_label = "Relink with Attract"
bl_label = "Relink With Attract"
strip_atc_object_id = bpy.props.StringProperty()
@@ -298,6 +409,15 @@ class AttractShotRelink(AttractShotFetchUpdate):
return {'FINISHED'}
def invoke(self, context, event):
maybe_id = context.window_manager.clipboard
if len(maybe_id) == 24:
try:
int(maybe_id, 16)
except ValueError:
pass
else:
self.strip_atc_object_id = maybe_id
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
@@ -306,26 +426,41 @@ class AttractShotRelink(AttractShotFetchUpdate):
col.prop(self, 'strip_atc_object_id', text='Shot ID')
class AttractShotSubmitUpdate(AttractOperatorMixin, Operator):
bl_idname = 'attract.shot_submit_update'
bl_label = 'Submit update'
bl_description = 'Sends local changes to Attract'
class ATTRACT_OT_shot_open_in_browser(AttractOperatorMixin, Operator):
bl_idname = 'attract.shot_open_in_browser'
bl_label = 'Open in Browser'
bl_description = 'Opens a webbrowser to show the shot on Attract'
@classmethod
def poll(cls, context):
return bool(context.selected_sequences and active_strip(context))
def execute(self, context):
strip = active_strip(context)
self.submit_update(strip)
from ..blender import PILLAR_WEB_SERVER_URL
import webbrowser
import urllib.parse
strip = active_strip(context)
url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL,
'nodes/%s/redir' % strip.atc_object_id)
webbrowser.open_new_tab(url)
self.report({'INFO'}, 'Opened a browser at %s' % url)
self.report({'INFO'}, 'Shot was updated on Attract')
return {'FINISHED'}
class AttractShotDelete(AttractOperatorMixin, Operator):
bl_idname = 'attract.shot_delete'
bl_label = 'Delete'
bl_description = 'Remove from Attract'
bl_label = 'Delete Shot'
bl_description = 'Remove this shot from Attract'
confirm = bpy.props.BoolProperty(name='confirm')
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
def execute(self, context):
from .. import pillar
@@ -333,37 +468,80 @@ class AttractShotDelete(AttractOperatorMixin, Operator):
self.report({'WARNING'}, 'Delete aborted.')
return {'CANCELLED'}
strip = active_strip(context)
node = pillar.sync_call(Node.find, strip.atc_object_id)
if not pillar.sync_call(node.delete):
print('Unable to delete the strip node on Attract.')
return {'CANCELLED'}
removed = kept = 0
for strip in selected_shots(context):
node = pillar.sync_call(Node.find, strip.atc_object_id)
if not pillar.sync_call(node.delete):
self.report({'ERROR'}, 'Unable to delete shot %s on Attract.' % strip.atc_name)
kept += 1
continue
remove_atc_props(strip)
removed += 1
if kept:
self.report({'ERROR'}, 'Removed %i shots, but was unable to remove %i' %
(removed, kept))
else:
self.report({'INFO'}, 'Removed all %i shots from Attract' % removed)
remove_atc_props(strip)
draw.tag_redraw_all_sequencer_editors()
return {'FINISHED'}
def invoke(self, context, event):
self.confirm = False
return context.window_manager.invoke_props_dialog(self)
def draw(self, context):
layout = self.layout
col = layout.column()
col.prop(self, 'confirm', text="I hereby confirm I want to delete this shot.")
selshots = list(selected_shots(context))
if len(selshots) > 1:
noun = '%i shots' % len(selshots)
else:
noun = 'this shot'
col.prop(self, 'confirm', text="I hereby confirm: delete %s from The Edit." % noun)
class AttractStripUnlink(AttractOperatorMixin, Operator):
bl_idname = 'attract.strip_unlink'
bl_label = 'Unlink'
bl_label = 'Unlink Shot From This Strip'
bl_description = 'Remove Attract props from the selected strip(s)'
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
def execute(self, context):
unlinked_ids = set()
# First remove the Attract properties from the strips.
for strip in context.selected_sequences:
atc_object_id = getattr(strip, 'atc_object_id')
remove_atc_props(strip)
if atc_object_id:
self.report({'INFO'}, 'Shot %s has been unlinked from Attract.' % atc_object_id)
unlinked_ids.add(atc_object_id)
# For all Object IDs that are no longer in use in the edit, let Attract know.
# This should be done with care, as the shot could have been attached to multiple
# strips.
id_to_shots = compute_strip_conflicts(context.scene)
for oid in unlinked_ids:
if len(id_to_shots[oid]):
# Still in use
continue
node = Node({'_id': oid})
pillar.sync_call(node.patch, {'op': 'unlink'})
if len(unlinked_ids) == 1:
shot_id = unlinked_ids.pop()
context.window_manager.clipboard = shot_id
self.report({'INFO'}, 'Copied unlinked shot ID %s to clipboard' % shot_id)
else:
self.report({'INFO'}, '%i shots have been marked as Unused.' % len(unlinked_ids))
draw.tag_redraw_all_sequencer_editors()
return {'FINISHED'}
@@ -371,7 +549,7 @@ class AttractStripUnlink(AttractOperatorMixin, Operator):
class AttractShotSubmitSelected(AttractOperatorMixin, Operator):
bl_idname = 'attract.submit_selected'
bl_label = 'Submit all selected'
bl_label = 'Submit All Selected'
bl_description = 'Submits all selected strips to Attract'
@classmethod
@@ -380,9 +558,9 @@ class AttractShotSubmitSelected(AttractOperatorMixin, Operator):
def execute(self, context):
# Check that the project is set up for Attract.
node_type = self.find_node_type('attract_shot')
if isinstance(node_type, set):
return node_type
maybe_error = self.find_node_type('attract_shot')
if isinstance(maybe_error, set):
return maybe_error
for strip in context.selected_sequences:
status = self.submit(strip)
@@ -404,43 +582,379 @@ class AttractShotSubmitSelected(AttractOperatorMixin, Operator):
return self.submit_update(strip)
class ATTRACT_OT_submit_all(AttractOperatorMixin, Operator):
bl_idname = 'attract.submit_all'
bl_label = 'Submit All Shots to Attract'
bl_description = 'Updates Attract with the current state of the edit'
def execute(self, context):
# Check that the project is set up for Attract.
maybe_error = self.find_node_type('attract_shot')
if isinstance(maybe_error, set):
return maybe_error
for strip in all_shots(context):
status = self.submit_update(strip)
if isinstance(status, set):
return status
self.report({'INFO'}, 'All strips re-sent to Attract.')
return {'FINISHED'}
class ATTRACT_OT_open_meta_blendfile(AttractOperatorMixin, Operator):
bl_idname = 'attract.open_meta_blendfile'
bl_label = 'Open Blendfile'
bl_description = 'Open Blendfile from movie strip metadata'
@classmethod
def poll(cls, context):
return bool(any(cls.filename_from_metadata(s) for s in context.selected_sequences))
@staticmethod
def filename_from_metadata(strip):
"""Returns the blendfile name from the strip metadata, or None."""
# Metadata is a dict like:
# meta = {'END_FRAME': '88',
# 'BLEND_FILE': 'metadata-test.blend',
# 'SCENE': 'SüperSčene',
# 'FRAME_STEP': '1',
# 'START_FRAME': '32'}
meta = strip.get('metadata', None)
if not meta:
return None
return meta.get('BLEND_FILE', None) or None
def execute(self, context):
for strip in context.selected_sequences:
meta = strip.get('metadata', None)
if not meta:
continue
fname = meta.get('BLEND_FILE', None)
if not fname: continue
scene = meta.get('SCENE', None)
self.open_in_new_blender(fname, scene)
return {'FINISHED'}
def open_in_new_blender(self, fname, scene):
"""
:type fname: str
:type scene: str
"""
import subprocess
import sys
cmd = [
bpy.app.binary_path,
str(fname),
]
cmd[1:1] = [v for v in sys.argv if v.startswith('--enable-')]
if scene:
cmd.extend(['--python-expr',
'import bpy; bpy.context.screen.scene = bpy.data.scenes["%s"]' % scene])
cmd.extend(['--scene', scene])
subprocess.Popen(cmd)
class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin,
async_loop.AsyncModalOperatorMixin,
Operator):
bl_idname = 'attract.make_shot_thumbnail'
bl_label = 'Render Shot Thumbnail'
bl_description = 'Renders the current frame, and uploads it as thumbnail for the shot'
stop_upon_exception = True
@classmethod
def poll(cls, context):
return bool(context.selected_sequences)
@contextlib.contextmanager
def thumbnail_render_settings(self, context, thumbnail_width=512):
# Remember current settings so we can restore them later.
orig_res_x = context.scene.render.resolution_x
orig_res_y = context.scene.render.resolution_y
orig_percentage = context.scene.render.resolution_percentage
orig_file_format = context.scene.render.image_settings.file_format
orig_quality = context.scene.render.image_settings.quality
try:
# Update the render size to something thumbnaily.
factor = orig_res_y / orig_res_x
context.scene.render.resolution_x = thumbnail_width
context.scene.render.resolution_y = round(thumbnail_width * factor)
context.scene.render.resolution_percentage = 100
context.scene.render.image_settings.file_format = 'JPEG'
context.scene.render.image_settings.quality = 85
yield
finally:
# Return the render settings to normal.
context.scene.render.resolution_x = orig_res_x
context.scene.render.resolution_y = orig_res_y
context.scene.render.resolution_percentage = orig_percentage
context.scene.render.image_settings.file_format = orig_file_format
context.scene.render.image_settings.quality = orig_quality
@contextlib.contextmanager
def temporary_current_frame(self, context):
"""Allows the context to set the scene current frame, restores it on exit.
Yields the initial current frame, so it can be used for reference in the context.
"""
current_frame = context.scene.frame_current
try:
yield current_frame
finally:
context.scene.frame_current = current_frame
async def async_execute(self, context):
nr_of_strips = len(context.selected_sequences)
do_multishot = nr_of_strips > 1
with self.temporary_current_frame(context) as original_curframe:
# The multishot and singleshot branches do pretty much the same thing,
# but report differently to the user.
if do_multishot:
context.window_manager.progress_begin(0, nr_of_strips)
try:
self.report({'INFO'}, 'Rendering thumbnails for %i selected shots.' %
nr_of_strips)
strips = sorted(context.selected_sequences, key=self.by_frame)
for idx, strip in enumerate(strips):
context.window_manager.progress_update(idx)
# Pick the middle frame, except for the strip the original current frame
# marker was over.
if not self.strip_contains(strip, original_curframe):
self.set_middle_frame(context, strip)
else:
context.scene.frame_set(original_curframe)
await self.thumbnail_strip(context, strip)
if self._state == 'QUIT':
return
context.window_manager.progress_update(nr_of_strips)
finally:
context.window_manager.progress_end()
else:
strip = active_strip(context)
if not self.strip_contains(strip, original_curframe):
self.report({'WARNING'}, 'Rendering middle frame as thumbnail for active shot.')
self.set_middle_frame(context, strip)
else:
self.report({'INFO'}, 'Rendering current frame as thumbnail for active shot.')
context.window_manager.progress_begin(0, 1)
context.window_manager.progress_update(0)
try:
await self.thumbnail_strip(context, strip)
finally:
context.window_manager.progress_update(1)
context.window_manager.progress_end()
if self._state == 'QUIT':
return
self.report({'INFO'}, 'Thumbnail uploaded to Attract')
self.quit()
@staticmethod
def strip_contains(strip, framenr: int) -> bool:
"""Returns True iff the strip covers the given frame number"""
return strip.frame_final_start <= framenr <= strip.frame_final_end
@staticmethod
def set_middle_frame(context, strip):
"""Sets the current frame to the middle frame of the strip."""
middle = round((strip.frame_final_start + strip.frame_final_end) / 2)
context.scene.frame_set(middle)
@staticmethod
def by_frame(sequence_strip) -> int:
"""Returns the start frame number of the sequence strip.
This can be used for sorting strips by time.
"""
return sequence_strip.frame_final_start
async def thumbnail_strip(self, context, strip):
atc_object_id = getattr(strip, 'atc_object_id', None)
if not atc_object_id:
self.report({'ERROR'}, 'Strip %s not set up for Attract' % strip.name)
self.quit()
return
with self.thumbnail_render_settings(context):
bpy.ops.render.render()
file_id = await self.upload_via_tempdir(bpy.data.images['Render Result'],
'attract_shot_thumbnail.jpg')
if file_id is None:
self.quit()
return
# Update the shot to include this file as the picture.
node = pillarsdk.Node({'_id': atc_object_id})
await pillar.pillar_call(
node.patch,
{
'op': 'from-blender',
'$set': {
'picture': file_id,
}
})
async def upload_via_tempdir(self, datablock, filename_on_cloud) -> pillarsdk.Node:
"""Saves the datablock to file, and uploads it to the cloud.
Saving is done to a temporary directory, which is removed afterwards.
Returns the node.
"""
import tempfile
import os.path
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, filename_on_cloud)
self.log.debug('Saving %s to %s', datablock, filepath)
datablock.save_render(filepath)
return await self.upload_file(filepath)
async def upload_file(self, filename: str, fileobj=None):
"""Uploads a file to the cloud, attached to the image sharing node.
Returns the node.
"""
from .. import blender
prefs = blender.preferences()
project = self.find_project(prefs.attract_project.project)
self.log.info('Uploading file %s', filename)
resp = await pillar.pillar_call(
pillarsdk.File.upload_to_project,
project['_id'],
'image/jpeg',
filename,
fileobj=fileobj)
self.log.debug('Returned data: %s', resp)
try:
file_id = resp['file_id']
except KeyError:
self.log.error('Upload did not succeed, response: %s', resp)
self.report({'ERROR'}, 'Unable to upload thumbnail to Attract: %s' % resp)
return None
self.log.info('Created file %s', file_id)
self.report({'INFO'}, 'File succesfully uploaded to the cloud!')
return file_id
class ATTRACT_OT_copy_id_to_clipboard(AttractOperatorMixin, Operator):
bl_idname = 'attract.copy_id_to_clipboard'
bl_label = 'Copy shot ID to clipboard'
@classmethod
def poll(cls, context):
return bool(context.selected_sequences and active_strip(context))
def execute(self, context):
strip = active_strip(context)
context.window_manager.clipboard = strip.atc_object_id
self.report({'INFO'}, 'Shot ID %s copied to clipboard' % strip.atc_object_id)
return {'FINISHED'}
def draw_strip_movie_meta(self, context):
strip = active_strip(context)
if not strip:
return
meta = strip.get('metadata', None)
if not meta:
return None
box = self.layout.column(align=True)
row = box.row(align=True)
fname = meta.get('BLEND_FILE', None) or None
if fname:
row.label('Original Blendfile: %s' % fname)
row.operator(ATTRACT_OT_open_meta_blendfile.bl_idname,
text='', icon='FILE_BLEND')
sfra = meta.get('START_FRAME', '?')
efra = meta.get('END_FRAME', '?')
box.label('Original Frame Range: %s-%s' % (sfra, efra))
def register():
bpy.types.Sequence.atc_is_synced = bpy.props.BoolProperty(name="Is synced")
bpy.types.Sequence.atc_is_synced = bpy.props.BoolProperty(name="Is Synced")
bpy.types.Sequence.atc_object_id = bpy.props.StringProperty(name="Attract Object ID")
bpy.types.Sequence.atc_object_id_conflict = bpy.props.BoolProperty(
name='Object ID Conflict',
description='Attract Object ID used multiple times',
default=False)
bpy.types.Sequence.atc_name = bpy.props.StringProperty(name="Shot Name")
bpy.types.Sequence.atc_description = bpy.props.StringProperty(name="Shot description")
bpy.types.Sequence.atc_notes = bpy.props.StringProperty(name="Shot notes")
bpy.types.Sequence.atc_description = bpy.props.StringProperty(name="Shot Description")
bpy.types.Sequence.atc_notes = bpy.props.StringProperty(name="Shot Notes")
# TODO: get this from the project's node type definition.
bpy.types.Sequence.atc_status = bpy.props.EnumProperty(
items=[
('on_hold', 'On hold', 'The shot is on hold'),
('on_hold', 'On Hold', 'The shot is on hold'),
('todo', 'Todo', 'Waiting'),
('in_progress', 'In progress', 'The show has been assigned'),
('in_progress', 'In Progress', 'The show has been assigned'),
('review', 'Review', ''),
('final', 'Final', ''),
],
name="Status")
bpy.types.Sequence.atc_order = bpy.props.IntProperty(name="Order")
bpy.types.SEQUENCER_PT_edit.append(draw_strip_movie_meta)
bpy.utils.register_class(ToolsPanel)
bpy.utils.register_class(AttractShotSubmitNew)
bpy.utils.register_class(AttractShotRelink)
bpy.utils.register_class(AttractShotSubmitUpdate)
bpy.utils.register_class(AttractShotDelete)
bpy.utils.register_class(AttractStripUnlink)
bpy.utils.register_class(AttractShotFetchUpdate)
bpy.utils.register_class(AttractShotSubmitSelected)
bpy.utils.register_class(ATTRACT_OT_submit_all)
bpy.utils.register_class(ATTRACT_OT_open_meta_blendfile)
bpy.utils.register_class(ATTRACT_OT_shot_open_in_browser)
bpy.utils.register_class(ATTRACT_OT_make_shot_thumbnail)
bpy.utils.register_class(ATTRACT_OT_copy_id_to_clipboard)
bpy.app.handlers.scene_update_post.append(scene_update_post_handler)
draw.callback_enable()
def unregister():
draw.callback_disable()
bpy.app.handlers.scene_update_post.remove(scene_update_post_handler)
bpy.utils.unregister_module(__name__)
del bpy.types.Sequence.atc_is_synced
del bpy.types.Sequence.atc_object_id
del bpy.types.Sequence.atc_object_id_conflict
del bpy.types.Sequence.atc_name
del bpy.types.Sequence.atc_description
del bpy.types.Sequence.atc_notes
del bpy.types.Sequence.atc_status
del bpy.types.Sequence.atc_order
bpy.utils.unregister_module(__name__)

View File

@@ -16,7 +16,7 @@
#
# ##### END GPL LICENSE BLOCK #####
# <pep8-80 compliant>
# <pep8 compliant>
import bpy
import logging
@@ -34,19 +34,22 @@ strip_status_colour = {
'todo': (1.0, 0.5019607843137255, 0.5019607843137255)
}
CONFLICT_COLOUR = (0.576, 0.118, 0.035) # RGB tuple
def get_strip_rectf(strip, pixel_size_y):
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 - pixel_size_y
y2 = y1 + 2 * pixel_size_y
y1 = strip.channel + 0.2
y2 = strip.channel - 0.2 + 1
return (x1, y1, x2, y2)
return x1, y1, x2, y2
def draw_underline_in_strip(strip_coords, pixel_size, color):
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
@@ -56,18 +59,44 @@ def draw_underline_in_strip(strip_coords, pixel_size, color):
# 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
glRectf(s_x1, s_y1, cf_x - pixel_size, s_y2)
glRectf(cf_x + pixel_size, s_y1, s_x2, s_y2)
else:
# Normal, full rectangle draw
glRectf(s_x1, s_y1, s_x2, s_y2)
bgl.glVertex2f(cf_x - pixel_size_x, s_y1)
bgl.glVertex2f(cf_x + pixel_size_x, s_y1)
bgl.glVertex2f(s_x2, s_y1)
glDisable(GL_BLEND)
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():
@@ -76,24 +105,22 @@ def draw_callback_px():
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
pixel_size_y = one_pixel_further_y - ywin1
if context.scene.sequence_editor.meta_stack:
strips = context.scene.sequence_editor.meta_stack[-1].sequences
else:
strips = context.scene.sequence_editor.sequences
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, pixel_size_y)
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 \
@@ -109,7 +136,9 @@ def draw_callback_px():
alpha = 1.0 if strip.atc_is_synced else 0.5
draw_underline_in_strip(strip_coords, pixel_size_x, color + (alpha, ))
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():

View File

@@ -31,8 +31,9 @@ import rna_prop_ui
from . import pillar, async_loop
PILLAR_SERVER_URL = 'https://cloud.blender.org/api/'
# PILLAR_SERVER_URL = 'http://pillar:5001/api/'
PILLAR_WEB_SERVER_URL = 'https://cloud.blender.org/'
# PILLAR_WEB_SERVER_URL = 'http://pillar-web:5001/'
PILLAR_SERVER_URL = '%sapi/' % PILLAR_WEB_SERVER_URL
ADDON_NAME = 'blender_cloud'
log = logging.getLogger(__name__)
@@ -44,6 +45,36 @@ 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."""
@@ -106,6 +137,7 @@ 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."""
@@ -165,7 +197,15 @@ class BlenderCloudPreferences(AddonPreferences):
default=True
)
# TODO: store local path with the Attract project, so that people
# can switch projects and the local path switches with it.
attract_project = PointerProperty(type=BlenderCloudProjectGroup)
attract_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='//../')
def draw(self, context):
import textwrap
@@ -245,15 +285,11 @@ class BlenderCloudPreferences(AddonPreferences):
# Image Share stuff
share_box = layout.box()
share_box.label('Image Sharing on Blender Cloud', icon_value=icon('CLOUD'))
share_box.enabled = msg_icon != 'ERROR'
share_box.prop(self, 'open_browser_after_share')
# Attract stuff
attract_box = layout.box()
attract_box.enabled = msg_icon != 'ERROR'
attract_row = attract_box.row(align=True)
attract_row.label('Attract', icon_value=icon('CLOUD'))
self.draw_attract_buttons(attract_row, self.attract_project)
self.draw_attract_buttons(attract_box, self.attract_project)
def draw_subscribe_button(self, layout):
layout.operator('pillar.subscribe', icon='WORLD')
@@ -289,9 +325,12 @@ class BlenderCloudPreferences(AddonPreferences):
else:
row_pull.label('Cloud Sync is running.')
def draw_attract_buttons(self, layout, bcp: BlenderCloudProjectGroup):
layout.enabled = bcp.status in {'NONE', 'IDLE'}
row_buttons = layout.row(align=True)
def draw_attract_buttons(self, attract_box, bcp: BlenderCloudProjectGroup):
attract_row = attract_box.row(align=True)
attract_row.label('Attract', icon_value=icon('CLOUD'))
attract_row.enabled = bcp.status in {'NONE', 'IDLE'}
row_buttons = attract_row.row(align=True)
projects = bcp.available_projects
project = bcp.project
@@ -308,6 +347,8 @@ class BlenderCloudPreferences(AddonPreferences):
else:
row_buttons.label('Fetching available projects.')
attract_box.prop(self, 'attract_project_local_path')
class PillarCredentialsUpdate(pillar.PillarOperatorMixin,
Operator):
@@ -361,6 +402,7 @@ 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
@@ -374,7 +416,7 @@ class PILLAR_OT_subscribe(Operator):
class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin,
pillar.PillarOperatorMixin,
Operator):
"""Fetches the projects available to the user, and ."""
"""Fetches the projects available to the user"""
bl_idname = 'pillar.projects'
bl_label = 'Fetch available projects'

View File

@@ -218,10 +218,10 @@ async def pillar_call(pillar_func, *args, caching=True, **kwargs):
return await loop.run_in_executor(None, partial)
def sync_call(pillar_func, *args, **kwargs):
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(), **kwargs)
return pillar_func(*args, api=pillar_api(caching=caching), **kwargs)
async def check_pillar_credentials(required_roles: set):

View File

@@ -553,6 +553,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
"""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,
@@ -653,6 +654,13 @@ 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)

View File

@@ -16,6 +16,8 @@
#
# ##### END GPL LICENSE BLOCK #####
import pathlib
def sizeof_fmt(num: int, suffix='B') -> str:
"""Returns a human-readable size.
@@ -29,3 +31,34 @@ 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

8
requirements-dev.txt Normal file
View File

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

View File

@@ -1,7 +1,7 @@
# Primary requirements:
-e git+https://github.com/sybrenstuvel/cachecontrol.git@sybren-filecache-delete-crash-fix#egg=CacheControl
lockfile==0.12.2
pillarsdk==1.6.0
pillarsdk==1.6.1
wheel==0.29.0
# Secondary requirements:

View File

@@ -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,12 +221,13 @@ 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.4.99',
version='1.5.2',
author='Sybren A. Stüvel',
author_email='sybren@stuvel.eu',
packages=find_packages('.'),

25
tests/test_utils.py Normal file
View File

@@ -0,0 +1,25 @@
"""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)

15
update_version.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Usage: $0 new-version" >&2
exit 1
fi
BL_INFO_VER=$(echo "$1" | sed 's/\./, /g')
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!"