# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later import os import re from bpy.types import Context import gazu import contextlib import colorsys import random from pathlib import Path from typing import Dict, List, Set, Optional, Tuple, Any import datetime import bpy from .. import cache, util, prefs, bkglobals from ..sqe import push, pull, checkstrip, opsdata, checksqe from ..logger import LoggerFactory from ..types import ( Cache, Sequence, Shot, TaskType, TaskStatus, Task, ) logger = LoggerFactory.getLogger() class KITSU_OT_sqe_push_shot_meta(bpy.types.Operator): bl_idname = "kitsu.sqe_push_shot_meta" bl_label = "Push Shot Metadata" bl_options = {"INTERNAL"} bl_description = ( "Pushes metadata of all selected sequences to server. " "This includes frame information, name, project, sequence and description" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context)) def execute(self, context: bpy.types.Context) -> Set[str]: succeeded = [] failed = [] logger.info("-START- Pushing Metadata") # Get strips. selected_sequences = context.selected_sequences if not selected_sequences: selected_sequences = context.scene.sequence_editor.sequences_all # Sort strips. selected_sequences = sorted(selected_sequences, key=lambda strip: strip.frame_final_start) # Begin progress update. context.window_manager.progress_begin(0, len(selected_sequences)) # Clear cache. Cache.clear_all() # Track sequence ids that were processed to later update sequence.data["color"] on kitu. sequence_ids: List[str] = [] # Shots. for idx, strip in enumerate(selected_sequences): context.window_manager.progress_update(idx) if not checkstrip.is_valid_type(strip): # Failed.append(strip). continue # Only if strip is linked to sevrer. if not checkstrip.is_linked(strip): # Failed.append(strip). continue # Check if shot is still available by id. shot = checkstrip.shot_exists_by_id(strip, clear_cache=False) if not shot: failed.append(strip) continue # Push update to shot. push.shot_meta(strip, shot) # Append sequence id. if shot.parent_id not in sequence_ids: sequence_ids.append(shot.parent_id) succeeded.append(strip) # End progress update. context.window_manager.progress_update(len(selected_sequences)) context.window_manager.progress_end() # Sequences. # Begin second progress update for sequences. context.window_manager.progress_begin(0, len(sequence_ids)) for idx, seq_id in enumerate(sequence_ids): context.window_manager.progress_update(idx) sequence = Sequence.by_id(seq_id) opsdata.push_sequence_color(context, sequence) # End second progress update. context.window_manager.progress_update(len(sequence_ids)) context.window_manager.progress_end() # Report. report_str = f"Pushed Metadata of {len(succeeded)} Shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Pushing Metadata") return {"FINISHED"} class KITSU_OT_sqe_push_new_shot(bpy.types.Operator): bl_idname = "kitsu.sqe_push_new_shot" bl_label = "Submit New Shot" bl_description = "Creates a new shot for each selected sequence strip on server. Checks if shot already exists" confirm: bpy.props.BoolProperty(name="confirm") add_tasks: bpy.props.BoolProperty(name="Add Default Tasks") @classmethod def poll(cls, context: bpy.types.Context) -> bool: # Needs to be logged in, active project. sequences = context.selected_sequences if not sequences: return False nr_of_shots = len(sequences) if nr_of_shots == 1: strip = context.scene.sequence_editor.active_strip return bool( prefs.session_auth(context) and cache.project_active_get() and strip.kitsu.sequence_name and (strip.kitsu.manual_shot_name or strip.kitsu.shot_name) ) return bool(prefs.session_auth(context) and cache.project_active_get()) def execute(self, context: bpy.types.Context) -> Set[str]: if not self.confirm: self.report({"WARNING"}, "Submit new shots aborted") return {"CANCELLED"} project_active = cache.project_active_get() succeeded = [] failed = [] no_sequence_id = [] logger.info("-START- Submitting new shots to: %s", project_active.name) # Clear cache. Cache.clear_all() # Get strips. selected_sequences = context.selected_sequences if not selected_sequences: selected_sequences = context.scene.sequence_editor.sequences_all # Sort strips. selected_sequences = sorted(selected_sequences, key=lambda strip: strip.frame_final_start) # Begin progress update. context.window_manager.progress_begin(0, len(selected_sequences)) for idx, strip in enumerate(selected_sequences): context.window_manager.progress_update(idx) if not checkstrip.is_valid_type(strip): # Failed.append(strip). continue # Check if user initialized shot. if not checkstrip.is_initialized(strip): # Failed.append(strip). continue # Check if strip is already linked to server. if checkstrip.is_linked(strip): failed.append(strip) continue # Check if user provided enough info. if not checkstrip.has_meta(strip): failed.append(strip) continue if strip.kitsu.sequence_id == "": no_sequence_id.append(strip) continue # Check if seq already to server > create it. seq = checkstrip.seq_exists_by_id(strip, project_active, clear_cache=False) if not seq: seq = push.new_sequence(strip, project_active) # Check if shot already to server > create it. shot = checkstrip.shot_exists_by_name(strip, project_active, seq, clear_cache=False) if shot: failed.append(strip) continue if strip.kitsu.manual_shot_name: strip.kitsu.shot_name = strip.kitsu.manual_shot_name # Push update to sequence. opsdata.push_sequence_color(context, seq) # Push update to shot. shot = push.new_shot(strip, seq, project_active, add_tasks=self.add_tasks) pull.shot_meta(strip, shot) succeeded.append(strip) # Rename strip. strip.name = shot.name # End progress update. context.window_manager.progress_update(len(selected_sequences)) context.window_manager.progress_end() # Clear cache. Cache.clear_all() # Report. report_str = f"Submitted {len(succeeded)} new shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" if no_sequence_id: report_state = "WARNING" report_str += f" | Sequence metadata was not set for: {len(failed)} strips" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Submitting new shots to: %s", project_active.name) util.ui_redraw() return {"FINISHED"} def invoke(self, context, event): self.confirm = False return context.window_manager.invoke_props_dialog(self, width=500) def draw(self, context): project_active = cache.project_active_get() selected_sequences = context.selected_sequences layout = self.layout if not selected_sequences: selected_sequences = context.scene.sequence_editor.sequences_all strips_to_submit = [ s for s in selected_sequences if s.kitsu.initialized and not s.kitsu.linked and (s.kitsu.manual_shot_name or s.kitsu.sequence_name) ] if len(selected_sequences) > 1: noun = "%i Shots" % len(strips_to_submit) else: noun = "this Shot" # Production. row = layout.row() row.label(text=f"Production: {project_active.name}", icon="FILEBROWSER") # Confirm dialog. col = layout.column() col.prop( self, "confirm", text="Submit %s to server. Will skip shots if they already exist" % (noun.lower()), ) col.prop(self, "add_tasks", text="Add Tasks with the default status to shot(s)") class KITSU_OT_sqe_push_new_sequence(bpy.types.Operator): bl_idname = "kitsu.sqe_push_new_sequence" bl_label = "Submit New Sequence" bl_description = "Creates new sequence on server. Will skip if sequence already exists" sequence_name: bpy.props.StringProperty( name="Name", default="", description="Name of new sequence" ) confirm: bpy.props.BoolProperty(name="confirm", default=True) @classmethod def poll(cls, context: bpy.types.Context) -> bool: # Needs to be logged in, active project. return bool(prefs.session_auth(context) and cache.project_active_get()) def execute(self, context: bpy.types.Context) -> Set[str]: if not self.confirm: self.report({"WARNING"}, "Submit new sequence aborted") return {"CANCELLED"} if not self.sequence_name: self.report({"WARNING"}, "Invalid sequence name") return {"CANCELLED"} project_active = cache.project_active_get() episode_active = cache.episode_active_get() sequence = project_active.get_sequence_by_name(self.sequence_name, episode_active) if sequence: self.report( {"WARNING"}, f"Sequence: {sequence.name} already exists on server", ) return {"CANCELLED"} # Create sequence. sequence = project_active.create_sequence( self.sequence_name, episode_active.id if episode_active else None, ) # Push sequence color. opsdata.push_sequence_color(context, sequence) # Clear cache. Cache.clear_all() cache.reset_sequences_enum_list() self.report( {"INFO"}, f"Submitted new sequence: {sequence.name}", ) # Avoids circular import from .ui import KITSU_PT_sqe_shot_tools as seq_tools # set newly created sequence as target for multi edit or active strip if seq_tools.poll_multi_edit(context): context.window_manager.selected_sequence_name = sequence.name else: context.active_sequence_strip.kitsu.sequence_name = sequence.name logger.info("Submitted new sequence: %s", sequence.name) return {"FINISHED"} def invoke(self, context, event): return context.window_manager.invoke_props_dialog(self, width=300) def draw(self, context): layout = self.layout project_active = cache.project_active_get() # Production. row = layout.row() row.label(text=f"Production: {project_active.name}", icon="FILEBROWSER") # Sequence name. row = layout.row() row.prop(self, "sequence_name") # Confirm dialog. col = layout.column() col.prop( self, "confirm", text="Submit sequence to server. Will skip if already exists", ) class KITSU_OT_sqe_init_strip(bpy.types.Operator): """ Operator that initializes a regular sequence strip to a 'kitsu' shot. Only sets strip.kitsu.initialized = True. But this is required for further operations and to differentiate between regular sequence strip and kitsu shot strip. """ bl_idname = "kitsu.sqe_init_strip" bl_label = "Initialize Shot" bl_description = "Initializes selected sequence strip as kitsu strip" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: return True def execute(self, context: bpy.types.Context) -> Set[str]: succeeded = [] failed = [] logger.info("-START- Initializing shots") # Get strips. selected_sequences = context.selected_sequences if not selected_sequences: selected_sequences = context.scene.sequence_editor.sequences_all # Sort strips. selected_sequences = sorted(selected_sequences, key=lambda strip: strip.frame_final_start) for strip in selected_sequences: if not checkstrip.is_valid_type(strip): continue if strip.kitsu.initialized: logger.info("%s already initialized", strip.name) continue strip.kitsu.initialized = True # Apply strip.kitsu.frame_start_offset. opsdata.init_start_frame_offset(strip) succeeded.append(strip) logger.info("Initiated strip: %s as shot", strip.name) # Report. report_str = f"Initiated {len(succeeded)} shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Initializing shots") util.ui_redraw() return {"FINISHED"} class KITSU_OT_sqe_link_sequence(bpy.types.Operator): """ Gets all sequences that are available in server for active production and let's user select. Invokes a search popup on click. """ bl_idname = "kitsu.sqe_link_sequence" bl_label = "Link Sequence" bl_options = {"REGISTER", "UNDO"} bl_property = "enum_prop" bl_description = "Links selected sequence strip to an existing sequence on server" enum_prop: bpy.props.EnumProperty( items=cache.get_sequences_enum_list, ) # type: ignore @classmethod def poll(cls, context: bpy.types.Context) -> bool: sqe = context.scene.sequence_editor if not sqe: return False strip = sqe.active_strip return bool( prefs.session_auth(context) and cache.project_active_get() and strip and context.selected_sequences and checkstrip.is_valid_type(strip) ) def execute(self, context: bpy.types.Context) -> Set[str]: strip = context.scene.sequence_editor.active_strip sequence_id = self.enum_prop if not sequence_id: return {"CANCELLED"} # Set sequence properties. seq = Sequence.by_id(sequence_id) strip.kitsu.sequence_name = seq.name strip.kitsu.sequence_id = seq.id # Pull sequence color. opsdata.append_sequence_color(context, seq) util.ui_redraw() return {"FINISHED"} def invoke(self, context, event): context.window_manager.invoke_search_popup(self) return {"FINISHED"} class KITSU_OT_sqe_link_shot(bpy.types.Operator): """ Operator that invokes ui which shows user all available shots on server. It is used to 'link' a sequence strip to an already existent shot on server. Pulls all metadata after selecting shot. """ bl_idname = "kitsu.sqe_link_shot" bl_label = "Link Shot" bl_description = ( "Links selected sequence strip to shot on server. Pulls all metadata of shot from server" ) bl_options = {"REGISTER", "UNDO"} use_url: bpy.props.BoolProperty( name="Use URL", description="Use URL of shot on server to initiate strip. Paste complete URL", ) url: bpy.props.StringProperty( name="URL", description="Complete URL of shot on server that will be used to initiate strip", default="", ) _strip = None @classmethod def poll(cls, context: bpy.types.Context) -> bool: sqe = context.scene.sequence_editor if not sqe: return False strip = sqe.active_strip return bool( prefs.session_auth(context) and cache.project_active_get() and strip and context.selected_sequences and checkstrip.is_valid_type(strip) ) def execute(self, context: bpy.types.Context) -> Set[str]: shot_id = self._strip.kitsu.shot_id # By url. if self.use_url: # http://192.168.178.80/productions/4dda1c36-1f49-44c7-98c9-93b40ea37dcd/shots/5e69e2e0-c3c8-4fc2-a4a3-f18151adf9dc split = self.url.split("/") shot_id = split[-1] # By shot enum. else: shot_id = self._strip.kitsu.shot_id if shot_id == "": self.report({"WARNING"}, "Invalid selection. Please choose a shot") return {"CANCELLED"} # Check if id available on server (mainly for url option). try: shot = Shot.by_id(shot_id) except (TypeError, gazu.exception.ServerErrorException): self.report({"WARNING"}, "Invalid URL: %s" % self.url) return {"CANCELLED"} except gazu.exception.RouteNotFoundException: self.report({"WARNING"}, "ID not found on server: %s" % shot_id) return {"CANCELLED"} seq = Sequence.by_id(shot.parent_id) opsdata.link_metadata_strip(context, shot, seq, self._strip) # Log. t = "Linked strip: %s to shot: %s with ID: %s" % ( self._strip.name, shot.name, shot.id, ) logger.info(t) self.report({"INFO"}, t) util.ui_redraw() return {"FINISHED"} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: if context.window_manager.clipboard: self.url = context.window_manager.clipboard self._strip = context.scene.sequence_editor.active_strip return context.window_manager.invoke_props_dialog(self, width=400) # type: ignore def draw(self, context): layout = self.layout row = layout.row() row.prop(self, "use_url") if self.use_url: row = layout.row() row.prop(self, "url", text="") else: row = layout.row() row.prop(self._strip.kitsu, "sequence_name") row = layout.row() row.prop(self._strip.kitsu, "shot_name") row = layout.row() class KITSU_OT_sqe_multi_edit_strip(bpy.types.Operator): bl_idname = "kitsu.sqe_multi_edit_strip" bl_label = "Multi Edit Strip" bl_options = {"INTERNAL"} bl_options = {"REGISTER", "UNDO"} bl_description = ( "Multi edits shot name and sequence of selected sequence strips based on " "settings in addon preferences with auto shot counter incrementation. " "Useful to create a bulk of new shots" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: # Only if all selected strips are initialized but not linked # and they all have the same sequence name. sel_shots = context.selected_sequences if not sel_shots: cls.poll_message_set("No sequences are selected") return False nr_of_shots = len(sel_shots) if nr_of_shots < 1: cls.poll_message_set("Please more than one sequence") return False seq_name = sel_shots[0].kitsu.sequence_name for s in sel_shots: if s.kitsu.linked or not s.kitsu.initialized or not checkstrip.is_valid_type(s): cls.poll_message_set( "Please select unlinked, initialized strips, of either MOVIE or COLOR type" ) return False if s.kitsu.sequence_name != seq_name: cls.poll_message_set( "Strips have conflicting sequence names. Please select strips with the same sequence name, or no sequence" ) return False return True def execute(self, context: bpy.types.Context) -> Set[str]: addon_prefs = prefs.addon_prefs_get(context) shot_counter_increment = addon_prefs.shot_counter_increment shot_counter_digits = addon_prefs.shot_counter_digits shot_counter_start = context.window_manager.shot_counter_start shot_pattern = addon_prefs.shot_pattern strip = context.scene.sequence_editor.active_strip sequence = context.window_manager.selected_sequence_name var_project = ( addon_prefs.var_project_custom if context.window_manager.var_use_custom_project else context.window_manager.var_project_active ) var_sequence = ( context.window_manager.var_sequence_custom if context.window_manager.var_use_custom_seq else sequence ) succeeded = [] failed = [] logger.info("-START- Multi Edit Shot") # Sort sequence after frame in. selected_sequences = context.selected_sequences selected_sequences = sorted(selected_sequences, key=lambda x: x.frame_final_start) episode = cache.episode_active_get() for idx, strip in enumerate(selected_sequences): # Gen data for resolver. counter_number = shot_counter_start + (shot_counter_increment * idx) counter = str(counter_number).rjust(shot_counter_digits, "0") var_lookup_table = { "Episode": episode.name, "Sequence": var_sequence, "Project": var_project, "Counter": counter, } # Run shot name resolver. shot = opsdata.resolve_pattern(shot_pattern, var_lookup_table) # Set metadata. strip.kitsu.sequence_name = sequence strip.kitsu.sequence_id = context.window_manager.selected_sequence_id strip.kitsu.shot_name = shot strip.name = shot succeeded.append(strip) logger.info( "Strip: %s Assign sequence: %s Assign shot: %s" % (strip.name, sequence, shot) ) # Report. report_str = f"Assigned {len(succeeded)} Shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Multi Edit Shot") util.ui_redraw() return {"FINISHED"} class KITSU_OT_sqe_pull_shot_meta(bpy.types.Operator): """ Operator that pulls metadata of all selected sequence strips from server after performing various checks. Metadata will be saved in strip.kitsu. """ bl_idname = "kitsu.sqe_pull_shot_meta" bl_label = "Pull Shot Metadata" bl_options = {"INTERNAL"} bl_options = {"REGISTER", "UNDO"} bl_description = ( "Pulls metadata of all selected sequences from server. " "This includes name, sequence, project and description. " "Frame range information will not be pulled" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context)) def execute(self, context: bpy.types.Context) -> Set[str]: succeeded = [] failed = [] logger.info("-START- Pulling shot metadata") # Get sequences to process. selected_sequences = context.selected_sequences if not selected_sequences: selected_sequences = context.scene.sequence_editor.sequences_all # Sort sequences. selected_sequences = sorted(selected_sequences, key=lambda strip: strip.frame_final_start) # Begin progress update. context.window_manager.progress_begin(0, len(selected_sequences)) # Clear cache once. Cache.clear_all() # Track sequence ids that were processed to later update sequence.data["color"] on kitu. sequence_ids: List[str] = [] # Shots. for idx, strip in enumerate(selected_sequences): context.window_manager.progress_update(idx) if not checkstrip.is_valid_type(strip): # Failed.append(strip). continue # Only if strip is linked to sevrer. if not checkstrip.is_linked(strip): # Failed.append(strip). continue # Check if shot is still available by id. shot = checkstrip.shot_exists_by_id(strip, clear_cache=False) if not shot: failed.append(strip) continue # Push update to shot. pull.shot_meta(strip, shot, clear_cache=False) # Append sequence id. if shot.parent_id not in sequence_ids: sequence_ids.append(shot.parent_id) succeeded.append(strip) # End progress update. context.window_manager.progress_update(len(selected_sequences)) context.window_manager.progress_end() # Sequences. # Begin second progress update for sequences. context.window_manager.progress_begin(0, len(sequence_ids)) for idx, seq_id in enumerate(sequence_ids): context.window_manager.progress_update(idx) sequence = Sequence.by_id(seq_id) opsdata.append_sequence_color(context, sequence) # End second progress update. context.window_manager.progress_update(len(sequence_ids)) context.window_manager.progress_end() # Report. report_str = f"Pulled metadata for {len(succeeded)} shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Pulling shot metadata") util.ui_redraw() return {"FINISHED"} class KITSU_OT_sqe_uninit_strip(bpy.types.Operator): bl_idname = "kitsu.sqe_uninit_strip" bl_label = "Uninitialize" bl_description = "Uninitialize selected strips. Only affects Sequence Editor. " bl_options = {"REGISTER", "UNDO"} bl_description = ( "Deletes all kitsu metadata of selected sequence strips. " "It does not delete anything on the server" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(context.selected_sequences) def execute(self, context: bpy.types.Context) -> Set[str]: failed: List[bpy.types.Strip] = [] succeeded: List[bpy.types.Strip] = [] logger.info("-START- Uninitializing strips") for strip in context.selected_sequences: if not checkstrip.is_valid_type(strip): continue if not checkstrip.is_initialized(strip): continue if checkstrip.is_linked(strip): continue # Clear kitsu properties. strip.kitsu.clear() succeeded.append(strip) logger.info("Uninitialized strip: %s", strip.name) # Report. report_str = f"Uninitialized {len(succeeded)} strips" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Uninitializing strips") util.ui_redraw() return {"FINISHED"} class KITSU_OT_sqe_unlink_shot(bpy.types.Operator): bl_idname = "kitsu.sqe_unlink_shot" bl_label = "Unlink" bl_description = ( "Deletes link to the server for each selected sequence strip. " "Keeps some metadata. " "It does not change anything on the server" ) bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(context.selected_sequences) def execute(self, context: bpy.types.Context) -> Set[str]: failed: List[bpy.types.Strip] = [] succeeded: List[bpy.types.Strip] = [] logger.info("-START- Unlinking shots") for strip in context.selected_sequences: if not checkstrip.is_valid_type(strip): continue if not checkstrip.is_initialized(strip): continue if not checkstrip.is_linked(strip): continue # Clear kitsu properties. shot_name = strip.kitsu.shot_name strip.kitsu.unlink() succeeded.append(strip) logger.info("Unlinked shot: %s", shot_name) # Report. report_str = f"Unlinked {len(succeeded)} shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Unlinking shots") util.ui_redraw() return {"FINISHED"} class KITSU_OT_sqe_push_del_shot(bpy.types.Operator): bl_idname = "kitsu.sqe_push_del_shot" bl_label = "Delete Shot" bl_description = "Deletes shot on server and clears metadata of selected sequence strips" confirm: bpy.props.BoolProperty(name="Confirm") @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context) and context.selected_sequences) def execute(self, context: bpy.types.Context) -> Set[str]: if not self.confirm: self.report({"WARNING"}, "Push delete aborted") return {"CANCELLED"} succeeded = [] failed = [] logger.info("-START- Deleting shots") # Clear cache. Cache.clear_all() # Begin progress update. selected_sequences = context.selected_sequences context.window_manager.progress_begin(0, len(selected_sequences)) for idx, strip in enumerate(selected_sequences): context.window_manager.progress_update(idx) if not checkstrip.is_valid_type(strip): continue # Check if strip is already linked to sevrer. if not checkstrip.is_linked(strip): continue # Check if shot still exists to sevrer. shot = checkstrip.shot_exists_by_id(strip, clear_cache=False) if not shot: failed.append(strip) continue # Delete shot. push.delete_shot(strip, shot) # This clears all kitsu properties. succeeded.append(strip) # End progress update. context.window_manager.progress_update(len(selected_sequences)) context.window_manager.progress_end() # Report. report_str = f"Deleted {len(succeeded)} shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Deleting shots") util.ui_redraw() return {"FINISHED"} def invoke(self, context, event): self.confirm = False return context.window_manager.invoke_props_dialog(self, width=500) def draw(self, context): layout = self.layout col = layout.column() selshots = context.selected_sequences strips_to_delete = [s for s in selshots if s.kitsu.linked] if len(selshots) > 1: noun = "%i shots" % len(strips_to_delete) else: noun = "this shot" col.prop( self, "confirm", text="Delete %s on server" % noun, ) class KITSU_OT_sqe_set_thumbnail_task_type(bpy.types.Operator): bl_idname = "kitsu.set_thumbnail_task_type" bl_label = "Set Thumbnail Task Type" bl_options = {"INTERNAL"} bl_property = "enum_prop" bl_description = "Sets kitsu task type that will be used when uploading thumbnails" enum_prop: bpy.props.EnumProperty(items=cache.get_shot_task_types_enum) # type: ignore @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context) and cache.project_active_get()) def execute(self, context: bpy.types.Context) -> Set[str]: # Task type selected by user. task_type_id = self.enum_prop if not task_type_id: return {"CANCELLED"} task_type = TaskType.by_id(task_type_id) # Update scene properties. context.scene.kitsu.task_type_thumbnail_name = task_type.name context.scene.kitsu.task_type_thumbnail_id = task_type_id util.ui_redraw() return {"FINISHED"} def invoke(self, context, event): context.window_manager.invoke_search_popup(self) return {"FINISHED"} class KITSU_OT_sqe_set_sqe_render_task_type(bpy.types.Operator): bl_idname = "kitsu.set_sqe_render_task_type" bl_label = "Set Sqe Render Task Type" bl_options = {"INTERNAL"} bl_property = "enum_prop" bl_description = "Sets kitsu task type that will be used when uploading sequence editor renders" enum_prop: bpy.props.EnumProperty(items=cache.get_shot_task_types_enum) # type: ignore @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context) and cache.project_active_get()) def execute(self, context: bpy.types.Context) -> Set[str]: # Task type selected by user. task_type_id = self.enum_prop if not task_type_id: return {"CANCELLED"} task_type = TaskType.by_id(task_type_id) # Update scene properties. context.scene.kitsu.task_type_sqe_render_name = task_type.name context.scene.kitsu.task_type_sqe_render_id = task_type_id util.ui_redraw() return {"FINISHED"} def invoke(self, context, event): context.window_manager.invoke_search_popup(self) return {"FINISHED"} class KITSU_OT_sqe_push_render_still(bpy.types.Operator): bl_idname = "kitsu.seq_render_still" bl_label = "Push Still" bl_options = {"INTERNAL"} bl_description = ( "Makes and saves one still for each shot. " "Uploads each still to server as a preview image for the selected task type" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context) and context.scene.kitsu.task_type_thumbnail_id) def execute(self, context: bpy.types.Context) -> Set[str]: nr_of_strips: int = len(context.selected_sequences) do_multishot: bool = nr_of_strips > 1 failed = [] upload_queue: List[Path] = [] # Will be used as succeeded list # Get task type by id from user selection enum property. task_type = TaskType.by_id(context.scene.kitsu.task_type_thumbnail_id) logger.info("-START- Pushing shot thumbnails") # Clear cache. Cache.clear_all() with self.override_render_settings(context): with self.temporary_current_frame(context) as original_curframe: # ----RENDER AND SAVE THUMBNAILS ------. # Begin first progress update. selected_sequences = context.selected_sequences if not selected_sequences: selected_sequences = context.scene.sequence_editor.sequences_all # Sort sequences. selected_sequences = sorted( selected_sequences, key=lambda strip: strip.frame_final_start ) context.window_manager.progress_begin(0, len(selected_sequences)) for idx, strip in enumerate(selected_sequences): context.window_manager.progress_update(idx) if not checkstrip.is_valid_type(strip): # Failed.append(strip). continue # Only if strip is linked to sevrer. if not checkstrip.is_linked(strip): # Failed.append(strip). continue # Check if shot is still available by id. shot = checkstrip.shot_exists_by_id(strip, clear_cache=False) if not shot: failed.append(strip) continue # If only one strip is selected,. if not do_multishot: # If active strip is not contained in the current frame, use middle frame of active strip # otherwise don't change frame and use current one. if not checkstrip.contains(strip, original_curframe): self.set_middle_frame(context, strip) else: self.set_middle_frame(context, strip) path = self.make_thumbnail(context, strip) upload_queue.append(path) # End first progress update. context.window_manager.progress_update(len(upload_queue)) context.window_manager.progress_end() # ----ULPOAD THUMBNAILS ------. # Begin second progress update. context.window_manager.progress_begin(0, len(upload_queue)) # Process thumbnail queue. for idx, filepath in enumerate(upload_queue): context.window_manager.progress_update(idx) opsdata.upload_preview(context, filepath, task_type, comment="Update thumbnail") # End second progress update. context.window_manager.progress_update(len(upload_queue)) context.window_manager.progress_end() # Report. report_str = f"Created thumbnails for {len(upload_queue)} shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Pushing shot thumbnails") return {"FINISHED"} def make_thumbnail(self, context: bpy.types.Context, strip: bpy.types.Strip) -> Path: bpy.ops.render.render() file_name = f"{strip.kitsu.shot_id}_{str(context.scene.frame_current)}.jpg" path = self._save_render(bpy.data.images["Render Result"], file_name) logger.info(f"Saved thumbnail of shot {strip.kitsu.shot_name} to {path.as_posix()}") return path def _save_render(self, datablock: bpy.types.Image, file_name: str) -> Path: """Save the current render image to disk""" addon_prefs = prefs.addon_prefs_get(bpy.context) folder_name = addon_prefs.thumbnail_dir # Ensure folder exists. folder_path = Path(folder_name).absolute() folder_path.mkdir(parents=True, exist_ok=True) path = folder_path.joinpath(file_name) datablock.save_render(str(path)) return path.absolute() @contextlib.contextmanager def override_render_settings(self, context, thumbnail_width=512): """Overrides the render settings for thumbnail size in a 'with' block scope""" rd = context.scene.render # Remember current render settings in order to restore them later. percentage = rd.resolution_percentage file_format = rd.image_settings.file_format quality = rd.image_settings.quality use_stamp_frame = rd.use_stamp_frame try: # Set the render settings to thumbnail size. # Update resolution % instead of the actual resolution to scale text strips properly. rd.resolution_percentage = round(thumbnail_width * 100 / rd.resolution_x) rd.image_settings.file_format = "JPEG" rd.image_settings.quality = 80 rd.use_stamp_frame = False yield finally: # Return the render settings to normal. rd.resolution_percentage = percentage rd.image_settings.file_format = file_format rd.image_settings.quality = quality rd.use_stamp_frame = use_stamp_frame @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 @staticmethod def set_middle_frame( context: bpy.types.Context, strip: bpy.types.Strip, ) -> int: """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) return middle class KITSU_OT_sqe_push_render(bpy.types.Operator): bl_idname = "kitsu.sqe_push_render" bl_label = "Push Render" bl_options = {"INTERNAL"} bl_description = ( "Makes and saves a .mp4 for each shot. " "Uploads each render on server as a preview image for the selected task type" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(prefs.session_auth(context) and context.scene.kitsu.task_type_sqe_render_id) def execute(self, context: bpy.types.Context) -> Set[str]: failed = [] upload_queue: List[Path] = [] # will be used as successed list # Get task stype by id from user selection enum property. task_type = TaskType.by_id(context.scene.kitsu.task_type_sqe_render_id) logger.info("-START- Pushing Sequence Render") # Clear cache. Cache.clear_all() with self.override_render_settings(context): # ----RENDER AND SAVE SQE ------. # Get strips. selected_sequences = context.selected_sequences if not selected_sequences: selected_sequences = context.scene.sequence_editor.sequences_all # Sort strips. selected_sequences = sorted( selected_sequences, key=lambda strip: strip.frame_final_start ) # Begin first progress update. context.window_manager.progress_begin(0, len(selected_sequences)) for idx, strip in enumerate(selected_sequences): context.window_manager.progress_update(idx) if not checkstrip.is_valid_type(strip): continue # Only if strip is linked to sevrer. if not checkstrip.is_linked(strip): continue # Check if shot is still available by id. shot = checkstrip.shot_exists_by_id(strip, clear_cache=False) if not shot: failed.append(strip) continue # Output path. output_path = self._gen_output_path(strip, task_type) context.scene.render.filepath = output_path.as_posix() # Frame range. context.scene.frame_start = strip.frame_final_start context.scene.frame_end = strip.frame_final_end - 1 # Ensure folder exists. output_path.parent.mkdir(parents=True, exist_ok=True) # Make opengl render. bpy.ops.render.opengl(animation=True, sequencer=True) # Append path to upload queue. upload_queue.append(output_path) # End first progress update. context.window_manager.progress_update(len(upload_queue)) context.window_manager.progress_end() # ----UPLOAD SQE RENDER ------. # Begin second progress update. context.window_manager.progress_begin(0, len(upload_queue)) # Process thumbnail queue. for idx, filepath in enumerate(upload_queue): context.window_manager.progress_update(idx) opsdata.upload_preview(context, filepath, task_type, comment="Sequence Editor Render") # End second progress update. context.window_manager.progress_update(len(upload_queue)) context.window_manager.progress_end() # Report. report_str = f"Uploaded sequence editor render for {len(upload_queue)} shots" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Pushing Sequence Editor Render") return {"FINISHED"} def _gen_output_path(self, strip: bpy.types.Strip, task_type: TaskType) -> Path: addon_prefs = prefs.addon_prefs_get(bpy.context) folder_name = addon_prefs.sqe_render_dir file_name = f"{strip.kitsu.shot_id}_{strip.kitsu.shot_name}.{(task_type.name).lower()}.mp4" return Path(folder_name).absolute().joinpath(file_name) @contextlib.contextmanager def override_render_settings(self, context, thumbnail_width=256): """Overrides the render settings for thumbnail size in a 'with' block scope""" rd = context.scene.render # Remember current render settings in order to restore them later. # Filepath. filepath = rd.filepath # Format render settings. percentage = rd.resolution_percentage file_format = rd.image_settings.file_format ffmpeg_constant_rate = rd.ffmpeg.constant_rate_factor ffmpeg_codec = rd.ffmpeg.codec ffmpeg_format = rd.ffmpeg.format ffmpeg_audio_codec = rd.ffmpeg.audio_codec # Scene settings. use_preview_range = context.scene.use_preview_range frame_start = context.scene.frame_start frame_end = context.scene.frame_end current_frame = context.scene.frame_current try: # Format render settings. rd.resolution_percentage = 100 rd.image_settings.file_format = "FFMPEG" rd.ffmpeg.constant_rate_factor = "MEDIUM" rd.ffmpeg.codec = "H264" rd.ffmpeg.format = "MPEG4" rd.ffmpeg.audio_codec = "AAC" # Scene settings. context.scene.use_preview_range = False yield finally: # Filepath. rd.filepath = filepath # Return the render settings to normal. rd.resolution_percentage = percentage rd.image_settings.file_format = file_format rd.ffmpeg.codec = ffmpeg_codec rd.ffmpeg.constant_rate_factor = ffmpeg_constant_rate rd.ffmpeg.format = ffmpeg_format rd.ffmpeg.audio_codec = ffmpeg_audio_codec # Scene settings. context.scene.frame_start = frame_start context.scene.frame_end = frame_end context.scene.frame_current = current_frame context.scene.use_preview_range = use_preview_range class KITSU_OT_sqe_push_shot(bpy.types.Operator): bl_idname = "kitsu.sqe_push_shot" bl_label = "Push Shot to Kitsu" bl_description = "Pushes the active strip to Kitsu" comment: bpy.props.StringProperty( name="Comment", description="Comment that will be appended to this video on Kitsu", default="", ) task_type: bpy.props.EnumProperty( name="Task Type", description="Which task this video should be added to", items=cache.get_task_types_enum_for_current_context, ) task_status: bpy.props.EnumProperty( name="Task Status", description="What to set the task's status to", items=cache.get_all_task_statuses_enum, ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: active_strip = context.scene.sequence_editor.active_strip if not hasattr(active_strip, 'filepath'): cls.poll_message_set("Selected Strip is not a Video") return False return bool(prefs.session_auth(context)) def invoke(self, context, _event): return context.window_manager.invoke_props_dialog(self) def draw(self, context): layout = self.layout layout.use_property_split = True layout.use_property_decorate = False layout.prop(self, 'task_type') layout.prop(self, 'task_status') layout.prop(self, 'comment') def execute(self, context: bpy.types.Context) -> Set[str]: active_strip = context.scene.sequence_editor.active_strip # Find the metadata strip of this strip that contains Kitsu information # about what sequence and shot this strip belongs to. shot_name = active_strip.name.split(bkglobals.DELIMITER)[0] metadata_strip = context.scene.sequence_editor.sequences.get(shot_name) if not metadata_strip: # The metadata strip should've been created by sqe_create_review_session, # if the Kitsu integration is enabled in the add-on preferences, # the Kitsu add-on is enabled, and valid Kitsu credentials were entered. self.report({"ERROR"}, f"Could not find Kitsu metadata strip: {shot_name}.") return {"CANCELLED"} if not self.task_status: self.report({"ERROR"}, "Failed to create playblast. Missing task status") return {"CANCELLED"} # Set the Kitsu sequence and shot information in the context cache.sequence_active_set_by_id(context, metadata_strip.kitsu.sequence_id) cache.shot_active_set_by_id(context, metadata_strip.kitsu.shot_id) # Upload render self.report({"INFO"}, f"Trying to upload render for {shot_name}") self._upload_render(context, Path(bpy.path.abspath(active_strip.filepath))) self.report({"INFO"}, f"Uploaded render for {shot_name}") return {'FINISHED'} def _upload_render(self, context: bpy.types.Context, filepath: Path) -> None: # Get shot. shot = cache.shot_active_get() # Get task status and type. task_status = TaskStatus.by_id(self.task_status) task_type = TaskType.by_id(self.task_type) if not task_type: raise RuntimeError( f"Failed to upload playblast. Task type: {self.task_type} was not found" ) # Find / get latest task task = Task.by_name(shot, task_type) if not 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. task = Task.new_task(shot, task_type, task_status=task_status) # Create a comment comment = task.add_comment( task_status, comment=self.comment, ) # Add_preview_to_comment task.add_preview_to_comment(comment, filepath.as_posix()) logger.info(f"Uploaded render for shot: {shot.name} under: {task_type.name}") class KITSU_OT_sqe_debug_duplicates(bpy.types.Operator): bl_idname = "kitsu.sqe_debug_duplicates" bl_label = "Debug Duplicates" bl_property = "duplicates" bl_options = {"REGISTER", "UNDO"} bl_description = ( "Searches for sequence strips that are linked to the same " "shot id. Shows them in a drop down menu which triggers a selection" ) duplicates: bpy.props.EnumProperty(items=opsdata.sqe_get_duplicates, name="Duplicates") @classmethod def poll(cls, context: bpy.types.Context) -> bool: return True def execute(self, context: bpy.types.Context) -> Set[str]: strip_name = self.duplicates if not strip_name: return {"CANCELLED"} # Deselect all if something is selected. if context.selected_sequences: bpy.ops.sequencer.select_all() strip = context.scene.sequence_editor.sequences_all[strip_name] strip.select = True bpy.ops.sequencer.select() return {"FINISHED"} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: opsdata._sqe_duplicates[:] = opsdata.sqe_update_duplicates(context) return context.window_manager.invoke_props_popup(self, event) # type: ignore class KITSU_OT_sqe_debug_not_linked(bpy.types.Operator): bl_idname = "kitsu.sqe_debug_not_linked" bl_label = "Debug Not Linked" bl_property = "not_linked" bl_options = {"REGISTER", "UNDO"} bl_description = ( "Searches for sequence strips that are initialized but not linked yet. " "Shows them in a drop down menu which triggers a selection" ) not_linked: bpy.props.EnumProperty(items=opsdata.sqe_get_not_linked, name="Not Linked") @classmethod def poll(cls, context: bpy.types.Context) -> bool: return True def execute(self, context: bpy.types.Context) -> Set[str]: strip_name = self.not_linked if not strip_name: return {"CANCELLED"} # Deselect all if something is selected. if context.selected_sequences: bpy.ops.sequencer.select_all() strip = context.scene.sequence_editor.sequences_all[strip_name] strip.select = True bpy.ops.sequencer.select() return {"FINISHED"} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: opsdata._sqe_not_linked[:] = opsdata.sqe_update_not_linked(context) return context.window_manager.invoke_props_popup(self, event) # type: ignore class KITSU_OT_sqe_debug_multi_project(bpy.types.Operator): bl_idname = "kitsu.sqe_debug_multi_project" bl_label = "Debug Multi Projects" bl_property = "multi_project" bl_options = {"REGISTER", "UNDO"} bl_description = ( "Searches for sequence strips that come from different projects. " "Shows them in a drop down menu which triggers a selection" ) multi_project: bpy.props.EnumProperty(items=opsdata.sqe_get_multi_project, name="Multi Project") @classmethod def poll(cls, context: bpy.types.Context) -> bool: return True def execute(self, context: bpy.types.Context) -> Set[str]: strip_name = self.multi_project if not strip_name: return {"CANCELLED"} # Deselect all if something is selected. if context.selected_sequences: bpy.ops.sequencer.select_all() strip = context.scene.sequence_editor.sequences_all[strip_name] strip.select = True bpy.ops.sequencer.select() return {"FINISHED"} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: opsdata._sqe_multi_project[:] = opsdata.sqe_update_multi_project(context) return context.window_manager.invoke_props_popup(self, event) # type: ignore class KITSU_OT_sqe_pull_edit(bpy.types.Operator): bl_idname = "kitsu.sqe_pull_edit" bl_label = "Pull Edit" bl_description = ( "Pulls the entire edit from kitsu and creates a metadata strip for each shot. " "Does not change existing strips. Only places new strips if there is space" ) bl_options = {"REGISTER", "UNDO", "UNDO_GROUPED"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: addon_prefs = prefs.addon_prefs_get(context) return bool(prefs.session_auth(context) and cache.project_active_get()) def execute(self, context: bpy.types.Context) -> Set[str]: addon_prefs = prefs.addon_prefs_get(context) failed = [] created = [] succeeded = [] existing = [] channel = context.scene.kitsu.pull_edit_channel active_project = cache.project_active_get() active_episode = cache.episode_active_get() sequences = ( active_episode.get_sequences_all() if active_episode else active_project.get_sequences_all() ) shot_strips = checksqe.get_shot_strips(context) occupied_ranges = checksqe.get_occupied_ranges(context) all_shots = active_project.get_shots_all() selection = context.selected_sequences logger.info("-START- Pulling Edit") # Begin progress update. context.window_manager.progress_begin(0, len(all_shots)) progress_idx = 0 # Process sequence after sequence. for seq in sequences: print("\n" * 2) logger.info("Processing Sequence %s", seq.name) shots = seq.get_all_shots() # Extend context.scene.kitsu.sequence_colors property. opsdata.append_sequence_color(context, seq) # Process all shots for sequence. for shot in shots: context.window_manager.progress_update(progress_idx) progress_idx += 1 # Can happen, propably when shot is missing frame information on # kitsu. if not shot.data: logger.warning( "Shot %s, is missing 'data' dictionary. Can't determine frame_in and frame_out. Skip.", shot.name, ) continue # Get frame range information. frame_start = shot.data.get("frame_in", None) frame_end = shot.data.get("frame_out", None) # Continue if frame range information is missing. if frame_start is None or frame_end is None: failed.append(shot) logger.error( "Failed to create shot %s. Missing frame range information", shot.name, ) continue # Frame info comes in str format from kitsu. frame_start = int(frame_start) frame_end = int(frame_end) shot_range = range(frame_start, frame_end + 1) # Try to find existing strip that is already linked to that shot. strip = self._find_shot_strip(shot_strips, shot.id) # Check if on the specified channel there is space to put the strip. if str(channel) in occupied_ranges: if checksqe.is_range_occupied(shot_range, occupied_ranges[str(channel)]): failed.append(shot) logger.error( "Failed to create shot %s. Channel: %i Range: %i - %i is occupied", shot.name, channel, frame_start, frame_end, ) continue # TODO Refactor as this reuses code from KITSU_OT_sqe_create_metadata_strip if not strip: # Create new strip. strip = opsdata.create_metadata_strip( context.scene, shot.name, channel, frame_start, frame_end ) # Apply slip to match offset. self._apply_strip_slip_from_shot(context, strip, shot) created.append(shot) logger.info("Shot %s created new strip", shot.name) else: # Update properties of existing strip. strip.channel = channel logger.info("Shot %s use existing strip: %s", shot.name, strip.name) existing.append(strip) # Set blend alpha. strip.blend_alpha = 0 # Pull shot meta and link shot. pull.shot_meta(strip, shot, clear_cache=False) succeeded.append(shot) # End progress update. context.window_manager.progress_update(len(all_shots)) context.window_manager.progress_end() # Restore selection. if context.selected_sequences: bpy.ops.sequencer.select_all() for s in selection: s.select = True # Report. report_str = f"Shots: Succeded:{len(succeeded)} | Created {len(created)} | Existing: {len(existing)}" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Pulling Edit") return {"FINISHED"} def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]: return context.window_manager.invoke_props_dialog(self, width=300) # type: ignore def draw(self, context): layout = self.layout row = layout.row() row.label(text="Set channel in which the entire edit should be created") row = layout.row() row.prop(context.scene.kitsu, "pull_edit_channel") def _find_shot_strip( self, shot_strips: List[bpy.types.Strip], shot_id: str ) -> Optional[bpy.types.Strip]: for strip in shot_strips: if strip.kitsu.shot_id == shot_id: return strip return None def _get_random_pastel_color_rgb(self) -> Tuple[float, float, float]: """Returns a randomly generated color with high brightness and low saturation""" hue = random.random() saturation = random.uniform(0.25, 0.33) brightness = random.uniform(0.75, 0.83) color = colorsys.hsv_to_rgb(hue, saturation, brightness) return (color[0], color[1], color[2]) def _apply_strip_slip_from_shot( self, context: bpy.types.Context, strip: bpy.types.Strip, shot: Shot ) -> None: # get offset offset = strip.kitsu_3d_start - int(shot.get_3d_start()) # Deselect everything. if context.selected_sequences: bpy.ops.sequencer.select_all() # Select strip and run slip op. strip.select = True bpy.ops.sequencer.slip(offset=offset) class KITSU_OT_sqe_init_strip_start_frame(bpy.types.Operator): bl_idname = "kitsu.sqe_init_strip_start_frame" bl_label = "Initialize Shot Start Frame" bl_description = "Calculates offset so the current shot starts at 101" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: return True def execute(self, context: bpy.types.Context) -> Set[str]: succeeded = [] failed = [] logger.info("-START- Initializing strip start frame") selected_sequences = context.selected_sequences if not selected_sequences: selected_sequences = context.scene.sequence_editor.sequences_all for strip in selected_sequences: if not checkstrip.is_valid_type(strip): continue if not strip.kitsu.initialized: logger.info("%s not initialized", strip.name) continue # Apply strip.kitsu.frame_start_offset. opsdata.init_start_frame_offset(strip) succeeded.append(strip) logger.info( "%s initiated start frame to 101 by applying offset: %i ", strip.name, strip.kitsu.frame_start_offset, ) # Report. report_str = f"Initiated start frame of {len(succeeded)} strips" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Log. logger.info("-END- Initializing strip start frame") util.ui_redraw() return {"FINISHED"} class KITSU_OT_sqe_create_metadata_strip(bpy.types.Operator): bl_idname = "kitsu.sqe_create_metadata_strip" bl_label = "Create Metadata Strip" bl_description = ( "Adds metadata strip for each selected strip. " "Tries to place metadata strip one channel above selected " ) bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: addon_prefs = prefs.addon_prefs_get(context) return bool(context.selected_sequences) def execute(self, context: bpy.types.Context) -> Set[str]: addon_prefs = prefs.addon_prefs_get(context) failed = [] created = [] occupied_ranges = checksqe.get_occupied_ranges(context) logger.info("-START- Creating Metadata Strips") selected_sequences = context.selected_sequences # Check if metadata strip file actually exists. for strip in selected_sequences: # Get frame range information from current strip. strip_range = range(strip.frame_final_start, strip.frame_final_end) channel = strip.channel + 1 # Check if one channel above strip there is space to put the metadata strip. if str(channel) in occupied_ranges: if checksqe.is_range_occupied(strip_range, occupied_ranges[str(channel)]): failed.append(strip) logger.error( "Failed to create metadata strip for %s. Channel: %i Range: %i - %i is occupied", strip.name, channel, strip.frame_final_start, strip.frame_final_end, ) continue # Create new metadata strip. # TODO: frame range of metadata strip is 1000 which is problematic because it needs to fit # on the first try, EDIT: seems to work maybe per python overlaps of sequences possible? metadata_strip = opsdata.create_metadata_strip( context.scene, f"{strip.name}{bkglobals.DELIMITER}metadata{bkglobals.SPACE_REPLACER}strip", strip.channel + 1, strip.frame_final_start, strip.frame_final_end, ) created.append(metadata_strip) logger.info( "%s created metadata strip: %s", strip.name, metadata_strip.name, ) # Report. report_str = f"Created {len(created)} metadata strips" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) # Deselect source strips after creating Metadata strips for strip in selected_sequences: if strip not in created: strip.select = False # Log. logger.info("-END- Creating Metadata Strips") util.ui_redraw() return {"FINISHED"} class KITSU_OT_sqe_fix_metadata_strips(bpy.types.Operator): bl_idname = "kitsu.sqe_fix_metadata_strip" bl_label = "Fix Metadata Strip Missing Media" bl_description = "Fixes missing media error in metadata strips. " bl_options = {"REGISTER", "UNDO"} def execute(self, context: Context): addon_prefs = prefs.addon_prefs_get(context) if len(context.selected_sequences) == 0: all_strips = context.scene.sequence_editor.sequences else: all_strips = context.selected_sequences offline_metadata_strips = [ strip for strip in all_strips if strip.kitsu.shot_id != '' and not Path(strip.filepath).is_file() ] for strip in offline_metadata_strips: strip.filepath = addon_prefs.metadatastrip_file return {"FINISHED"} class KITSU_OT_sqe_add_sequence_color(bpy.types.Operator): """ Adds sequence of active strip to scene.kitsu.sequence_colors collection property """ bl_idname = "kitsu.add_sequence_color" bl_label = "Add Sequence Color" bl_description = "Registers a sequence color for active sequence strip" @classmethod def poll(cls, context: bpy.types.Context) -> bool: sqe = context.scene.sequence_editor if not sqe: return False active_strip = sqe.active_strip return bool(active_strip and active_strip.kitsu.sequence_id) def execute(self, context: bpy.types.Context) -> Set[str]: sequence_colors = context.scene.kitsu.sequence_colors active_strip = context.scene.sequence_editor.active_strip sequence_id = active_strip.kitsu.sequence_id # Check if sequence_id is already in sequence color collection property if sequence_id in sequence_colors.keys(): self.report( {"WARNING"}, f"Sequence {sequence_id} (ID: {active_strip.kitsu.sequence_name}) already in scene.kitsu.sequence_colors", ) return {"CANCELLED"} # Add new item to sequence color collection property item = context.scene.kitsu.sequence_colors.add() item.name = active_strip.kitsu.sequence_id self.report( {"INFO"}, f"Added {sequence_id} (ID: {active_strip.kitsu.sequence_name}) to scene.kitsu.seqeuence_colors", ) return {"FINISHED"} class KITSU_OT_sqe_scan_for_media_updates(bpy.types.Operator): bl_idname = "kitsu.sqe_scan_for_media_updates" bl_label = "Scan for media updates" bl_description = ( "Scans sequence editor for movie strips and highlights them if there is a more recent version of their source media. " "Source Media is located either in the Playblast Directory or the 'Media Update Search Paths' directories" ) bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: sqe = context.scene.sequence_editor if not sqe: return False return bool(sqe.sequences_all) def execute(self, context: bpy.types.Context) -> Set[str]: addon_prefs = prefs.addon_prefs_get(context) outdated: List[bpy.types.Strip] = [] invalid: List[bpy.types.Strip] = [] no_version: List[bpy.types.Strip] = [] up_to_date: List[bpy.types.Strip] = [] checked: List[bpy.types.Strip] = [] excluded: List[bpy.types.Strip] = [] sequences = context.selected_sequences if not sequences: sequences = context.scene.sequence_editor.sequences_all logger.info("-START- Scanning for media updates") for strip in sequences: if not strip.type == "MOVIE": continue checked.append(strip) # Check if it has valid filepath key. if not strip.filepath: logger.info("%s has invalid filepath. Skip", strip.name) invalid.append(strip) continue media_path_old = Path(os.path.abspath(bpy.path.abspath(strip.filepath))) current_version = util.get_version(media_path_old.name) # Check if filepath is in include path. included = False paths = [item.filepath for item in addon_prefs.media_update_search_paths] paths.append(addon_prefs.shot_playblast_root_dir) for path in paths: filepath = Path(os.path.abspath(bpy.path.abspath(path))) if media_path_old.as_posix().startswith(filepath.as_posix()): included = True break if not included: logger.info("Not included in media update search list: %s", strip.filepath) excluded.append(strip) continue # Check if source media path contains version string. if not current_version: no_version.append(strip) continue # Gather valid files to compare source media to. media_folder = media_path_old.parent media_all: List[Path] = [ f for f in media_folder.iterdir() if f.is_file() and util.get_version(f.name) ] # List of files that are named as source except for version str. valid_files: List[Path] = [] for file in media_all: # Version should exists here, we already check for that in list comprehension. version = util.get_version(file.name) # We only want to consider files that have the same name except for version string. if file.name.replace(version, "") != media_path_old.name.replace( current_version, "" ): continue valid_files.append(file) valid_files.sort(reverse=True) # No valid files found, should not happen source file should be at least here. if not valid_files: continue if valid_files[0] == media_path_old: # Logger.info("%s already up to date: %s", strip.name, strip.filepath). strip.kitsu.media_outdated = False up_to_date.append(strip) continue # Load latest media. logger.info( "%s newer version of source media available: %s > %s", strip.name, current_version, util.get_version(valid_files[0].name), ) # Append to outdated list. outdated.append(strip) # Set media outdatet property for gpu overlay. strip.kitsu.media_outdated = True # Report. self.report( {"INFO"}, f"Scanned {len(checked)} | Outdatet: {len(outdated)} | Up-to-date: {len(up_to_date)} | Invalid: {len(invalid) + len(excluded) + len(no_version)}", ) # Log. logger.info("-END- Scanning for outdated media") util.ui_redraw() return {"FINISHED"} def get_used_channels(self: Any, context: bpy.types.Context, edit_text: str = "") -> List[str]: used_channels = [] for seq in context.scene.sequence_editor.sequences_all: used_channels.append(seq.channel) aval_channels = [] for channel in range(1, 100): if channel not in used_channels: aval_channels.append(channel) return [f"{channel}" for channel in aval_channels] def get_shot_task_types_enum_list(self, context: bpy.types.Context) -> List[Tuple[str, str, str]]: active_project = cache.project_active_get() return [ (t.id, t.name, "") for t in TaskType.all_shot_task_types() if t.id in active_project.task_types ] class KITSU_OT_sqe_import_playblast(bpy.types.Operator): bl_idname = "kitsu.sqe_import_playblast" bl_label = "Import Playblast" bl_description = "Import playblast for selected metadata strips" bl_options = {"REGISTER", "UNDO"} channel_selection: bpy.props.StringProperty( # type: ignore name="Channel", description="Choose an empty target channel to place playblasts onto", search=get_used_channels, search_options={'SORT'}, ) task_type: bpy.props.EnumProperty( # type: ignore name="Task Type", description="Choose a task type to import playblasts for", items=get_shot_task_types_enum_list, ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: sqe = context.scene.sequence_editor if not sqe: return False if not cache.project_active_get(): cls.poll_message_set("No Kitsu Project Found check Add-on Preferences") return False for strip in context.selected_sequences: if strip.kitsu.shot_id == "": cls.poll_message_set(f"Selected strip {strip.name} is not metadata strip'") return False if len(bpy.context.selected_sequences) == 0: cls.poll_message_set("Please select one or more metadata strips") return False return True def invoke(self, context, event): channels = [int(x) for x in get_used_channels(self, context)] strip = context.selected_sequences[0] channel = min(channels, key=lambda x: abs(x - strip.channel)) self.channel_selection = f"{channel}" return context.window_manager.invoke_props_dialog(self, width=500) def draw(self, context: bpy.types.Context) -> None: layout = self.layout layout.prop(self, "channel_selection") layout.prop(self, "task_type") def execute(self, context: Context) -> Set[str] | Set[int]: succeeded: Set[str] = set() failed: Set[str] = set() sequences = context.scene.sequence_editor.sequences metadata_strips = [ strip for strip in context.selected_sequences if strip.kitsu.shot_id != '' ] for metadata_strip in metadata_strips: # TODO add try except if ID is not valid, do same for shot as image sequence. shot = Shot.by_id(metadata_strip.kitsu.shot_id) task_type_short_name = TaskType.by_id(self.task_type).get_short_name() filepath = shot.get_latest_playblast_file(context, task_type_short_name) if filepath: playblast = sequences.new_movie( name=Path(filepath).name, filepath=filepath, frame_start=int(metadata_strip.frame_start), channel=int(self.channel_selection), ) if playblast.frame_final_end > metadata_strip.frame_final_end: playblast.frame_final_end = metadata_strip.frame_final_end succeeded.add(metadata_strip.name) else: failed.add(metadata_strip.name) # Convert Sets to Lists for easy access to contents succeeded = list(succeeded) failed = list(failed) if len(metadata_strips) == 1: if len(failed) == 1: self.report({"WARNING"}, f"Failed to import Playblast `{failed[0]}` does not exist") return {"CANCELLED"} if len(succeeded) == 1: self.report({"INFO"}, f"Imported Playblast from `{succeeded[0]}`") return {"FINISHED"} report_str = f"Imported {len(succeeded)} Playblast" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) self.report( {report_state}, report_str, ) return {'FINISHED'} class KITSU_OT_sqe_import_image_sequence(bpy.types.Operator): bl_idname = "kitsu.sqe_import_image_sequence" bl_label = "Import Image Sequence" bl_description = "Import Image Sequence for selected metadata strips" bl_options = {"REGISTER", "UNDO"} channel_selection: bpy.props.StringProperty( name="Channel", description="Choose an empty target channel to place image sequences onto", search=get_used_channels, search_options={'SORT'}, ) file_type: bpy.props.EnumProperty( name="File Type", description="Choose an empty target channel to place image sequences onto", items=[ ('.jpg', 'JPG', '', '', 0), ('.exr', 'EXR', '', '', 1), ], ) set_color_space: bpy.props.BoolProperty( name="Match Scene Color", description="Match scene color space to image sequence file type. \n JPG: sGRB with no look, EXR: Linear with Medium Contrast look", default=True, ) task_type: bpy.props.EnumProperty( # type: ignore name="Task Type", description="Choose a task type to import playblasts for", items=get_shot_task_types_enum_list, ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: sqe = context.scene.sequence_editor if not sqe: return False if not cache.project_active_get(): cls.poll_message_set("No Kitsu Project Found check Add-on Preferences") return False for strip in context.selected_sequences: if strip.kitsu.shot_id == "": cls.poll_message_set(f"Selected strip {strip.name} is not metadata strip'") return False if len(bpy.context.selected_sequences) == 0: cls.poll_message_set("Please select a 'MOVIE' strip") return False return True def set_scene_colorspace(self, context): if bpy.app.version_string.split('.')[0] == '3': color_space_name = "Linear" else: color_space_name = "Linear Rec.709" scene = context.scene if self.file_type == ".jpg": scene.sequencer_colorspace_settings.name = "sRGB" scene.view_settings.look = "None" scene.view_settings.view_transform = 'Standard' if self.file_type == ".exr": scene.sequencer_colorspace_settings.name = color_space_name scene.view_settings.look = 'Medium High Contrast' scene.view_settings.view_transform = 'Filmic' def invoke(self, context, event): channels = [int(x) for x in get_used_channels(self, context)] strip = context.selected_sequences[0] channel = min(channels, key=lambda x: abs(x - strip.channel)) self.channel_selection = f"{channel}" return context.window_manager.invoke_props_dialog(self, width=500) def draw(self, context: bpy.types.Context) -> None: layout = self.layout layout.prop(self, "task_type") layout.prop(self, "channel_selection") layout.prop(self, "file_type") layout.prop(self, "set_color_space") def get_shot_name(self, strip): filepath = strip.filepath name = Path(filepath).name match = re.search(r"^(\w+)-", name) if match: return match.group(1) return def import_strip(self, context, metadata_strip, directory, channel): # https://blender.stackexchange.com/questions/286946/how-to-add-image-sequence-in-sequencer-via-python frame_start = metadata_strip.frame_final_start frame_end = metadata_strip.frame_final_end files = [] shot = Shot.by_id(metadata_strip.kitsu.shot_id) start_frame = ( shot.data.get('3d_start') if shot.data.get('3d_start') else bkglobals.FRAME_START ) for file in sorted(list(directory.iterdir())): if file.name.endswith("mp4"): continue frame_number = int(file.name.split(".")[0]) duration = metadata_strip.frame_duration + start_frame if self.file_type in file.name and frame_number < duration: files.append({"name": file.name}) area_type = 'SEQUENCE_EDITOR' areas = [area for area in bpy.context.window.screen.areas if area.type == area_type] if files == []: self.report({'ERROR'}, "No files found") return {'CANCELLED'} len_strip = len(context.scene.sequence_editor.sequences_all) with bpy.context.temp_override( window=bpy.context.window, area=areas[0], regions=[region for region in areas[0].regions if region.type == 'WINDOW'][0], screen=bpy.context.window.screen, ): bpy.ops.sequencer.image_strip_add( directory=directory._str, files=files, relative_path=True, show_multiview=False, frame_start=frame_start, frame_end=frame_end, channel=channel, fit_method='FIT', ) if len_strip + 1 != len(context.scene.sequence_editor.sequences_all): print(f"Failed to import image sequence for {metadata_strip.name}") return new_strip = context.selected_sequences[0] new_strip.animation_offset_end = metadata_strip.animation_offset_end new_strip.animation_offset_start = metadata_strip.animation_offset_start new_strip.frame_offset_end = metadata_strip.frame_offset_end new_strip.frame_offset_start = metadata_strip.frame_offset_start new_strip.frame_start = metadata_strip.frame_start new_strip.channel = channel new_strip.name = f"{self.get_shot_name(metadata_strip)}{self.file_type.lower()}" new_strip.colorspace_settings.name = new_strip.colorspace_settings.name def get_shot_seq_directory(self, context, filepath): addon_prefs = prefs.addon_prefs_get(context) path_string = os.path.realpath(bpy.path.abspath(filepath)) path = Path( path_string.replace(addon_prefs.shot_playblast_root_dir, addon_prefs.frames_root_dir) ) return path.parent def execute(self, context: bpy.types.Context) -> Set[str]: # Get closest empty channel succeeded = [] failed = [] channel = int(self.channel_selection) addon_prefs = prefs.addon_prefs_get(context) if not (Path(addon_prefs.frames_root_dir).is_dir() and addon_prefs.frames_root_dir != ''): self.report({"ERROR"}, f"Frames Directory does not exist, check add-on preferences") return {"CANCELLED"} if self.set_color_space: self.set_scene_colorspace(context) metadata_strips = [ strip for strip in context.selected_sequences if strip.kitsu.shot_id != '' ] for strip in metadata_strips: shot = Shot().by_id(strip.kitsu.shot_id) # TODO pass task type as variable task_type_short_name = TaskType.by_id(self.task_type).get_short_name() filepath = shot.get_latest_playblast_file(context, task_type_short_name) directory = self.get_shot_seq_directory(context, filepath) if not directory.exists(): failed.append(str(directory)) continue self.import_strip(context, strip, directory, channel) succeeded.append(str(directory)) if len(metadata_strips) == 1: if len(failed) == 1: self.report( {"WARNING"}, f"Failed to import Image Sequence `{failed[0]}` does not exist" ) return {"CANCELLED"} if len(succeeded) == 1: self.report({"INFO"}, f"Imported Image Sequence from `{succeeded[0]}`") return {"FINISHED"} report_str = f"Imported {len(succeeded)} Image Sequences" report_state = "INFO" if failed: report_state = "WARNING" report_str += f" | Failed: {len(failed)}" self.report( {report_state}, report_str, ) return {"FINISHED"} class KITSU_OT_sqe_clear_update_indicators(bpy.types.Operator): bl_idname = "kitsu.sqe_clear_update_indicators" bl_label = "Clear Update Indicators" bl_description = "Removes the media update indicators from all strips" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: sqe = context.scene.sequence_editor if not sqe: return False return bool(sqe.sequences_all) def execute(self, context: bpy.types.Context) -> Set[str]: addon_prefs = prefs.addon_prefs_get(context) reset: List[bpy.types.Strip] = [] sequences = context.selected_sequences if not sequences: sequences = context.scene.sequence_editor.sequences_all for strip in sequences: if strip.kitsu.media_outdated: strip.kitsu.media_outdated = False reset.append(strip) if not reset: self.report({"INFO"}, "Already reset") return {"FINISHED"} self.report( {"INFO"}, f"Cleared indicator of {len(reset)} {'strip' if len(reset) == 1 else 'strips'}", ) util.ui_redraw() return {"FINISHED"} class KITSU_OT_sqe_change_strip_source(bpy.types.Operator): bl_idname = "kitsu.sqe_change_strip_source" bl_label = "Change Strip Media Source" bl_description = ( "Changes the media source of the active strip by " "cycling through the different versions on disk" ) bl_options = {"REGISTER", "UNDO"} direction: bpy.props.EnumProperty(items=[("UP", "UP", ""), ("DOWN", "DOWN", "")]) go_latest: bpy.props.BoolProperty(name="Got to latest", default=False) @classmethod def poll(cls, context: bpy.types.Context) -> bool: sqe = context.scene.sequence_editor if not sqe: return False return bool(sqe.active_strip) def execute(self, context: bpy.types.Context) -> Set[str]: strip = context.scene.sequence_editor.active_strip # Check if it has valid filepath key. if not strip.filepath: self.report({"ERROR"}, f"{strip.name} has invalid filepath") return {"CANCELLED"} media_path_old = Path(os.path.abspath(bpy.path.abspath(strip.filepath))) current_version = util.get_version(media_path_old.name) # Check if source media path contains version string. if not current_version: self.report( {"ERROR"}, f"{strip.name} source media contains no version string: {strip.filepath}", ) return {"CANCELLED"} # Gather valid files to compare source media to. media_folder = media_path_old.parent media_all: List[Path] = [ f for f in media_folder.iterdir() if f.is_file() and util.get_version(f.name) ] # List of files that are named as source except for version str. valid_files: List[Path] = [] for file in media_all: # Version should exists here, we already check for that in list comprehension. version = util.get_version(file.name) # We only want to consider files that have the same name except for version string. if file.name.replace(version, "") != media_path_old.name.replace(current_version, ""): continue valid_files.append(file) valid_files.sort(reverse=True) # No valid files found, should not happen source file should be at least here. if not valid_files: self.report({"WARNING"}, f"{strip.name} no other files available") current_idx = valid_files.index(media_path_old) if self.go_latest: latest_index = 0 # Check if already on latest version. if current_idx == latest_index: self.report( {"INFO"}, f"Already at latest version: {util.get_version(valid_files[0].name)}", ) else: self.report( {"INFO"}, f"Reached latest version: {util.get_version(valid_files[0].name)}", ) strip.filepath = bpy.path.relpath(valid_files[latest_index].as_posix()) strip.kitsu.media_outdated = False # Needs to be reset otherwise other operator instances also do go_latest. self.go_latest = False elif self.direction == "UP": new_index = current_idx - 1 if new_index <= 0: self.report( {"INFO"}, f"Reached latest version: {util.get_version(valid_files[0].name)}", ) strip.kitsu.media_outdated = False if current_idx == 0: return {"FINISHED"} if new_index >= 0: strip.filepath = bpy.path.relpath(valid_files[new_index].as_posix()) elif self.direction == "DOWN": new_index = current_idx + 1 if new_index >= len(valid_files) - 1: self.report( {"INFO"}, f"Reached oldest version: {util.get_version(valid_files[-1].name)}", ) if current_idx == len(valid_files) - 1: return {"FINISHED"} if new_index <= len(valid_files) - 1: strip.filepath = bpy.path.relpath(valid_files[new_index].as_posix()) strip.kitsu.media_outdated = True # Load latest media. logger.info( "%s changed source media version: %s > %s", strip.name, current_version, util.get_version(Path(strip.filepath).name), ) util.ui_redraw() return {"FINISHED"} # ---------REGISTER ----------. classes = [ KITSU_OT_sqe_push_new_sequence, KITSU_OT_sqe_push_new_shot, KITSU_OT_sqe_push_shot_meta, KITSU_OT_sqe_uninit_strip, KITSU_OT_sqe_unlink_shot, KITSU_OT_sqe_init_strip, KITSU_OT_sqe_link_shot, KITSU_OT_sqe_link_sequence, KITSU_OT_sqe_set_thumbnail_task_type, KITSU_OT_sqe_set_sqe_render_task_type, KITSU_OT_sqe_push_render_still, KITSU_OT_sqe_push_render, KITSU_OT_sqe_push_shot, KITSU_OT_sqe_push_del_shot, KITSU_OT_sqe_pull_shot_meta, KITSU_OT_sqe_multi_edit_strip, KITSU_OT_sqe_debug_duplicates, KITSU_OT_sqe_debug_not_linked, KITSU_OT_sqe_debug_multi_project, KITSU_OT_sqe_pull_edit, KITSU_OT_sqe_init_strip_start_frame, KITSU_OT_sqe_create_metadata_strip, KITSU_OT_sqe_fix_metadata_strips, KITSU_OT_sqe_add_sequence_color, KITSU_OT_sqe_scan_for_media_updates, KITSU_OT_sqe_change_strip_source, KITSU_OT_sqe_clear_update_indicators, KITSU_OT_sqe_import_image_sequence, KITSU_OT_sqe_import_playblast, ] def register(): for cls in classes: bpy.utils.register_class(cls) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls)