Files
blender-portable-repo/scripts/addons/blender_kitsu/render_review/ops.py
T
2026-03-17 14:58:51 -06:00

1041 lines
38 KiB
Python

# 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 '<str:width>x<str:height>'
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)