# ##### 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" # } import contextlib import functools import logging if "bpy" in locals(): import importlib draw = importlib.reload(draw) pillar = importlib.reload(pillar) async_loop = importlib.reload(async_loop) blender = importlib.reload(blender) else: import bpy from . import draw from .. import pillar, async_loop, blender import bpy import pillarsdk from pillarsdk.nodes import Node from pillarsdk.projects import Project from pillarsdk import exceptions as sdk_exceptions from bpy.types import Operator, Panel, AddonPreferences import bl_ui.space_sequencer log = logging.getLogger(__name__) # Global flag used to determine whether panels etc. can be drawn. attract_is_active = False def active_strip(context): try: return context.scene.sequence_editor.active_strip except AttributeError: 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""" strip.atc_name = "" strip.atc_description = "" strip.atc_object_id = "" 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 attract_is_active: return 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 AttractPollMixin: @classmethod def poll(cls, context): return attract_is_active class ATTRACT_PT_tools(AttractPollMixin, Panel): bl_label = "Attract" bl_space_type = "SEQUENCE_EDITOR" bl_region_type = "UI" bl_category = "Strip" 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", "META", "COLOR"} if strip and strip.type in strip_types and strip.atc_object_id: self._draw_attractstrip_buttons(context, strip) elif context.selected_sequences: if len(context.selected_sequences) > 1: noun = "Selected Strips" else: noun = "This Strip" layout.operator( ATTRACT_OT_submit_selected.bl_idname, text="Submit %s as New Shot" % noun, ) layout.operator("attract.shot_relink") else: layout.operator(ATTRACT_OT_submit_all.bl_idname) layout.operator(ATTRACT_OT_project_open_in_browser.bl_idname, icon="WORLD") def _draw_attractstrip_buttons(self, context, strip): """Draw buttons when selected strips are Attract shots.""" layout = self.layout selshots = list(selected_shots(context)) 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( text="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") # Create a special sub-layout for read-only properties. ro_sub = layout.column(align=True) ro_sub.enabled = False ro_sub.prop(strip, "atc_description", text="Description") ro_sub.prop(strip, "atc_notes", text="Notes") if strip.atc_is_synced: 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( ATTRACT_OT_shot_fetch_update.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.split(factor=0.6, align=True) dangerous_sub.operator( "attract.strip_unlink", text="Unlink %s" % noun, icon="PANEL_CLOSE" ) dangerous_sub.operator( ATTRACT_OT_shot_delete.bl_idname, text="Delete %s" % noun, icon="CANCEL" ) class AttractOperatorMixin(AttractPollMixin): """Mix-in class for all Attract operators.""" def _project_needs_setup_error(self): self.report({"ERROR"}, "Your Blender Cloud project is not set up for Attract.") return {"CANCELLED"} @functools.lru_cache() def find_project(self, project_uuid: str) -> Project: """Finds a single project. Caches the result in memory to prevent more than one call to Pillar. """ from .. import pillar project = pillar.sync_call(Project.find_one, {"where": {"_id": project_uuid}}) return project def find_node_type(self, node_type_name: str) -> dict: from .. import pillar, blender prefs = blender.preferences() project = self.find_project(prefs.project.project) # FIXME: Eve doesn't seem to handle the $elemMatch projection properly, # even though it works fine in MongoDB itself. As a result, we have to # search for the node type. node_type_list = project["node_types"] node_type = next( (nt for nt in node_type_list if nt["name"] == node_type_name), None ) if not node_type: return self._project_needs_setup_error() return node_type def submit_new_strip(self, strip): from .. import pillar, blender # Define the shot properties user_uuid = pillar.pillar_user_uuid() if not user_uuid: self.report( {"ERROR"}, "Your Blender Cloud user ID is not known, " "update your credentials.", ) return {"CANCELLED"} prop = { "name": strip.name, "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, "node_type": "attract_shot", "project": blender.preferences().project.project, "user": user_uuid, } # Create a Node item with the attract API node = Node(prop) post = pillar.sync_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_description = node["description"] strip.atc_notes = node["properties"]["notes"] strip.atc_status = node["properties"]["status"] draw.tag_redraw_all_sequencer_editors() def submit_update(self, strip): import pillarsdk from .. import pillar patch = { "op": "from-blender", "$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, }, } node = pillarsdk.Node({"_id": strip.atc_object_id}) result = pillar.sync_call(node.patch, patch) log.info("PATCH result: %s", result) 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, caching=False) except (sdk_exceptions.ResourceNotFound, sdk_exceptions.MethodNotAllowed): 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 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 ATTRACT_OT_shot_fetch_update(AttractOperatorMixin, Operator): bl_idname = "attract.shot_fetch_update" bl_label = "Fetch Update From Attract" bl_description = "Update status, description & notes from Attract" @classmethod def poll(cls, context): return AttractOperatorMixin.poll(context) and any(selected_shots(context)) def execute(self, context): 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 ATTRACT_OT_shot_relink(AttractOperatorMixin, Operator): bl_idname = "attract.shot_relink" bl_label = "Relink With Attract" strip_atc_object_id: bpy.props.StringProperty() @classmethod def poll(cls, context): if not AttractOperatorMixin.poll(context): return False strip = active_strip(context) return strip is not None and not getattr(strip, "atc_object_id", None) def execute(self, context): strip = active_strip(context) status = self.relink(strip, self.strip_atc_object_id) if isinstance(status, set): return status strip.atc_object_id = self.strip_atc_object_id self.report({"INFO"}, "Shot {0} relinked".format(strip.atc_name)) 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): layout = self.layout col = layout.column() col.prop(self, "strip_atc_object_id", text="Shot ID") 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 AttractOperatorMixin.poll(context) and bool( context.selected_sequences and active_strip(context) ) def execute(self, context): 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) return {"FINISHED"} class ATTRACT_OT_shot_delete(AttractOperatorMixin, Operator): bl_idname = "attract.shot_delete" bl_label = "Delete Shot" bl_description = "Remove this shot from Attract" confirm: bpy.props.BoolProperty(name="confirm") @classmethod def poll(cls, context): return AttractOperatorMixin.poll(context) and bool(context.selected_sequences) def execute(self, context): from .. import pillar if not self.confirm: self.report({"WARNING"}, "Delete aborted.") 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) 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() 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 ATTRACT_OT_strip_unlink(AttractOperatorMixin, Operator): bl_idname = "attract.strip_unlink" bl_label = "Unlink Shot From This Strip" bl_description = "Remove Attract props from the selected strip(s)" @classmethod def poll(cls, context): return AttractOperatorMixin.poll(context) and 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: 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"} class ATTRACT_OT_submit_selected(AttractOperatorMixin, Operator): bl_idname = "attract.submit_selected" bl_label = "Submit All Selected" bl_description = "Submits all selected strips to Attract" @classmethod def poll(cls, context): return AttractOperatorMixin.poll(context) and bool(context.selected_sequences) 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 context.selected_sequences: status = self.submit(strip) if isinstance(status, set): return status self.report({"INFO"}, "All selected strips sent to Attract.") return {"FINISHED"} def submit(self, strip): atc_object_id = getattr(strip, "atc_object_id", None) # Submit as new? if not atc_object_id: return self.submit_new_strip(strip) # Or just save to Attract. 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 AttractOperatorMixin.poll(context) and 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.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 AttractOperatorMixin.poll(context) and 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"} class ATTRACT_OT_project_open_in_browser(Operator): bl_idname = "attract.project_open_in_browser" bl_label = "Open Project in Browser" bl_description = "Opens a webbrowser to show the project in Attract" project_id: bpy.props.StringProperty(name="Project ID", default="") def execute(self, context): import webbrowser import urllib.parse import pillarsdk from ..pillar import sync_call from ..blender import PILLAR_WEB_SERVER_URL, preferences if not self.project_id: self.project_id = preferences().project.project project = sync_call( pillarsdk.Project.find, self.project_id, {"projection": {"url": True}} ) if log.isEnabledFor(logging.DEBUG): import pprint log.debug("found project: %s", pprint.pformat(project.to_dict())) url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, "attract/" + project.url) webbrowser.open_new_tab(url) self.report({"INFO"}, "Opened a browser at %s" % url) return {"FINISHED"} class ATTRACT_PT_strip_metadata(bl_ui.space_sequencer.SequencerButtonsPanel, Panel): bl_label = "Metadata" bl_parent_id = "SEQUENCER_PT_source" bl_category = "Strip" bl_options = {"DEFAULT_CLOSED"} def draw(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(text="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(text="Original Frame Range: %s-%s" % (sfra, efra)) def activate(): global attract_is_active log.info("Activating Attract") attract_is_active = True # TODO: properly fix 2.8 compatibility; this is just a workaround. if hasattr(bpy.app.handlers, "scene_update_post"): bpy.app.handlers.scene_update_post.append(scene_update_post_handler) draw.callback_enable() def deactivate(): global attract_is_active log.info("Deactivating Attract") attract_is_active = False draw.callback_disable() # TODO: properly fix 2.8 compatibility; this is just a workaround. if hasattr(bpy.app.handlers, "scene_update_post"): try: bpy.app.handlers.scene_update_post.remove(scene_update_post_handler) except ValueError: # This is thrown when scene_update_post_handler does not exist in the handler list. pass _rna_classes = [ cls for cls in locals().values() if isinstance(cls, type) and cls.__name__.startswith("ATTRACT") ] 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_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") # 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"), ("todo", "Todo", "Waiting"), ("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") for cls in _rna_classes: bpy.utils.register_class(cls) def unregister(): deactivate() for cls in _rna_classes: try: bpy.utils.unregister_class(cls) except RuntimeError: log.warning( "Unable to unregister class %r, probably already unregistered", cls ) 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