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.

677 lines
23 KiB
Python

# ##### 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
import logging
if "bpy" in locals():
import importlib
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
from bpy.types import Operator, Panel, AddonPreferences
log = logging.getLogger(__name__)
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."""
for strip in context.selected_sequences:
atc_object_id = getattr(strip, 'atc_object_id')
if not atc_object_id:
continue
yield strip
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
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'}
selshots = list(selected_shots(context))
if strip and strip.type in strip_types and strip.atc_object_id:
if len(selshots) > 1:
noun = 'selected shots'
else:
noun = 'this shot'
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)
row.operator('attract.submit_selected', text='Submit %s' % noun)
row.operator(AttractShotFetchUpdate.bl_idname,
text='', icon='FILE_REFRESH')
row.operator(ATTRACT_OT_shot_open_in_browser.bl_idname,
text='', icon='WORLD')
sub.operator(ATTRACT_OT_make_shot_thumbnail.bl_idname)
# Group more dangerous operations.
dangerous_sub = layout.column(align=True)
dangerous_sub.operator(AttractShotDelete.bl_idname)
dangerous_sub.operator('attract.strip_unlink')
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')
class AttractOperatorMixin:
"""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.attract_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': '',
'trim_start_in_frames': strip.frame_offset_start,
'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().attract_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.duration_in_edit_in_frames': strip.frame_final_duration,
'properties.cut_in_timeline_in_frames': strip.frame_final_start,
'properties.status': strip.atc_status,
}
}
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
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 AttractShotFetchUpdate(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 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 AttractShotRelink(AttractShotFetchUpdate):
bl_idname = "attract.shot_relink"
bl_label = "Relink with Attract"
strip_atc_object_id = bpy.props.StringProperty()
@classmethod
def poll(cls, context):
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'
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 AttractShotDelete(AttractOperatorMixin, Operator):
bl_idname = 'attract.shot_delete'
bl_label = 'Delete Shot'
bl_description = 'Remove this shot from Attract'
confirm = bpy.props.BoolProperty(name='confirm')
def execute(self, context):
from .. import pillar
if not self.confirm:
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'}
remove_atc_props(strip)
draw.tag_redraw_all_sequencer_editors()
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, 'confirm', text="I hereby confirm I want to delete this shot.")
class AttractStripUnlink(AttractOperatorMixin, Operator):
bl_idname = 'attract.strip_unlink'
bl_label = 'Unlink shot from this strip'
bl_description = 'Remove Attract props from the selected strip(s)'
def execute(self, context):
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)
draw.tag_redraw_all_sequencer_editors()
return {'FINISHED'}
class AttractShotSubmitSelected(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 bool(context.selected_sequences)
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
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_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)
@contextlib.contextmanager
def thumbnail_render_settings(context, thumbnail_width=512):
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
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
async def async_execute(self, context):
# Later: for strip in context.selected_sequences:
strip = active_strip(context)
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 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,
}
})
self.report({'INFO'}, 'Thumbnail uploaded to Attract')
self.quit()
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
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_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")
# 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")
bpy.types.SEQUENCER_PT_edit.append(draw_strip_movie_meta)
bpy.utils.register_class(ToolsPanel)
bpy.utils.register_class(AttractShotRelink)
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_open_meta_blendfile)
bpy.utils.register_class(ATTRACT_OT_shot_open_in_browser)
bpy.utils.register_class(ATTRACT_OT_make_shot_thumbnail)
draw.callback_enable()
def unregister():
draw.callback_disable()
bpy.utils.unregister_module(__name__)
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_status
del bpy.types.Sequence.atc_order