# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later import webbrowser from pathlib import Path from typing import Dict, List, Set, Optional, Tuple, Any import time import bpy from bpy.app.handlers import persistent from .. import bkglobals from .. import ( cache, util, prefs, bkglobals, ) from ..logger import LoggerFactory from ..types import ( Shot, Task, TaskStatus, TaskType, ) from ..playblast.core import ( playblast_with_scene_settings, playblast_with_viewport_settings, playblast_with_viewport_preset_settings, playblast_vse, ) from ..context import core as context_core from ..playblast import opsdata, core logger = LoggerFactory.getLogger() class KITSU_OT_playblast_create(bpy.types.Operator): bl_idname = "kitsu.playblast_create" bl_label = "Create Playblast" bl_description = ( "Creates render either from viewport in which operator was triggered" "or renderes with the current scene's render settings" "Saves the set version to disk and uploads it to Kitsu with the specified " "comment and task type." "Opens web browser or VSE after playblast if set in addon preferences" ) comment: bpy.props.StringProperty( name="Comment", description="Comment that will be appended to this playblast on Kitsu", default="", ) confirm: bpy.props.BoolProperty(name="Confirm", default=False) task_status: bpy.props.EnumProperty(items=cache.get_all_task_statuses_enum) # type: ignore thumbnail_frame: bpy.props.IntProperty( name="Thumbnail Frame", description="Frame to use as the thumbnail on Kitsu", min=0, ) thumbnail_frame_final: bpy.props.IntProperty(name="Thumbnail Frame Final") _entity = None _task_status = None _task = None _task_type = None @classmethod def poll(cls, context: bpy.types.Context) -> bool: if not prefs.session_auth(context): cls.poll_message_set("Not logged into Kitsu Server, see Add-On Preferences") return False if not context.scene.kitsu.playblast_file: cls.poll_message_set("Invalid Playblast File/Directory, see Add-On Preferences") return False if context.space_data.type == "VIEW_3D": if not context.scene.camera: cls.poll_message_set("No Active Camera in active Scene") return False if context_core.is_sequence_context(): if not cache.sequence_active_get(): cls.poll_message_set("No Active Sequence set in Kitsu Context UI") return False if context_core.is_shot_context(): if not cache.shot_active_get(): cls.poll_message_set("No Active Shot set in Kitsu Context UI") return False if not cache.task_type_active_get(): cls.poll_message_set("No Active Task Type set in Kitsu Context UI") return False return True def is_vse(self, context): return bool(context.space_data.type == "SEQUENCE_EDITOR") def _get_kitsu_task(self, context: bpy.types.Context): task_type_name = cache.task_type_active_get().name # Get task status 'wip' and task type 'Animation'. self._task_status = TaskStatus.by_id(self.task_status) self._task_type = TaskType.by_name(task_type_name) if not self._task_type: raise RuntimeError("Failed to upload playblast. Task type missing on Kitsu Server") # Find / get latest task self._task = Task.by_name(self._entity, self._task_type) if not self._task: # An Entity on the server can have 0 tasks even tough task types exist. # We have to create a task first before being able to upload a thumbnail. try: self._task = Task.new_task( self._entity, self._task_type, task_status=self._task_status ) except TypeError: raise RuntimeError( f"Failed to upload playblast. Task type {self._task_type.name} not present in {self._entity.type} {self._entity.name}" ) def _get_user_name(self, context: bpy.types.Context) -> str: session = prefs.session_get(context) if len(self._task.persons) == 1: return self._task.persons[0]["full_name"] elif len(self._task.persons) >= 1: person = "" for index, user in enumerate(self._task.persons): person += user["full_name"] if index < len(self._task.persons) - 1: person += ", " return person else: return session.data.user["full_name"] def execute(self, context: bpy.types.Context) -> Set[str]: addon_prefs = prefs.addon_prefs_get(context) kitsu_scene_props = context.scene.kitsu render_mode = kitsu_scene_props.playblast_render_mode if not self.task_status: self.report({"ERROR"}, "Failed to create playblast. Missing task status") return {"CANCELLED"} # Playblast file always starts at frame 0, account for this in thumbnail frame selection self.thumbnail_frame_final = self.thumbnail_frame - context.scene.frame_start # Ensure thumbnail frame is not outside of frame range if self.thumbnail_frame_final not in range( 0, (context.scene.frame_end - context.scene.frame_start) + 1 ): self.report( {"ERROR"}, f"Thumbnail frame '{self.thumbnail_frame}' is outside of frame range ", ) return {"CANCELLED"} # entity is either a shot or a sequence self._entity = self._get_active_entity(context) # get kitsu task info self._get_kitsu_task(context) # Save playblast task status id for next time. kitsu_scene_props.playblast_task_status_id = self.task_status logger.info("-START- Creating Playblast") context.window_manager.progress_begin(0, 2) context.window_manager.progress_update(0) playblast_file = kitsu_scene_props.playblast_file username = self._get_user_name(context) # Render and save playblast if self.is_vse(context): output_path = playblast_vse(self, context, playblast_file) else: if render_mode == "VIEWPORT": output_path = playblast_with_viewport_settings( self, context, playblast_file, username ) elif render_mode == "VIEWPORT_PRESET": output_path = playblast_with_viewport_preset_settings( self, context, playblast_file, username ) else: # render_mode == "SCENE": output_path = playblast_with_scene_settings(self, context, playblast_file, username) context.window_manager.progress_update(1) # Upload playblast self._upload_playblast(context, output_path) if not addon_prefs.version_control: basename = context_core.get_versioned_file_basename(Path(bpy.data.filepath).stem) version_filename = basename + "-" + kitsu_scene_props.playblast_version + ".blend" version_filepath = Path(bpy.data.filepath).parent.joinpath(version_filename).as_posix() bpy.ops.wm.save_as_mainfile(filepath=version_filepath, copy=True) context.window_manager.progress_update(2) context.window_manager.progress_end() self.report({"INFO"}, f"Created and uploaded playblast for {self._entity.name}") logger.info("-END- Creating Playblast") # Redraw UI util.ui_redraw() # Post playblast # Open web browser if addon_prefs.pb_open_webbrowser: self._open_webbrowser() # Open playblast in second scene video sequence editor. if addon_prefs.pb_open_vse: # Create new scene. scene_orig = bpy.context.scene try: scene_pb = bpy.data.scenes[bkglobals.SCENE_NAME_PLAYBLAST] except KeyError: # Create scene. bpy.ops.scene.new(type="EMPTY") # changes active scene scene_pb = bpy.context.scene scene_pb.name = bkglobals.SCENE_NAME_PLAYBLAST logger.info( "Created new scene for playblast playback: %s", scene_pb.name ) else: logger.info( "Use existing scene for playblast playback: %s", scene_pb.name ) # Change scene. context.window.scene = scene_pb # Init video sequence editor. if not context.scene.sequence_editor: context.scene.sequence_editor_create() # what the hell # Setup video sequence editor space. if "Video Editing" not in [ws.name for ws in bpy.data.workspaces]: scripts_path = bpy.utils.script_paths(use_user=False)[0] template_path = ( "/startup/bl_app_templates_system/Video_Editing/startup.blend" ) ws_filepath = Path(scripts_path + template_path) bpy.ops.workspace.append_activate( idname="Video Editing", filepath=ws_filepath.as_posix(), ) else: context.window.workspace = bpy.data.workspaces["Video Editing"] # Add movie strip # load movie strip file in sequence editor # in this case we make use of ops.sequencer.movie_strip_add because # it provides handy auto placing,would be hard to achieve with # context.scene.sequence_editor.sequences.new_movie(). override = context.copy() for window in bpy.context.window_manager.windows: screen = window.screen for area in screen.areas: if area.type == "SEQUENCE_EDITOR": override["window"] = window override["screen"] = screen override["area"] = area bpy.ops.sequencer.movie_strip_add( override, filepath=scene_orig.kitsu.playblast_file, frame_start=context.scene.frame_start, ) # Playback. context.scene.frame_current = context.scene.frame_start bpy.ops.screen.animation_play() return {"FINISHED"} def invoke(self, context, event): # Initialize comment and playblast task status variable. self.comment = "" prev_task_status_id = context.scene.kitsu.playblast_task_status_id if context.scene.frame_current not in range( context.scene.frame_start, context.scene.frame_end ): context.scene.frame_current = context.scene.frame_start self.thumbnail_frame = context.scene.frame_current # Only use prev_task_status_id if it exists in the task statuses enum list valid_ids = [status[0] for status in cache.get_all_task_statuses_enum(self, context)] if prev_task_status_id in valid_ids: self.task_status = prev_task_status_id else: # Find todo. todo_status = TaskStatus.by_name(bkglobals.PLAYBLAST_DEFAULT_STATUS) if todo_status: self.task_status = todo_status.id return context.window_manager.invoke_props_dialog(self, width=500) def draw(self, context: bpy.types.Context) -> None: layout = self.layout layout.use_property_split = True layout.use_property_decorate = False layout.prop(self, "task_status", text="Status") layout.prop(self, "comment") layout.prop(self, "thumbnail_frame") if not self.is_vse(context): layout.prop(context.scene.kitsu, "playblast_render_mode", text="Render Mode") def _upload_playblast(self, context: bpy.types.Context, filepath: Path) -> None: # Create a comment comment_text = self._gen_comment_text(context, self._entity) comment = self._task.add_comment( self._task_status, comment=comment_text, ) # Add_preview_to_comment self._task.add_preview_to_comment( comment, filepath.as_posix(), self.thumbnail_frame_final, ) logger.info( f"Uploaded playblast for shot: {self._entity.name} under: {self._task_type.name}" ) def _gen_comment_text(self, context: bpy.types.Context, shot: Shot) -> str: header = f"Playblast {shot.name}: {context.scene.kitsu.playblast_version}" if self.comment: return header + f"\n\n{self.comment}" return header def _open_webbrowser(self) -> None: addon_prefs = prefs.addon_prefs_get(bpy.context) # https://staging.kitsu.blender.cloud/productions/7838e728-312b-499a-937b-e22273d097aa/shots?search=010_0010_A host_url = addon_prefs.host if host_url.endswith("/api"): host_url = host_url[:-4] if host_url.endswith("/"): host_url = host_url[:-1] url = f"{host_url}/productions/{cache.project_active_get().id}/shots?search={cache.shot_active_get().name}" webbrowser.open(url) def _get_active_entity(self, context): if context_core.is_sequence_context(): # Get sequence return cache.sequence_active_get() elif context_core.is_asset_context(): return cache.asset_active_get() else: # Get shot. return cache.shot_active_get() class KITSU_OT_playblast_set_version(bpy.types.Operator): bl_idname = "kitsu.anim_set_playblast_version" bl_label = "Version" bl_property = "versions" bl_description = ( "Sets version of playblast. Warning triangle in ui " "indicates that version already exists on disk" ) versions: bpy.props.EnumProperty( items=opsdata.get_playblast_versions_enum_list, name="Versions" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: addon_prefs = prefs.addon_prefs_get(context) return bool(context.scene.kitsu.playblast_dir) def execute(self, context: bpy.types.Context) -> Set[str]: kitsu_props = context.scene.kitsu version = self.versions if not version: return {"CANCELLED"} if kitsu_props.get('playblast_version') == version: return {"CANCELLED"} # Update global scene cache version prop. kitsu_props.playblast_version = version logger.info("Set playblast version to %s", version) # Redraw ui. util.ui_redraw() return {"FINISHED"} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: context.window_manager.invoke_search_popup(self) # type: ignore return {"FINISHED"} class KITSU_OT_push_frame_range(bpy.types.Operator): bl_idname = "kitsu.push_frame_range" bl_label = "Push Frame Start" bl_options = {"REGISTER", "UNDO"} bl_description = "Adjusts the start frame of animation file." frame_start = None @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context) and cache.shot_active_get()) def draw(self, context: bpy.types.Context) -> None: layout = self.layout col = layout.column(align=True) col.label(text="Set 3d_start using current scene frame start.") col.label(text=f"New Frame Start: {self.frame_start}", icon="ERROR") def execute(self, context: bpy.types.Context) -> Set[str]: core.set_frame_range_in(self.frame_start) self.report({"INFO"}, f"Updated frame range offset {self.frame_start}") return {"FINISHED"} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: self.frame_start = context.scene.frame_start frame_in, _ = core.get_frame_range() if frame_in == self.frame_start: self.report( {"INFO"}, f"Sever's 'Frame In' already matches current Scene's 'Frame Start' {self.frame_start}", ) return {"FINISHED"} return context.window_manager.invoke_props_dialog(self, width=500) class KITSU_OT_pull_frame_range(bpy.types.Operator): bl_idname = "kitsu.pull_frame_range" bl_label = "Pull Frame Range" bl_options = {"REGISTER", "UNDO"} bl_description = ( "Pulls frame range of active shot from the server " "and set the current scene's frame range to it" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context) and cache.shot_active_get()) def execute(self, context: bpy.types.Context) -> Set[str]: frame_in, frame_out = core.get_frame_range() # Check if current frame range matches the one for active shot. if core.check_frame_range(context): self.report({"INFO"}, f"Frame range already up to date") return {"FINISHED"} # Update scene frame range. context.scene.frame_start = frame_in context.scene.frame_end = frame_out if not core.check_frame_range(context): self.report( {"ERROR"}, f"Failed to update frame range to {frame_in} - {frame_out}" ) return {"CANCELLED"} # Log. self.report({"INFO"}, f"Updated frame range {frame_in} - {frame_out}") return {"FINISHED"} class KITSU_OT_check_frame_range(bpy.types.Operator): bl_idname = "kitsu.check_frame_range" bl_label = "Check Frame Range" bl_options = {"REGISTER", "UNDO"} bl_description = ( "Checks frame range of active shot from the server matches current file" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context) and cache.shot_active_get()) def execute(self, context: bpy.types.Context) -> Set[str]: if core.check_frame_range(context): self.report({"INFO"}, f"Frame Range is accurate") return {"FINISHED"} self.report({"ERROR"}, f"Failed: Frame Range Check") return {"CANCELLED"} class KITSU_OT_playblast_increment_playblast_version(bpy.types.Operator): bl_idname = "kitsu.anim_increment_playblast_version" bl_label = "Add Version Increment" bl_description = "Increment the playblast version by one" @classmethod def poll(cls, context: bpy.types.Context) -> bool: return True def execute(self, context: bpy.types.Context) -> Set[str]: # Incremenet version. version = opsdata.add_playblast_version_increment(context) # Update cache_version prop. context.scene.kitsu.playblast_version = version # Report. self.report({"INFO"}, f"Add playblast version {version}") util.ui_redraw() return {"FINISHED"} @persistent def load_post_handler_init_version_model(dummy: Any) -> None: opsdata.init_playblast_file_model(bpy.context) def draw_frame_range_warning(self, context): active_shot = cache.shot_active_get() layout = self.layout layout.alert = True layout.label( text="Frame Range on server does not match the active shot. Please 'pull' the correct frame range from the server" ) layout.label(text=f" File Frame Range: {context.scene.frame_start}-{context.scene.frame_end}") if active_shot: kitsu_3d_start = active_shot.get_3d_start() layout.label( text=f'Server Frame Range: {kitsu_3d_start}-{kitsu_3d_start + int(active_shot.nb_frames) - 1}' ) else: layout.label(text=f' Server Frame Range: not found') layout.operator("kitsu.pull_frame_range", icon='TRIA_DOWN') def draw_kitsu_context_warning(self, context): layout = self.layout layout.alert = True layout.label( text="Frame Range couldn't be found because current Shot wasn't found, please select an active Shot from Kitsu Context Menu" ) @persistent def detect_kitsu_context(dummy: Any) -> None: # TODO Move this handler should not be part of playblast # Leaving this here so it can be set in order along with frame range detection # Skip not logged into Kitsu if not prefs.session_auth(bpy.context): return # Skip project not set if not cache.project_active_get(): return # Skip if project root dir is not in file path project_root_dir = prefs.project_root_dir_get(bpy.context) if not project_root_dir in Path(bpy.data.filepath).parents: return # Skip if Unsaved File if not bpy.data.is_saved: return try: bpy.ops.kitsu.con_detect_context() except RuntimeError: bpy.context.window_manager.popup_menu( draw_kitsu_context_warning, title="Warning: Kitsu Context Auto-Detection Failed.", icon='WARNING', ) pass @persistent def load_post_handler_check_frame_range(dummy: Any) -> None: # Only show if kitsu context is detected cat = bpy.context.scene.kitsu.category project_root_dir = prefs.project_root_dir_get(bpy.context) shots_dir = project_root_dir.joinpath("pro/shots/") # Skip if Unsaved File if not bpy.data.is_saved: return # Skip if File Outside of Shots Directory if not shots_dir in Path(bpy.data.filepath).parents: return # Skip if category is not SHOT if not cat == "SHOT": return if not core.check_frame_range(bpy.context): bpy.context.window_manager.popup_menu( draw_frame_range_warning, title="Warning: Frame Range Error.", icon='ERROR', ) @persistent def save_pre_handler_clean_overrides(dummy: Any) -> None: """ Removes some Library Override properties that could be accidentally created and cause problems. """ for o in bpy.data.objects: if not o.override_library: continue if o.library: continue override = o.override_library props = override.properties for prop in props[:]: rna_path = prop.rna_path if rna_path in ["active_material_index", "active_material"]: props.remove(prop) linked_value = getattr(override.reference, rna_path) setattr(o, rna_path, linked_value) o.property_unset(rna_path) # ---------REGISTER ----------. classes = [ KITSU_OT_playblast_create, KITSU_OT_playblast_set_version, KITSU_OT_playblast_increment_playblast_version, KITSU_OT_pull_frame_range, KITSU_OT_push_frame_range, KITSU_OT_check_frame_range, ] def register(): for cls in classes: bpy.utils.register_class(cls) # init_playblast_file_model(bpy.context) not working because of restricted context. # Handlers. bpy.app.handlers.load_post.append(load_post_handler_init_version_model) bpy.app.handlers.load_post.append(detect_kitsu_context) bpy.app.handlers.load_post.append(load_post_handler_check_frame_range) bpy.app.handlers.save_pre.append(save_pre_handler_clean_overrides) def unregister(): # Clear handlers. bpy.app.handlers.load_post.remove(load_post_handler_check_frame_range) bpy.app.handlers.load_post.remove(load_post_handler_init_version_model) bpy.app.handlers.load_post.remove(detect_kitsu_context) bpy.app.handlers.save_pre.remove(save_pre_handler_clean_overrides) for cls in reversed(classes): bpy.utils.unregister_class(cls)