This repository has been archived on 2023-10-03. You can view files and clone it, but cannot push or open issues or pull requests.

1116 lines
36 KiB
Python
Raw Normal View History

# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
# 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
2016-09-23 17:45:06 +02:00
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
2016-04-12 16:54:05 +02:00
from pillarsdk.projects import Project
2016-09-27 16:26:46 +02:00
from pillarsdk import exceptions as sdk_exceptions
from bpy.types import Operator, Panel, AddonPreferences
import bl_ui.space_sequencer
2016-09-23 17:45:06 +02:00
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"""
2016-09-27 15:51:24 +02:00
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:
2018-10-30 11:29:32 +01:00
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")
2016-09-27 16:26:46 +02:00
2018-10-30 11:29:32 +01:00
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)
2018-10-30 11:29:32 +01:00
else:
noun = "This Shot"
2018-10-30 11:29:32 +01:00
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")
2018-10-30 11:29:32 +01:00
# 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")
2018-10-30 11:29:32 +01:00
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"
)
2018-10-30 11:29:32 +01:00
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",
)
2018-10-30 11:29:32 +01:00
# 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"
)
2018-10-30 11:29:32 +01:00
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
2016-04-12 16:54:05 +02:00
def find_node_type(self, node_type_name: str) -> dict:
from .. import pillar, blender
prefs = blender.preferences()
project = self.find_project(prefs.project.project)
2016-04-12 16:54:05 +02:00
# 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
)
2016-04-12 16:54:05 +02:00
if not node_type:
return self._project_needs_setup_error()
return node_type
2016-09-27 16:26:46 +02:00
def submit_new_strip(self, strip):
2016-04-12 16:54:05 +02:00
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)
2016-08-01 14:57:05 +02:00
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()
2016-09-27 16:26:46 +02:00
def submit_update(self, strip):
import pillarsdk
from .. import pillar
2016-09-27 16:26:46 +02:00
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,
},
2016-09-27 16:26:46 +02:00
}
node = pillarsdk.Node({"_id": strip.atc_object_id})
2016-09-27 16:26:46 +02:00
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)
2016-09-27 16:26:46 +02:00
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"]
2016-09-23 17:45:06 +02:00
# 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):
2016-09-27 16:26:46 +02:00
bl_idname = "attract.shot_fetch_update"
bl_label = "Fetch Update From Attract"
bl_description = "Update status, description & notes from Attract"
2016-09-27 16:26:46 +02:00
@classmethod
def poll(cls, context):
return AttractOperatorMixin.poll(context) and any(selected_shots(context))
2016-09-27 16:26:46 +02:00
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)
2016-09-27 16:26:46 +02:00
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")
2016-10-18 12:26:46 +02:00
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"
2016-10-18 12:26:46 +02:00
2016-11-11 09:26:52 +01:00
@classmethod
def poll(cls, context):
return AttractOperatorMixin.poll(context) and bool(
context.selected_sequences and active_strip(context)
)
2016-11-11 09:26:52 +01:00
2016-10-18 12:26:46 +02:00
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
)
2016-10-18 12:26:46 +02:00
webbrowser.open_new_tab(url)
self.report({"INFO"}, "Opened a browser at %s" % url)
2016-10-18 12:26:46 +02:00
return {"FINISHED"}
2016-10-18 12:26:46 +02:00
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")
2016-09-27 16:26:46 +02:00
2016-11-11 09:26:52 +01:00
@classmethod
def poll(cls, context):
return AttractOperatorMixin.poll(context) and bool(context.selected_sequences)
2016-11-11 09:26:52 +01:00
def execute(self, context):
from .. import pillar
2016-09-27 16:26:46 +02:00
if not self.confirm:
self.report({"WARNING"}, "Delete aborted.")
return {"CANCELLED"}
2016-09-27 16:26:46 +02:00
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"}
2016-09-27 16:26:46 +02:00
def invoke(self, context, event):
self.confirm = False
2016-09-27 16:26:46 +02:00
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
)
2016-09-27 16:26:46 +02:00
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)"
2016-11-11 09:26:52 +01:00
@classmethod
def poll(cls, context):
return AttractOperatorMixin.poll(context) and bool(context.selected_sequences)
2016-11-11 09:26:52 +01:00
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.
2016-11-04 16:42:26 +01:00
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"
2016-09-27 16:26:46 +02:00
@classmethod
def poll(cls, context):
return AttractOperatorMixin.poll(context) and bool(context.selected_sequences)
2016-09-27 16:26:46 +02:00
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
2016-09-27 16:26:46 +02:00
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"}
2016-09-27 16:26:46 +02:00
def submit(self, strip):
atc_object_id = getattr(strip, "atc_object_id", None)
2016-09-27 16:26:46 +02:00
# 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):
2016-11-03 16:33:33 +01:00
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")
]
2018-09-04 14:56:10 +02:00
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")
2016-09-23 17:45:06 +02:00
# 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", ""),
2016-09-23 17:45:06 +02:00
],
name="Status",
)
bpy.types.Sequence.atc_order = bpy.props.IntProperty(name="Order")
2018-09-04 14:56:10 +02:00
for cls in _rna_classes:
bpy.utils.register_class(cls)
def unregister():
deactivate()
2018-09-04 14:56:10 +02:00
for cls in _rna_classes:
try:
bpy.utils.unregister_class(cls)
except RuntimeError:
log.warning(
"Unable to unregister class %r, probably already unregistered", cls
)
2018-09-04 14:56:10 +02:00
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