diff --git a/blender_cloud/__init__.py b/blender_cloud/__init__.py index a351f7d..6d6c75b 100644 --- a/blender_cloud/__init__.py +++ b/blender_cloud/__init__.py @@ -20,7 +20,7 @@ bl_info = { 'name': 'Blender Cloud', - 'author': 'Sybren A. Stüvel and Francesco Siddi', + "author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis", 'version': (1, 4, 3), 'blender': (2, 77, 0), 'location': 'Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser', @@ -42,12 +42,13 @@ if 'pillar' in locals(): pillar = importlib.reload(pillar) cache = importlib.reload(cache) + attract = importlib.reload(attract) else: from . import wheels wheels.load_wheels() - from . import pillar, cache + from . import pillar, cache, attract log = logging.getLogger(__name__) @@ -89,6 +90,7 @@ def register(): blender.register() settings_sync.register() image_sharing.register() + attract.register() def _monkey_patch_requests(): @@ -112,6 +114,7 @@ def unregister(): from . import blender, texture_browser, async_loop, settings_sync, image_sharing image_sharing.unregister() + attract.unregister() settings_sync.unregister() blender.unregister() texture_browser.unregister() diff --git a/blender_cloud/attract/__init__.py b/blender_cloud/attract/__init__.py new file mode 100644 index 0000000..241607f --- /dev/null +++ b/blender_cloud/attract/__init__.py @@ -0,0 +1,339 @@ +# ##### 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 ##### + +# + +# Old info, kept here for reference, so that we can merge wiki pages, +# descriptions, etc. +# +# bl_info = { +# "name": "Attract", +# "author": "Francesco Siddi, Inês Almeida, Antony Riakiotakis", +# "version": (0, 2, 0), +# "blender": (2, 76, 0), +# "location": "Video Sequence Editor", +# "description": +# "Blender integration with the Attract task tracking service" +# ". *requires the Blender ID add-on", +# "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/" +# "Scripts/Workflow/Attract", +# "category": "Workflow", +# "support": "TESTING" +# } + +if "bpy" in locals(): + import importlib + + importlib.reload(draw) +else: + from . import draw + +import bpy +from pillarsdk.api import Api +from pillarsdk.nodes import Node +from pillarsdk.nodes import NodeType +from pillarsdk import utils +from pillarsdk.exceptions import ResourceNotFound + +from bpy.props import StringProperty +from bpy.types import Operator, Panel, AddonPreferences + + +def active_strip(context): + try: + return context.scene.sequence_editor.active_strip + except AttributeError: + return None + + +def remove_atc_props(strip): + """Resets the attract custom properties assigned to a VSE strip""" + strip.atc_cut_in = 0 + strip.atc_cut_out = 0 + strip.atc_name = "" + strip.atc_description = "" + strip.atc_object_id = "" + strip.atc_is_synced = False + + +class ToolsPanel(Panel): + bl_label = 'Attract' + bl_space_type = 'SEQUENCE_EDITOR' + bl_region_type = 'UI' + + def draw_header(self, context): + strip = active_strip(context) + if strip and strip.atc_object_id: + self.layout.prop(strip, 'atc_is_synced', text='') + + 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: + layout.prop(strip, 'atc_name', text='Name') + layout.prop(strip, 'atc_description', text='Description') + layout.prop(strip, 'atc_notes', text='Notes') + layout.prop(strip, 'atc_status', text='Status') + layout.prop(strip, 'atc_cut_in', text='Cut in') + # layout.prop(strip, 'atc_cut_out', text='Cut out') + + if strip.atc_is_synced: + layout.operator('attract.shot_submit_update') + layout.operator('attract.shot_delete') + layout.operator('attract.strip_unlink') + + elif strip and strip.type in strip_types: + layout.operator('attract.shot_submit_new') + layout.operator('attract.shot_relink') + else: + layout.label(text='Select a Movie or Image strip') + layout.operator('attract.shots_order_update') + + +class AttractShotSubmitNew(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): + from blender_cloud import pillar + import blender_id + + strip = active_strip(context) + if strip.atc_object_id: + return + + # Filter the NodeType collection, but it's still a list + node_type_list = pillar.call(NodeType.all, {'where': "name=='shot'"}) + # Get the 'shot' node type + node_type = node_type_list['_items'][0] + # Define the shot properties + prop = {'name': strip.name, + 'description': '', + 'properties': {'status': 'on_hold', + 'notes': '', + 'cut_in': strip.frame_offset_start, + 'cut_out': strip.frame_offset_start + strip.frame_final_duration}, + 'order': 0, + 'node_type': node_type['_id'], + 'user': blender_id.get_active_user_id()} + + # Create a Node item with the attract API + node = Node(prop) + post = pillar.call(node.create) + + # Populate the strip with the freshly generated ObjectID and info + if not post: + self.report({'ERROR'}, 'Error creating node! Check the console for now.') + return {'CANCELLED'} + + strip.atc_object_id = node['_id'] + strip.atc_is_synced = True + strip.atc_name = node['name'] + strip.atc_cut_in = node['properties']['cut_in'] + strip.atc_cut_out = node['properties']['cut_out'] + + return {'FINISHED'} + + +class AttractShotRelink(Operator): + bl_idname = "attract.shot_relink" + bl_label = "Relink to Attract" + strip_atc_object_id = bpy.props.StringProperty() + + def execute(self, context): + from .. import pillar + + strip = active_strip(context) + try: + node = pillar.call(Node.find, self.strip_atc_object_id) + except ResourceNotFound: + self.report({'ERROR'}, 'No shot found on the server') + return {'CANCELLED'} + + strip.atc_object_id = self.strip_atc_object_id + strip.atc_is_synced = True + strip.atc_name = node.name + strip.atc_cut_in = node.properties.cut_in + strip.atc_cut_out = node.properties.cut_out + strip.atc_description = node.description + + self.report({'INFO'}, "Shot {0} relinked".format(node.name)) + return {'FINISHED'} + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + col = layout.column() + col.prop(self, 'strip_atc_object_id', text='Shot ID') + + +class AttractShotSubmitUpdate(Operator): + bl_idname = 'attract.shot_submit_update' + bl_label = 'Update' + bl_description = 'Syncronizes local and remote changes' + + def execute(self, context): + strip = active_strip(context) + # Update cut_in and cut_out properties on the strip + # strip.atc_cut_in = strip.frame_offset_start + # strip.atc_cut_out = strip.frame_offset_start + strip.frame_final_duration + # print("Query Attract server with {0}".format(strip.atc_object_id)) + strip.atc_cut_out = strip.atc_cut_in + strip.frame_final_duration - 1 + node = Node.find(strip.atc_object_id) + node.name = strip.atc_name + node.description = strip.atc_description + node.properties.cut_in = strip.atc_cut_in + node.properties.cut_out = strip.atc_cut_out + node.update() + return {'FINISHED'} + + +class AttractShotDelete(Operator): + bl_idname = 'attract.shot_delete' + bl_label = 'Delete' + bl_description = 'Remove from Attract' + + def execute(self, context): + strip = active_strip(context) + node = Node.find(strip.atc_object_id) + if node.delete(): + remove_atc_props(strip) + return {'FINISHED'} + + +class AttractStripUnlink(Operator): + bl_idname = 'attract.strip_unlink' + bl_label = 'Unlink' + bl_description = 'Remove Attract props from the strip' + + def execute(self, context): + strip = active_strip(context) + remove_atc_props(strip) + return {'FINISHED'} + + +class AttractShotsOrderUpdate(Operator): + bl_idname = 'attract.shots_order_update' + bl_label = 'Update shots order' + + def execute(self, context): + from .. import pillar + + # Get all shot nodes from server, build dictionary using ObjectID + # as indexes + node_type_list = pillar.call(NodeType.all, {'where': "name=='shot'"}) + node_type = node_type_list._items[0] + + shots = pillar.call(Node.all, { + 'where': {'node_type': node_type._id}, + 'max_results': 100}) + + shots = shots._items + + # TODO (fsiddi) take into account pagination. Currently we do not do it + # and it makes this dict useless. + # We should use the pagination info from the node_type_list query and + # keep querying until we have all the items. + shots_dict = {} + for shot in shots: + shots_dict[shot._id] = shot + + # Build ordered list of strips from the edit. + strips_with_atc_object_id = [strip + for strip in context.scene.sequence_editor.sequences_all + if strip.atc_object_id] + + strips_with_atc_object_id.sort( + key=lambda strip: strip.frame_start + strip.frame_offset_start) + index = 1 + for strip in strips_with_atc_object_id: + """ + # Currently we use the code below to force update all nodes. + # Check that the shot is in the list of retrieved shots + if strip.atc_order != index: #or shots_dict[strip.atc_object_id]['order'] != index: + # If there is an update in the order, retrieve and update + # the node, as well as the VSE strip + # shot_node = Node.find(strip.atc_object_id) + # shot_node.order = index + # shot_node.update() + # strip.atc_order = index + print ("{0} > {1}".format(strip.atc_order, index)) + """ + # We get all nodes one by one. This is bad and stupid. + try: + shot_node = pillar.call(Node.find, strip.atc_object_id) + # if shot_node.properties.order != index: + shot_node.order = index + shot_node.update() + print('{0} - updating {1}'.format(shot_node.order, shot_node.name)) + strip.atc_order = index + index += 1 + except ResourceNotFound: + # Reset the attract properties for any shot not found on the server + # print("Error: shot {0} not found".format(strip.atc_object_id)) + remove_atc_props(strip) + + return {'FINISHED'} + + +def register(): + 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_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_cut_in = bpy.props.IntProperty(name="Cut in") + bpy.types.Sequence.atc_cut_out = bpy.props.IntProperty(name="Cut out") + bpy.types.Sequence.atc_status = bpy.props.EnumProperty( + items=[ + ('on_hold', 'On hold', 'The shot is on hold'), + ('todo', 'Todo', 'Waiting'), + ('in_progress', 'In progress', 'The show has been assigned')], + name="Status") + bpy.types.Sequence.atc_order = bpy.props.IntProperty(name="Order") + + 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(AttractShotsOrderUpdate) + draw.callback_enable() + + +def unregister(): + draw.callback_disable() + del bpy.types.Sequence.atc_is_synced + del bpy.types.Sequence.atc_object_id + del bpy.types.Sequence.atc_name + del bpy.types.Sequence.atc_description + del bpy.types.Sequence.atc_notes + del bpy.types.Sequence.atc_cut_in + del bpy.types.Sequence.atc_cut_out + del bpy.types.Sequence.atc_status + del bpy.types.Sequence.atc_order + bpy.utils.unregister_module(__name__) diff --git a/blender_cloud/attract/draw.py b/blender_cloud/attract/draw.py new file mode 100644 index 0000000..2625c19 --- /dev/null +++ b/blender_cloud/attract/draw.py @@ -0,0 +1,142 @@ +# ##### 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 ##### + +# + +import bpy + +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 = y1 + 0.25 + + return [x1, y1, x2, y2] + + +def draw_underline_in_strip(scroller_width, strip_coords, curx, color): + from bgl import glColor4f, glRectf, glEnable, glDisable, GL_BLEND + + context = bpy.context + + # Strip coords + s_x1, s_y1, s_x2, s_y2 = strip_coords + + # Drawing coords + x = 0 + d_y1 = s_y1 + d_y2 = s_y2 + d_x1 = s_x1 + d_x2 = s_x2 + + # be careful not to override the current frame line + cf_x = context.scene.frame_current_final + y = 0 + + r, g, b, a = color + glColor4f(r, g, b, a) + glEnable(GL_BLEND) + + # // this checks if the strip range overlaps the current f. label range + # // then it would need a polygon? to draw around it + # // TODO: check also if label display is ON + # Check if the current frame label overlaps the strip + # label_height = scroller_width * 2 + # if d_y1 < label_height: + # if cf_x < d_x2 and d_x1 < cf_x + label_height: + # print("ALARM!!") + + if d_x1 < cf_x and cf_x < d_x2: + # Bad luck, the line passes our strip + glRectf(d_x1, d_y1, cf_x - curx, d_y2) + glRectf(cf_x + curx, d_y1, d_x2, d_y2) + else: + # Normal, full rectangle draw + glRectf(d_x1, d_y1, d_x2, d_y2) + + glDisable(GL_BLEND) + + +def draw_callback_px(): + context = bpy.context + + if not context.scene.sequence_editor: + return + + # Calculate scroller width, dpi and pixelsize dependent + pixel_size = context.user_preferences.system.pixel_size + dpi = context.user_preferences.system.dpi + dpi_fac = pixel_size * dpi / 72 + # A normal widget unit is 20, but the scroller is apparently 16 + scroller_width = 16 * dpi_fac + + region = context.region + xwin1, ywin1 = region.view2d.region_to_view(0, 0) + xwin2, ywin2 = region.view2d.region_to_view(region.width, region.height) + curx, cury = region.view2d.region_to_view(1, 0) + curx = curx - xwin1 + + for strip in context.scene.sequence_editor.sequences: + if strip.atc_object_id: + + # 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 + color = [1.0, 0, 1.0, 0.5] + draw_underline_in_strip(scroller_width, strip_coords, curx, color) + + +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 + + bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW') + + tag_redraw_all_sequencer_editors() diff --git a/blender_cloud/pillar.py b/blender_cloud/pillar.py index 1ee0c82..7709f7a 100644 --- a/blender_cloud/pillar.py +++ b/blender_cloud/pillar.py @@ -198,6 +198,12 @@ 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() @@ -205,6 +211,12 @@ async def pillar_call(pillar_func, *args, caching=True, **kwargs): return await loop.run_in_executor(None, partial) +def call(pillar_func, *args, **kwargs): + """Synchronous call to Pillar, ensures the correct Api object is used.""" + + return pillar_func(*args, api=pillar_api(), **kwargs) + + async def check_pillar_credentials(required_roles: set): """Tries to obtain the user at Pillar using the user's credentials.