# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors # # SPDX-License-Identifier: GPL-3.0-or-later import sys import subprocess import shutil from pathlib import Path from datetime import datetime from typing import Set, Union, Optional, List, Dict, Any, Tuple from collections import OrderedDict import bpy from . import vars, opsdata, util from .. import prefs, cache from ..sqe import opsdata as seq_opsdata from ..logger import LoggerFactory from .exception import NoImageStripAvailableException logger = LoggerFactory.getLogger() class RR_OT_sqe_create_review_session(bpy.types.Operator): """ Review a sequence of shots or a single shot. Review shot will load all available preview sequences (.jpg / .png) of each found rendering in to the sequence editor of the specified shot. Review sequence does it for each shot in that sequence. If user enabled use_blender_kitsu in the addon preferences, this operator will create a linked metadata strip for the loaded shot on the top most channel. """ bl_idname = "rr.sqe_create_review_session" bl_label = "Create Review Session" bl_description = ( "Imports all available renderings for the specified shot / sequence " "in to the sequence editor" ) bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: render_dir = context.scene.rr.render_dir_path if not render_dir: return False return bool( context.scene.rr.is_render_dir_valid and opsdata.is_shot_dir(render_dir) or opsdata.is_sequence_dir(render_dir) ) def load_strip_from_img_seq(self, context, directory, idx: int, frame_start: int = 0): try: # Get best preview files sequence. image_sequence = opsdata.get_best_preview_sequence(directory) except NoImageStripAvailableException: # if no preview files available create an empty image strip # this assumes that when a folder is there exr sequences are available to inspect logger.warning("%s found no preview sequence", directory.name) exr_files = opsdata.gather_files_by_suffix( directory, output=list, search_suffixes=[".exr"] ) if not exr_files: logger.error("%s found no exr or preview sequence", directory.name) return image_sequence = exr_files[0] logger.info("%s found %i exr frames", directory.name, len(image_sequence)) else: logger.info("%s found %i preview frames", directory.name, len(image_sequence)) # Get frame start. frame_start = frame_start or int(image_sequence[0].stem) # Create new image strip. strip = context.scene.sequence_editor.sequences.new_image( name=directory.name, filepath=image_sequence[0].as_posix(), channel=idx + 1, frame_start=frame_start, fit_method='ORIGINAL', ) # Extend strip elements with all the available frames. for f in image_sequence[1:]: strip.elements.append(f.name) # Set strip properties. strip.mute = True if image_sequence[0].suffix == ".exr" else False return strip def execute(self, context: bpy.types.Context) -> Set[str]: # Clear existing strips and markers context.scene.sequence_editor_clear() context.scene.timeline_markers.clear() addon_prefs = prefs.addon_prefs_get(context) render_dir = Path(context.scene.rr.render_dir_path) shot_version_folders_dict = self.get_shot_folder_dict( render_dir=render_dir, max_versions_per_shot=addon_prefs.versions_max_count, ) prev_frame_end: int = 1 for shot_task_name, shot_version_folders in shot_version_folders_dict.items(): shot_name = shot_task_name.split("-")[0] logger.info("Loading versions of shot %s", shot_name) imported_strips = self.import_shot_versions_as_strips( context, shot_version_folders, frame_start=prev_frame_end, shot_name=shot_name, ) if not imported_strips: continue # Query the strip that is the longest for metadata strip and prev_frame_end. imported_strips.sort(key = lambda s : s.channel) if addon_prefs.match_latest_length: strip_ref = imported_strips[-1] if strip_ref.frame_final_duration <= 1 and len(imported_strips) >= 2: strip_ref = imported_strips[-2] else: strip_ref = sorted(imported_strips, key = lambda s: s.frame_final_duration)[-1] prev_frame_end = strip_ref.frame_final_end if addon_prefs.match_latest_length: channel_offset = 0 for strip in imported_strips: strip.channel -= channel_offset if strip.frame_final_duration != strip_ref.frame_final_duration: context.scene.sequence_editor.sequences.remove(strip) channel_offset += 1 else: # ensure reference strip still exists strip_ref = strip # Perform kitsu operations if enabled. if prefs.session_auth(context) and imported_strips: if opsdata.is_active_project(): sequence_name = shot_version_folders[0].parent.parent.parent.name # Create metadata strip. metadata_strip = seq_opsdata.create_metadata_strip( context.scene, f"{strip_ref.name}_metadata-strip", strip_ref.channel + 1, strip_ref.frame_final_start, strip_ref.frame_final_end, ) logger.info( "%s created Metadata Strip: %s", strip_ref.name, metadata_strip.name, ) # Link metadata strip. opsdata.link_strip_by_name(context, metadata_strip, shot_name, sequence_name) else: logger.error( "Unable to perform kitsu operations. No active project or not authorized" ) # Set default scene resolution to resolution of loaded image. render_resolution_x = vars.RESOLUTION[0] render_resolution_y = vars.RESOLUTION[1] project = cache.project_active_get() # If Kitsu add-on is enabled, fetch the resolution from the online project if project: # TODO: make the resolution fetching a bit more robust # Assume resolution is a string 'x' resolution = project.resolution.split('x') render_resolution_x = int(resolution[0]) render_resolution_y = int(resolution[1]) context.scene.render.resolution_x = render_resolution_x context.scene.render.resolution_y = render_resolution_y # Change frame range and frame start. opsdata.fit_frame_range_to_strips(context) context.scene.frame_current = context.scene.frame_start # Setup color management. opsdata.setup_color_management(context) # scan for approved renders, will modify strip.rr.is_approved prop # which controls the custom gpu overlay opsdata.update_sequence_statuses(context) bpy.ops.sequencer.select_all(action='DESELECT') util.redraw_ui() self.report( {"INFO"}, f"Imported {len(imported_strips)} Render Strips", ) return {"FINISHED"} def get_shot_folder_dict( self, render_dir: str, max_versions_per_shot=32, ) -> OrderedDict[str, List[Path]]: shot_version_folders: List[Path] = [] # If render is sequence folder user wants to review whole sequence. if opsdata.is_sequence_dir(render_dir): for shot_dir in render_dir.iterdir(): # TODO: Handle case when directory is empty shot_version_folders.extend(list(shot_dir.iterdir())) else: # TODO: Handle case when directory is empty shot_version_folders.extend(list(render_dir.iterdir())) shot_version_folders_dict = dict() for shot_main_folder in shot_version_folders: shot_name = opsdata.get_shot_name_from_dir(shot_main_folder) for shot_folder in shot_main_folder.iterdir(): if shot_name not in shot_version_folders_dict: shot_version_folders_dict[shot_name] = [shot_folder] else: shot_version_folders_dict[shot_name].append(shot_folder) # Sort versions by date for shot_name, shot_folder in shot_version_folders_dict.items(): shot_version_folders_dict[shot_name] = sorted(shot_folder, reverse=False) # Limit list to max number of versions shot_version_folders_dict[shot_name] = shot_version_folders_dict[shot_name][ -max_versions_per_shot: ] # Sort shots by name sorted_dict = OrderedDict(sorted(shot_version_folders_dict.items())) return sorted_dict def import_shot_versions_as_strips( self, context: bpy.types.Context, shot_version_folders: List[Path], frame_start: int, shot_name: str, ) -> List[bpy.types.Strip]: addon_prefs = prefs.addon_prefs_get(context) imported_strips: bpy.types.Strip = [] shots_folder_to_add = [] if addon_prefs.shot_name_filter == "": shots_folder_to_add = shot_version_folders else: for shot_folder in shot_version_folders: if addon_prefs.shot_name_filter in shot_folder.parent.name: shots_folder_to_add.append(shot_folder) for idx, shot_folder in enumerate(shots_folder_to_add): logger.info("Processing %s", shot_folder.name) use_video = addon_prefs.use_video and not ( shot_folder != shot_version_folders[-1] and addon_prefs.use_video_latest_only ) shot_strip = self.import_shot_as_strip( context, frame_start=frame_start, channel_idx=idx, shot_folder=shot_folder, shot_name=shot_name, use_video=use_video, ) if shot_strip: imported_strips.append(shot_strip) return imported_strips def import_shot_as_strip( self, context: bpy.types.Context, frame_start: int, channel_idx: int, shot_folder: Path, shot_name: str, use_video=False, ) -> bpy.types.Strip: context.scene.timeline_markers.new(shot_name, frame=frame_start) # Init sequencer if not context.scene.sequence_editor: context.scene.sequence_editor_create() ### Load preview sequences in vse. # Compose frames found text. frames_found_text = opsdata.gen_frames_found_text(shot_folder) if use_video: video_path = opsdata.get_farm_output_mp4_path_from_folder(shot_folder) if not video_path: logger.warning("%s found no .mp4 preview sequence", shot_folder.name) video_path = shot_folder strip = context.scene.sequence_editor.sequences.new_movie( name=shot_folder.name, filepath=video_path.as_posix(), channel=channel_idx + 1, frame_start=frame_start, fit_method='ORIGINAL', ) else: strip = self.load_strip_from_img_seq(context, shot_folder, channel_idx, frame_start) if not strip: return shot_datetime = datetime.fromtimestamp(shot_folder.stat().st_mtime) time_str = shot_datetime.strftime("%B %d, %I:%M") strip.name = f"{shot_folder.parent.name} ({time_str})" strip.rr.shot_name = shot_name strip.rr.is_render = True strip.rr.frames_found_text = frames_found_text return strip class RR_OT_setup_review_workspace(bpy.types.Operator): """ Makes Video Editing Workspace active and deletes all other workspaces. Replaces File Browser area with Image Editor. """ bl_idname = "rr.setup_review_workspace" bl_label = "Setup Review Workspace" bl_description = ( "Makes Video Editing Workspace active and deletes all other workspaces. " "Replaces File Browser area with Image Editor" ) bl_options = {"REGISTER", "UNDO"} def sequences_enum_items(self, context): return [("None", "None", "None")] + cache.get_sequences_enum_list(self, context) sequence: bpy.props.EnumProperty( name="Sequence", description="Select which sequence to review", items=sequences_enum_items, ) @staticmethod def delayed_setup_review_workspace(): """This function can be used as a bpy.app.timer. It is necessary to delay certain UI changing operations that rely on previous UI changing operations, because Blender.""" context = bpy.context for window in context.window_manager.windows: screen = window.screen for area in screen.areas: # Change video editing workspace media browser to image editor. if area.type == "FILE_BROWSER": area.type = "IMAGE_EDITOR" # Disable filepath overlay on the strips in the VSE. if area.spaces.active.type == 'SEQUENCE_EDITOR': area.spaces.active.timeline_overlay.show_strip_source = False area.spaces.active.timeline_overlay.show_strip_duration = False if area.spaces.active.view_type == 'PREVIEW': area.spaces.active.show_overlays = False def invoke(self, context, _event): if not cache.project_active_get(): return self.execute(context) 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 addon_prefs = prefs.addon_prefs_get(context) layout.prop(self, 'sequence') if self.sequence != 'None': layout.row().prop(addon_prefs, 'match_latest_length') row = layout.row() row.prop(addon_prefs, 'use_video') if addon_prefs.use_video: row.prop(addon_prefs, 'use_video_latest_only') layout.prop(addon_prefs, 'shot_name_filter') def execute(self, context: bpy.types.Context) -> Set[str]: render_dir = context.scene.rr.render_dir scripts_path = bpy.utils.script_paths(use_user=False)[0] template_path = "/startup/bl_app_templates_system/Video_Editing/startup.blend" ws_filepath = Path(scripts_path + template_path) bpy.ops.workspace.append_activate( idname="Video Editing", filepath=ws_filepath.as_posix(), ) # Pre-fill render directory with farm output shots directory. addon_prefs = prefs.addon_prefs_get(bpy.context) context.scene.rr.render_dir = str(Path(addon_prefs.farm_output_dir).joinpath(addon_prefs.shot_dir_name)) if not Path(render_dir).exists(): self.report( {"ERROR"}, f"Farm Output Directory {render_dir} doesn't exist, check Add-On preferences", ) return {"CANCELLED"} # Init sqe. if not context.scene.sequence_editor: context.scene.sequence_editor_create() # Setup color management. opsdata.setup_color_management(bpy.context) self.report({"INFO"}, "Setup Render Review Workspace") if self.sequence and self.sequence != 'None': cache.sequence_active_set_by_id(context, self.sequence) context.scene.rr.render_dir += "/" + cache.sequence_active_get().name bpy.ops.rr.sqe_create_review_session() # Switch File Browser to Image Editor (needs to be done with a delay). bpy.app.timers.register(self.delayed_setup_review_workspace, first_interval=1) return {"FINISHED"} class RR_OT_sqe_inspect_exr_sequence(bpy.types.Operator): bl_idname = "rr.sqe_inspect_exr_sequence" bl_label = "Inspect EXR" bl_description = "Loads EXR sequence for selected sequence strip in image editor, if it exists" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: active_strip = context.scene.sequence_editor.active_strip image_editor = opsdata.get_image_editor(context) if not active_strip: cls.poll_message_set("No sequence strip selected") return False if not active_strip.rr.is_render: cls.poll_message_set("Selected sequence strip is not an imported render") return False if not image_editor: cls.poll_message_set("No image editor open in the current workspace") return False output_dir = opsdata.get_strip_folder(active_strip) # Find exr sequence. for f in output_dir.iterdir(): if f.is_file() and f.suffix == ".exr": return True cls.poll_message_set("Selected strip is not EXR sequence") return False def execute(self, context: bpy.types.Context) -> Set[str]: active_strip = context.scene.sequence_editor.active_strip image_editor = opsdata.get_image_editor(context) output_dir = opsdata.get_strip_folder(active_strip) # Find exr sequence. exr_seq = [f for f in output_dir.iterdir() if f.is_file() and f.suffix == ".exr"] exr_seq.sort(key=lambda p: p.name) exr_seq_frame_start = int(exr_seq[0].stem) offset = exr_seq_frame_start - active_strip.frame_final_start # Remove all images with same filepath that are already loaded. img_to_rm: bpy.types.Image = [] for img in bpy.data.images: if Path(bpy.path.abspath(img.filepath)) == exr_seq[0]: img_to_rm.append(img) for img in img_to_rm: bpy.data.images.remove(img) if bpy.app.version_string.split('.')[0] == '3': color_space_name = "Linear" else: color_space_name = "Linear Rec.709" # Create new image datablock. image = bpy.data.images.load(exr_seq[0].as_posix(), check_existing=True) image.name = exr_seq[0].parent.name + "_RENDER" image.source = "SEQUENCE" image.colorspace_settings.name = color_space_name # Set active image. image_editor.spaces.active.image = image image_editor.spaces.active.image_user.frame_duration = 5000 image_editor.spaces.active.image_user.frame_offset = offset return {"FINISHED"} class RR_OT_sqe_clear_exr_inspect(bpy.types.Operator): bl_idname = "rr.sqe_clear_exr_inspect" bl_label = "Clear EXR Inspect" bl_description = "Removes the active image from the image editor" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: image_editor = cls._get_image_editor(context) if not image_editor: cls.poll_message_set("No image editor open in the current workspace") return False if not image_editor.spaces.active.image: cls.poll_message_set("No image to clear from image editor") return False return True def execute(self, context: bpy.types.Context) -> Set[str]: image_editor = self._get_image_editor(context) image_editor.spaces.active.image = None return {"FINISHED"} @classmethod def _get_image_editor(self, context: bpy.types.Context) -> Optional[bpy.types.Area]: image_editor = None for area in bpy.context.screen.areas: if area.type == "IMAGE_EDITOR": image_editor = area return image_editor class RR_OT_sqe_approve_render(bpy.types.Operator): bl_idname = "rr.sqe_approve_render" bl_label = "Push To Edit & Approve Render" bl_description = ( "Copies the selected strip render from the farm_output to the shot_frames directory. " "Existing render in shot_frames will be renamed for extra backup" ) bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: active_strip = context.scene.sequence_editor.active_strip addon_prefs = prefs.addon_prefs_get(bpy.context) if not addon_prefs.shot_playblast_root_dir: cls.poll_message_set("Playblast directory not set") return False if not active_strip: cls.poll_message_set("No sequence strip selected") return False if not active_strip.rr.is_render: cls.poll_message_set("Selected sequence strip is not an imported render") return False if active_strip.rr.is_approved: cls.poll_message_set("Selected sequence strip is already approved") return False return True def execute(self, context: bpy.types.Context) -> Set[str]: active_strip = context.scene.sequence_editor.active_strip if not active_strip.rr.is_pushed_to_edit: bpy.ops.rr.sqe_push_to_edit() strip_dir = opsdata.get_strip_folder(active_strip) frames_root_dir = opsdata.get_frames_root_dir(active_strip) shot_frames_backup_path = opsdata.get_shot_frames_backup_path(active_strip) metadata_path = opsdata.get_shot_frames_metadata_path(active_strip) # Create Shot Frames path if not exists yet. if frames_root_dir.exists(): # Delete backup if exists. if shot_frames_backup_path.exists(): shutil.rmtree(shot_frames_backup_path) # Rename current to backup. frames_root_dir.rename(shot_frames_backup_path) logger.info( "Created backup: %s > %s", frames_root_dir.name, shot_frames_backup_path.name, ) else: frames_root_dir.mkdir(parents=True) logger.info("Created dir in Shot Frames: %s", frames_root_dir.as_posix()) # Copy dir. opsdata.copytree_verbose( strip_dir, frames_root_dir, dirs_exist_ok=True, ) logger.info("Copied: %s \nTo: %s", strip_dir.as_posix(), frames_root_dir.as_posix()) # Update metadata json. if not metadata_path.exists(): metadata_path.touch() opsdata.save_to_json( {"source_current": strip_dir.as_posix(), "source_backup": ""}, metadata_path, ) logger.info("Created metadata.json: %s", metadata_path.as_posix()) else: json_dict = opsdata.load_json(metadata_path) # Source backup will get value from old source current. json_dict["source_backup"] = json_dict["source_current"] # Source current will get value from strip dir. json_dict["source_current"] = strip_dir.as_posix() opsdata.save_to_json(json_dict, metadata_path) # Scan for approved renders. opsdata.update_sequence_statuses(context) util.redraw_ui() # Log. self.report({"INFO"}, f"Updated {frames_root_dir.name} in Shot Frames") logger.info("Updated metadata in: %s", metadata_path.as_posix()) return {"FINISHED"} def invoke(self, context, event): active_strip = context.scene.sequence_editor.active_strip frames_root_dir = opsdata.get_frames_root_dir(active_strip) width = 200 + len(frames_root_dir.as_posix()) * 5 return context.window_manager.invoke_props_dialog(self, width=width) def draw(self, context: bpy.types.Context) -> None: layout = self.layout active_strip = context.scene.sequence_editor.active_strip strip_dir = opsdata.get_strip_folder(active_strip) frames_root_dir = opsdata.get_frames_root_dir(active_strip) layout.separator() layout.row(align=True).label(text="From Farm Output:", icon="RENDER_ANIMATION") layout.row(align=True).label(text=strip_dir.as_posix()) layout.separator() layout.row(align=True).label(text="To Shot Frames:", icon="FILE_TICK") layout.row(align=True).label(text=frames_root_dir.as_posix()) layout.separator() layout.row(align=True).label(text="Update Shot Frames?") class RR_OT_sqe_update_sequence_statuses(bpy.types.Operator): bl_idname = "rr.update_sequence_statuses" bl_label = "Update Sequence Statuses" bl_description = ( "Scans sequence editor and updates flags for which ones are pushed " "to the edit and which one is the currently approved version " "by reading the metadata.json files" ) bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: return bool(context.scene.sequence_editor.sequences_all) def execute(self, context: bpy.types.Context) -> Set[str]: approved_strips = opsdata.update_sequence_statuses(context)[0] if approved_strips: self.report( {"INFO"}, f"Found approved {'render' if len(approved_strips) == 1 else 'renders'}: {', '.join(s.name for s in approved_strips)}", ) else: self.report({"INFO"}, "Found no approved renders") return {"FINISHED"} class RR_OT_open_path(bpy.types.Operator): """ Opens cls.filepath in explorer. Supported for win / mac / linux. """ bl_idname = "rr.open_path" bl_label = "Open Path" bl_description = "Opens filepath in system default file browser" filepath: bpy.props.StringProperty( # type: ignore name="Filepath", description="Filepath that will be opened in explorer", default="", ) def execute(self, context: bpy.types.Context) -> Set[str]: if not self.filepath: self.report({"ERROR"}, "Can't open empty path in explorer") return {"CANCELLED"} filepath = Path(self.filepath) if filepath.is_file(): filepath = filepath.parent if not filepath.exists(): filepath = self._find_latest_existing_folder(filepath) if sys.platform == "darwin": subprocess.check_call(["open", filepath.as_posix()]) elif sys.platform == "linux2" or sys.platform == "linux": subprocess.check_call(["xdg-open", filepath.as_posix()]) elif sys.platform == "win32": os.startfile(filepath.as_posix()) else: self.report({"ERROR"}, f"Can't open explorer. Unsupported platform {sys.platform}") return {"CANCELLED"} return {"FINISHED"} def _find_latest_existing_folder(self, path: Path) -> Path: if path.exists() and path.is_dir(): return path else: return self._find_latest_existing_folder(path.parent) class RR_OT_sqe_isolate_strip_exit(bpy.types.Operator): bl_idname = "rr.sqe_isolate_strip_exit" bl_label = "Exit isolate strip view" bl_description = "Exits isolate strip view and restores previous state" bl_options = {"REGISTER", "UNDO"} def execute(self, context: bpy.types.Context) -> Set[str]: for i in context.scene.rr.isolate_view: try: strip = context.scene.sequence_editor.sequences[i.name] except KeyError: logger.error("Exit isolate view: Strip does not exist %s", i.name) continue strip.mute = i.mute # Clear all items. context.scene.rr.isolate_view.clear() return {"FINISHED"} class RR_OT_sqe_isolate_strip_enter(bpy.types.Operator): bl_idname = "rr.sqe_isolate_strip_enter" bl_label = "Isolate Strip" bl_description = "Isolate all selected sequence strips, others are hidden. Previous state is saved and restorable" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context: bpy.types.Context) -> bool: active_strip = context.scene.sequence_editor.active_strip return bool(active_strip) def execute(self, context: bpy.types.Context) -> Set[str]: sequences = list(context.scene.sequence_editor.sequences_all) if context.scene.rr.isolate_view.items(): bpy.ops.rr.sqe_isolate_strip_exit() # Mute all and save state to restore later. for s in sequences: # Save this state to restore it later. item = context.scene.rr.isolate_view.add() item.name = s.name item.mute = s.mute s.mute = True # Unmute selected. for s in context.selected_sequences: s.mute = False return {"FINISHED"} class RR_OT_sqe_push_to_edit(bpy.types.Operator): """ This operator pushes the active render strip to the edit. Only .mp4 files will be pushed to edit. If the .mp4 file is not existent but the preview .jpg sequence is in the render folder. This operator creates an .mp4 with ffmpeg. The .mp4 file will be named after the flamenco naming convention, but when copied over to the Shot Previews it will be renamed and gets a version string. """ bl_idname = "rr.sqe_push_to_edit" bl_label = "Push To Edit" bl_description = ( "Copies .mp4 file of current sequence strip to the shot preview directory with " "auto version incrementation. " "Creates .mp4 with ffmpeg if not existent yet" ) @classmethod def poll(cls, context: bpy.types.Context) -> bool: addon_prefs = prefs.addon_prefs_get(context) active_strip = context.scene.sequence_editor.active_strip if not addon_prefs.shot_playblast_root_dir: cls.poll_message_set("No shot playblast root dir set") return False if not active_strip: cls.poll_message_set("No active strip") return False if not active_strip.rr.is_render: cls.poll_message_set("Selected sequence strip is not an imported render") return False if active_strip.rr.is_pushed_to_edit: cls.poll_message_set("Selected sequence strip is already pushed to edit") return False return True def execute(self, context: bpy.types.Context) -> Set[str]: active_strip = context.scene.sequence_editor.active_strip render_dir = opsdata.get_strip_folder(active_strip) shot_previews_dir = opsdata.get_shot_previews_path(active_strip) metadata_path = shot_previews_dir / "metadata.json" # -------------GET MP4 OR CREATE WITH FFMPEG --------------- # Trying to get render_mp4_path will throw error if no jpg files are available. try: mp4_path = Path(opsdata.get_farm_output_mp4_path(active_strip)) except NoImageStripAvailableException: # No jpeg files available. self.report({"ERROR"}, f"No preview files available in {render_dir.as_posix()}") return {"CANCELLED"} # If mp4 path does not exist, use ffmpeg to create preview file. if not mp4_path.exists(): preview_files = opsdata.get_best_preview_sequence(render_dir) fffmpeg_command = f"ffmpeg -start_number {int(preview_files[0].stem)} -framerate {vars.FPS} -i {render_dir.as_posix()}/%06d{preview_files[0].suffix} -c:v libx264 -preset medium -crf 23 -pix_fmt yuv420p {mp4_path.as_posix()}" logger.info("Creating .mp4 with ffmpeg") subprocess.call(fffmpeg_command, shell=True) logger.info("Created .mp4: %s", mp4_path.as_posix()) else: logger.info("Found existing .mp4 file: %s", mp4_path.as_posix()) # --------------COPY MP4 TO Shot Previews ----------------. # Create edit path if not exists yet. if not shot_previews_dir.exists(): shot_previews_dir.mkdir(parents=True) logger.info("Created dir in Shot Previews: %s", shot_previews_dir.as_posix()) # Get edit_filepath. edit_filepath = self.get_edit_filepath(active_strip) # Copy mp4 to edit filepath. shutil.copy2(mp4_path.as_posix(), edit_filepath.as_posix()) logger.info("Copied: %s \nTo: %s", mp4_path.as_posix(), edit_filepath.as_posix()) # ----------------UPDATE METADATA.JSON ------------------. # Create metadata json. if not metadata_path.exists(): metadata_path.touch() logger.info("Created metadata.json: %s", metadata_path.as_posix()) opsdata.save_to_json({}, metadata_path) # Udpate metadata json. json_obj = opsdata.load_json(metadata_path) json_obj[edit_filepath.name] = mp4_path.as_posix() opsdata.save_to_json( json_obj, metadata_path, ) logger.info("Updated metadata in: %s", metadata_path.as_posix()) # Scan for approved renders. opsdata.update_sequence_statuses(context) # Log. self.report( {"INFO"}, f"Pushed to edit: {edit_filepath.as_posix()}", ) return {"FINISHED"} def draw(self, context: bpy.types.Context) -> None: layout = self.layout active_strip = context.scene.sequence_editor.active_strip edit_filepath = self.get_edit_filepath(active_strip) try: mp4_path = Path(opsdata.get_farm_output_mp4_path(active_strip)) except NoImageStripAvailableException: layout.separator() layout.row(align=True).label(text="No preview files available", icon="ERROR") return text = "From Farm Output:" if not mp4_path.exists(): text = "From Farm Output (will be created with ffmpeg):" layout.separator() layout.row(align=True).label(text=text, icon="RENDER_ANIMATION") layout.row(align=True).label(text=mp4_path.as_posix()) layout.separator() layout.row(align=True).label(text="To Shot Previews:", icon="FILE_TICK") layout.row(align=True).label(text=edit_filepath.as_posix()) layout.separator() layout.row(align=True).label(text="Copy to Shot Previews?") def invoke(self, context, event): active_strip = context.scene.sequence_editor.active_strip try: mp4_path = Path(opsdata.get_farm_output_mp4_path(active_strip)) except NoImageStripAvailableException: width = 200 else: width = 200 + len(mp4_path.as_posix()) * 5 return context.window_manager.invoke_props_dialog(self, width=width) def get_edit_filepath(self, strip: bpy.types.Strip) -> Path: delimiter = vars.DELIMITER render_dir = opsdata.get_strip_folder(strip) shot_previews_dir = opsdata.get_shot_previews_path(strip) # Find latest edit version. existing_files: List[Path] = [] increment = "v001" if shot_previews_dir.exists(): for file in shot_previews_dir.iterdir(): if not file.is_file(): continue if not file.name.startswith(opsdata.get_shot_dot_task_type(render_dir)): continue version = util.get_version(file.name) if not version: continue if ( file.name.replace(version, "") == f"{opsdata.get_shot_dot_task_type(render_dir)}{delimiter}.mp4" ): existing_files.append(file) existing_files.sort(key=lambda f: f.name) # Get version string. if len(existing_files) > 0: latest_version = util.get_version(existing_files[-1].name) increment = "v{:03}".format(int(latest_version.replace("v", "")) + 1) # Compose edit filepath of new mp4 file. edit_filepath = ( shot_previews_dir / f"{opsdata.get_shot_dot_task_type(render_dir)}{delimiter}{increment}.mp4" ) return edit_filepath # ----------------REGISTER--------------. classes = [ RR_OT_sqe_create_review_session, RR_OT_setup_review_workspace, RR_OT_sqe_inspect_exr_sequence, RR_OT_sqe_clear_exr_inspect, RR_OT_sqe_approve_render, RR_OT_sqe_update_sequence_statuses, RR_OT_open_path, RR_OT_sqe_isolate_strip_enter, RR_OT_sqe_isolate_strip_exit, RR_OT_sqe_push_to_edit, ] addon_keymap_items = [] def register(): for cls in classes: bpy.utils.register_class(cls) # register hotkeys # does not work if blender runs in background if not bpy.app.background: global addon_keymap_items keymap = bpy.context.window_manager.keyconfigs.addon.keymaps.new(name="Window") # Isolate strip. addon_keymap_items.append( keymap.keymap_items.new("rr.sqe_isolate_strip_enter", value="PRESS", type="ONE") ) # Umute all. addon_keymap_items.append( keymap.keymap_items.new( "rr.sqe_isolate_strip_exit", value="PRESS", type="ONE", alt=True ) ) for kmi in addon_keymap_items: logger.info("Registered new hotkey: %s : %s", kmi.type, kmi.properties.bl_rna.name) def unregister(): # Does not work if blender runs in background. if not bpy.app.background: global addon_keymap_items # Remove hotkeys. keymap = bpy.context.window_manager.keyconfigs.addon.keymaps["Window"] for kmi in addon_keymap_items: logger.info("Remove hotkey: %s : %s", kmi.type, kmi.properties.bl_rna.name) keymap.keymap_items.remove(kmi) addon_keymap_items.clear() for cls in reversed(classes): bpy.utils.unregister_class(cls)