diff --git a/docs/media/addons/blender_kitsu/Shot_as_Image_Sequence.jpg b/docs/media/addons/blender_kitsu/Shot_as_Image_Sequence.jpg deleted file mode 100644 index fe330736..00000000 --- a/docs/media/addons/blender_kitsu/Shot_as_Image_Sequence.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:32179fa31e68bb3db9f399f276aef2cc099900f362e23083fc2f844b5b731df2 -size 30881 diff --git a/docs/media/addons/blender_kitsu/import_playblast.jpg b/docs/media/addons/blender_kitsu/import_playblast.jpg new file mode 100644 index 00000000..e7ee390b --- /dev/null +++ b/docs/media/addons/blender_kitsu/import_playblast.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af9b32a64d397be45f3a93da07fc9363bf0d4e0bda508a7085d4dc698018edad +size 21621 diff --git a/docs/media/addons/blender_kitsu/media_panel.jpg b/docs/media/addons/blender_kitsu/media_panel.jpg new file mode 100644 index 00000000..8e8f98bd --- /dev/null +++ b/docs/media/addons/blender_kitsu/media_panel.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:237b450829e64323e789ee236308ff18558f76dace1510b6e3f03f2c05e2dd11 +size 12367 diff --git a/docs/media/addons/blender_kitsu/shot_image_sequence.jpg b/docs/media/addons/blender_kitsu/shot_image_sequence.jpg new file mode 100644 index 00000000..7e995fdb --- /dev/null +++ b/docs/media/addons/blender_kitsu/shot_image_sequence.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:552f020232601d6feab4036dd1f825cd23c73c2dfc25f87b0059a4c9c015636d +size 28019 diff --git a/docs/user-guide/project_tools/usage-playblast.md b/docs/user-guide/project_tools/usage-playblast.md index 5702ddd4..632dc16c 100644 --- a/docs/user-guide/project_tools/usage-playblast.md +++ b/docs/user-guide/project_tools/usage-playblast.md @@ -16,6 +16,7 @@ For each new task type, Anim/Layout etc needs to be added manually, then it can Returning to your edit .blend file, we can now load the playblast from the animation file into the edit. 1. Open your edit .blend file inside the directory `/your_project_name/svn/edit` -2. From the Sequencer Header select `Add>Movie` -3. Navigate to the directory of the playblast for your shot's .blend file `your_project_name/shared/footage/pro/{sequence_name}/{shot_name}/{file_name}` and select the `.mp4` file -4. Place the new shot at the same timing as the corresponding metastrip \ No newline at end of file +2. Select the Metadata Strip associated with your shot +3. From the Sequencer Side Panel select `Import 1 Shot Playblast` +3. Select the Task Type you would like to load the playblast from and an empty channel +4. Your new playblast will be imported with the same timing as the corresponding metadata strip \ No newline at end of file diff --git a/docs/user-guide/project_tools/usage-render-review.md b/docs/user-guide/project_tools/usage-render-review.md index a10244c1..ee910f74 100644 --- a/docs/user-guide/project_tools/usage-render-review.md +++ b/docs/user-guide/project_tools/usage-render-review.md @@ -16,4 +16,4 @@ Renders approved by the render review Add-On can be automatically imported into 1. Open your Edit .blend file 2. Select the video strip representing the shot that has an approved render. In the Kitsu Sidebar under General Tools select, `^` to load the next playblast from that shot automatically, which is an **mp4 preview** of your final render -3. Select the video strips representing all the shots you have approved renders for. Use `Shot as Image Sequence` to import the final image sequences for each shot as EXR or JPG and load it to a new channel in the VSE +3. Select the metadata strips representing all the shots you have approved renders for. Use `Import Image Sequence` operator to import the final image sequences for each shot as EXR or JPG and load it to a new channel in the VSE diff --git a/scripts-blender/addons/blender_kitsu/README.md b/scripts-blender/addons/blender_kitsu/README.md index 52a01be6..ed4c415a 100644 --- a/scripts-blender/addons/blender_kitsu/README.md +++ b/scripts-blender/addons/blender_kitsu/README.md @@ -14,7 +14,7 @@ blender-kitsu is a Blender Add-on to interact with Kitsu from within Blender. It - [Push](#push) - [Pull](#pull) - [Multi Edit](#multi-edit) - - [Shot as Image Sequence](#shot-as-image-sequence) + - [Import Media](#import-media) - [General Sequence Editor Tools](#general-sequence-editor-tools) - [Context](#context) - [Animation Tools](#animation-tools) @@ -192,13 +192,21 @@ The `Multi Edit` panel only appears when you select multiple metadata strips tha ![image info](/media/addons/blender_kitsu/sqe_multi_edit.jpg) It is meant to be way to quickly setup lots of shots if they don't exist on Kitsu yet. You specify the sequence all shots should belong to and adjust the `Shot Counter Start` value. In the preview property you can see how all shots will be named when you execute the `Multi Edit Strip` operator.
+##### Import Media +A collection of operators to Import media based on the Shot associated with the selected metadata strip(s).
-##### Shot as Image Sequence -The `Shot as Image Sequence` Operator will replace a playblast from your Playblast Root directory with an image sequence located in the Frames Root directory. The Shots Directory and the Frames Directory should have matching folder structures. Typically the format is `/{sequence_name}/{shot_name}/{shot_name}-{shot_task}/` +![Media Panel](/media/addons/blender_kitsu/media_panel.jpg) +##### Import Playblast +With a metadata strip selected `Import Playblast` Operator will find an image sequence of a given task type located in the Frames Root directory. +![Import Playblast](/media/addons/blender_kitsu/import_playblast.jpg) -![Shot as Image Sequence](/media/addons/blender_kitsu/Shot_as_Image_Sequence.jpg) +Use this operator to import image sequences that have been approved via the [Render Review Add-On](/addons/render_review) Image Sequences can be loaded as either `EXR` or `JPG` sequences. -Use this operator to replace playblasts with image sequences that have been approved via the [Render Review Add-On](/addons/render_review) Image Sequences can be loaded as either `EXR` or `JPG` sequences. +##### Import Image Sequence +With a metadata strip selected `Import Image Sequence` Operator will find an image sequence of a given task type located in the Frames Root directory. +![Import Image Sequence](/media/addons/blender_kitsu/shot_image_sequence.jpg) + +Use this operator to import image sequences that have been approved via the [Render Review Add-On](/addons/render_review) Image Sequences can be loaded as either `EXR` or `JPG` sequences. ###### Advanced Settings If you check the `Advanced` checkbox next to the counter value, you have access to advance settings to customize the operator even more. diff --git a/scripts-blender/addons/blender_kitsu/sqe/ops.py b/scripts-blender/addons/blender_kitsu/sqe/ops.py index 8d160d21..5ec197c1 100644 --- a/scripts-blender/addons/blender_kitsu/sqe/ops.py +++ b/scripts-blender/addons/blender_kitsu/sqe/ops.py @@ -20,6 +20,8 @@ import os import re + +from bpy.types import Context import gazu import contextlib import colorsys @@ -2195,23 +2197,133 @@ class KITSU_OT_sqe_scan_for_media_updates(bpy.types.Operator): return {"FINISHED"} -class KITSU_OT_shot_image_sequence(bpy.types.Operator): - bl_idname = "kitsu.shot_image_sequence" - bl_label = "Shot as Image Sequence" - bl_description = "Import image sequences for selected clips" +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"} - 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) + 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'}, + ) - aval_channels = [] - for channel in range(1, 100): - if channel not in used_channels: - aval_channels.append(channel) + 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, + ) - return [f"{channel}" for channel in aval_channels] + @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) + + 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", @@ -2234,6 +2346,12 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): 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 @@ -2242,9 +2360,9 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): if not cache.project_active_get(): cls.poll_message_set("No Kitsu Project Found check Add-on Preferences") return False - for sqe in context.selected_sequences: - if sqe.type != 'MOVIE': - cls.poll_message_set("Selected strips must be 'MOVIE'") + 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") @@ -2268,7 +2386,7 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): scene.view_settings.view_transform = 'Filmic' def invoke(self, context, event): - channels = [int(x) for x in self.get_used_channels(context, "")] + 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}" @@ -2276,6 +2394,7 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): 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") @@ -2288,23 +2407,13 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): return match.group(1) return - def get_metadata_strip(self, context, strip): - name = self.get_shot_name(strip) - for strip in context.scene.sequence_editor.sequences_all: - if strip.name == name: - return strip - return - - def import_strip(self, context, strip, directory, channel): + 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 = strip.frame_final_start - frame_end = strip.frame_final_end + frame_start = metadata_strip.frame_final_start + frame_end = metadata_strip.frame_final_end files = [] - metadata_strip = self.get_metadata_strip(context, strip) - if not metadata_strip: - self.report({'ERROR'}, f"No Metadata Strip found for {strip.name}") - return {'CANCELLED'} + 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 @@ -2313,7 +2422,7 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): if file.name.endswith("mp4"): continue frame_number = int(file.name.split(".")[0]) - duration = strip.frame_duration + start_frame + duration = metadata_strip.frame_duration + start_frame if self.file_type in file.name and frame_number < duration: files.append({"name": file.name}) @@ -2345,24 +2454,24 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): fit_method='FIT', ) if len_strip + 1 != len(context.scene.sequence_editor.sequences_all): - print(f"Failed to import image sequence for {strip.name}") + print(f"Failed to import image sequence for {metadata_strip.name}") return new_strip = context.selected_sequences[0] - new_strip.animation_offset_end = strip.animation_offset_end - new_strip.animation_offset_start = strip.animation_offset_start + 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 = strip.frame_offset_end - new_strip.frame_offset_start = strip.frame_offset_start - new_strip.frame_start = strip.frame_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(strip)}{self.file_type.lower()}" + 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, strip): + def get_shot_seq_directory(self, context, filepath): addon_prefs = prefs.addon_prefs_get(context) - path_string = os.path.realpath(bpy.path.abspath(strip.filepath)) + 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 @@ -2372,6 +2481,8 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): 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 ( @@ -2386,12 +2497,42 @@ class KITSU_OT_shot_image_sequence(bpy.types.Operator): if self.set_color_space: self.set_scene_colorspace(context) - for strip in [strip for strip in context.selected_sequences if strip.type == 'MOVIE']: - directory = self.get_shot_seq_directory(context, strip) + 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(): - self.report({"ERROR"}, f"{directory._str} does not exist") - return {"CANCELLED"} + 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"} @@ -2594,7 +2735,8 @@ classes = [ KITSU_OT_sqe_scan_for_media_updates, KITSU_OT_sqe_change_strip_source, KITSU_OT_sqe_clear_update_indicators, - KITSU_OT_shot_image_sequence, + KITSU_OT_sqe_import_image_sequence, + KITSU_OT_sqe_import_playblast, ] diff --git a/scripts-blender/addons/blender_kitsu/sqe/ui.py b/scripts-blender/addons/blender_kitsu/sqe/ui.py index 9c1d1220..f0486c6e 100644 --- a/scripts-blender/addons/blender_kitsu/sqe/ui.py +++ b/scripts-blender/addons/blender_kitsu/sqe/ui.py @@ -50,7 +50,8 @@ from ..sqe.ops import ( KITSU_OT_sqe_scan_for_media_updates, KITSU_OT_sqe_change_strip_source, KITSU_OT_sqe_clear_update_indicators, - KITSU_OT_shot_image_sequence, + KITSU_OT_sqe_import_image_sequence, + KITSU_OT_sqe_import_playblast, ) from pathlib import Path @@ -121,6 +122,7 @@ class KITSU_PT_sqe_shot_tools(bpy.types.Panel): if self.poll_pull(context): self.draw_pull(context) + self.draw_media(context) if self.poll_debug(context): self.draw_debug(context) @@ -639,6 +641,30 @@ class KITSU_PT_sqe_shot_tools(bpy.types.Panel): icon="MODIFIER_ON", ) + def draw_media(self, context: bpy.types.Context) -> None: + + sel_metadata_strips = [strip for strip in context.selected_sequences if strip.kitsu.linked] + + noun = get_selshots_noun(len(sel_metadata_strips), prefix=f"{len(sel_metadata_strips)}") + playblast = "Playblast" if len(sel_metadata_strips) <= 1 else "Playblasts" + + sequence = "Sequence" if len(sel_metadata_strips) <= 1 else "Sequences" + + # Create box. + layout = self.layout + box = layout.box() + box.label(text="Media", icon="RENDER_ANIMATION") + box.operator( + KITSU_OT_sqe_import_playblast.bl_idname, + text=f"Import {noun} {playblast}", + icon="FILE_MOVIE", + ) + box.operator( + KITSU_OT_sqe_import_image_sequence.bl_idname, + text=f"Import {noun} Image {sequence}", + icon="RENDER_RESULT", + ) + class KITSU_PT_sqe_general_tools(bpy.types.Panel): """ @@ -684,7 +710,6 @@ class KITSU_PT_sqe_general_tools(bpy.types.Panel): box = layout.box() box.label(text="General", icon="MODIFIER") - box.operator(KITSU_OT_shot_image_sequence.bl_idname) # Scan for outdated media and reset operator. row = box.row(align=True) row.operator( diff --git a/scripts-blender/addons/blender_kitsu/types.py b/scripts-blender/addons/blender_kitsu/types.py index 1fa37ceb..88a56858 100644 --- a/scripts-blender/addons/blender_kitsu/types.py +++ b/scripts-blender/addons/blender_kitsu/types.py @@ -27,6 +27,8 @@ import gazu from .logger import LoggerFactory from . import bkglobals from . import prefs +from .models import FileListModel +import mimetypes logger = LoggerFactory.getLogger() @@ -614,24 +616,54 @@ class Shot(Entity): def get_output_collection_name(self, task_type_short_name: str) -> str: return f"{self.get_task_name(task_type_short_name)}{bkglobals.DELIMITER}output" - def get_dir(self, context) -> str: - project_root_dir = prefs.project_root_dir_get(context) - all_shots_dir = project_root_dir.joinpath('pro').joinpath('shots') - + def get_shot_folder_tree(self, base_path: Path) -> str: # Add Episode to Path if available if self.episode_id: - base_dir = all_shots_dir.joinpath(self.episode_name) + base_dir = base_path.joinpath(self.episode_name) else: - base_dir = all_shots_dir + base_dir = base_path seq = self.get_sequence() shot_dir = base_dir.joinpath(seq.name).joinpath(self.name) - return shot_dir.__str__() + return shot_dir + + def get_dir(self, context) -> str: + project_root_dir = prefs.project_root_dir_get(context) + all_shots_dir = project_root_dir.joinpath('pro').joinpath('shots') + return str(self.get_shot_folder_tree(all_shots_dir)) def get_filepath(self, context, task_type_short_name: str) -> str: file_name = self.get_task_name(task_type_short_name) + '.blend' return Path(self.get_dir(context)).joinpath(file_name).__str__() + def get_playblast_dir(self, context, task_type_short_name: str) -> str: + addon_prefs = prefs.addon_prefs_get(context) + playblsat_dir = addon_prefs.shot_playblast_root_dir + shot_dir = self.get_shot_folder_tree(Path(playblsat_dir)) + task_dir = shot_dir.joinpath(self.name + bkglobals.DELIMITER + task_type_short_name) + return task_dir.__str__() + + def get_latest_playblast_file(self, context, task_type_short_name: str): + filemodel = FileListModel() + filemodel.reset() + playblast_dir = Path(self.get_playblast_dir(context, task_type_short_name)) + filemodel.root_path = playblast_dir + if len(filemodel.items) < 1: + return + + playblast_files = set() + for file in filemodel.items: + filepath = playblast_dir.joinpath(file) + if mimetypes.guess_type(filepath)[0].startswith('video'): + playblast_files.add(filepath) + + playblast_files = sorted(playblast_files, key=lambda x: str(x), reverse=True) + file = playblast_files[0] + + if not file.exists(): + return + return str(file) + def update_data(self, data: Dict[str, Any]) -> Shot: gazu.shot.update_shot_data(asdict(self), data=data) if not self.data: