Blender Kitsu: Add Import Edit Render Operator for Shots #236

Merged
Nick Alberelli merged 6 commits from TinyNick/blender-studio-pipeline:feature/import_edit_export into main 2024-02-19 21:02:44 +01:00
9 changed files with 280 additions and 121 deletions

View File

@ -4,25 +4,32 @@ import re
from pathlib import Path
def edit_export_get_latest(context: bpy.types.Context):
"""Find latest export in editorial export directory"""
addon_prefs = prefs.addon_prefs_get(context)
def edit_render_get_latest(context: bpy.types.Context):
"""Find latest render in editorial render directory"""
edit_export_path = Path(addon_prefs.edit_export_dir)
files_list = [
f
for f in edit_export_path.iterdir()
if f.is_file()
and edit_export_is_valid_edit_name(addon_prefs.edit_export_file_pattern, f.name)
]
files_list = edit_renders_get_all(context)
if len(files_list) >= 1:
files_list = sorted(files_list, reverse=True)
return files_list[0]
return None
def edit_export_is_valid_edit_name(file_pattern: str, filename: str) -> bool:
def edit_renders_get_all(context: bpy.types.Context):
"""Find latest render in editorial render directory"""
addon_prefs = prefs.addon_prefs_get(context)
edit_render_path = Path(addon_prefs.edit_render_dir)
files_list = [
f
for f in edit_render_path.iterdir()
if f.is_file()
and edit_render_is_valid_edit_name(addon_prefs.edit_render_file_pattern, f.name)
]
return files_list
def edit_render_is_valid_edit_name(file_pattern: str, filename: str) -> bool:
"""Verify file name matches file pattern set in preferences"""
# Prevents un-expected matches
file_pattern = re.escape(file_pattern)
@ -31,3 +38,51 @@ def edit_export_is_valid_edit_name(file_pattern: str, filename: str) -> bool:
if match:
return True
return False
def edit_render_import_latest(
context: bpy.types.Context, shot
) -> list[bpy.types.Sequence]: # TODO add info to shot
"""Loads latest render from editorial department"""
addon_prefs = prefs.addon_prefs_get(context)
strip_channel = 1
latest_file = edit_render_get_latest(context)
if not latest_file:
return None
# Check if Kitsu server returned empty shot
if shot.id == '':
return None
strip_filepath = latest_file.as_posix()
strip_frame_start = addon_prefs.shot_builder_frame_offset
scene = context.scene
if not scene.sequence_editor:
scene.sequence_editor_create()
seq_editor = scene.sequence_editor
movie_strip = seq_editor.sequences.new_movie(
latest_file.name,
strip_filepath,
strip_channel + 1,
strip_frame_start,
fit_method="ORIGINAL",
)
sound_strip = seq_editor.sequences.new_sound(
latest_file.name,
strip_filepath,
strip_channel,
strip_frame_start,
)
new_strips = [movie_strip, sound_strip]
# Update shift frame range prop.
frame_in = shot.data.get("frame_in")
frame_3d_start = shot.get_3d_start()
frame_3d_offset = frame_3d_start - addon_prefs.shot_builder_frame_offset
edit_export_offset = addon_prefs.edit_render_frame_offset
# Set sequence strip start kitsu data.
for strip in new_strips:
strip.frame_start = (
-frame_in + (strip_frame_start * 2) + frame_3d_offset + edit_export_offset
)
return new_strips

View File

@ -1,12 +1,14 @@
import bpy
from bpy.types import Sequence, Context
import os
from typing import Set
from typing import Set, List
from pathlib import Path
from .. import cache, prefs, util
from ..types import Task, TaskStatus
from ..playblast.core import override_render_path, override_render_format
from . import opsdata
from ..logger import LoggerFactory
from .core import edit_render_import_latest, edit_renders_get_all, edit_render_get_latest
logger = LoggerFactory.getLogger()
@ -17,7 +19,7 @@ class KITSU_OT_edit_render_publish(bpy.types.Operator):
bl_description = (
"Renders current VSE Edit as .mp4"
"Saves the set version to disk and uploads it to Kitsu with the specified "
"comment and task type. Overrides some render settings during export. "
"comment and task type. Overrides some render settings during render. "
)
task_status: bpy.props.EnumProperty(name="Task Status", items=cache.get_all_task_statuses_enum)
@ -52,7 +54,7 @@ class KITSU_OT_edit_render_publish(bpy.types.Operator):
cls.poll_message_set("Edit Render Directory is Invalid, see Add-On preferences")
return False
if not addon_prefs.is_edit_render_pattern_valid:
cls.poll_message_set("Edit Export File Pattern is Invalid, see Add-On preferences")
cls.poll_message_set("Edit Render File Pattern is Invalid, see Add-On preferences")
return False
return True
@ -146,7 +148,7 @@ class KITSU_OT_edit_render_set_version(bpy.types.Operator):
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
addon_prefs = prefs.addon_prefs_get(context)
return bool(addon_prefs.edit_export_dir != "")
return bool(addon_prefs.edit_render_dir != "")
def execute(self, context: bpy.types.Context) -> Set[str]:
kitsu_props = context.scene.kitsu
@ -195,10 +197,139 @@ class KITSU_OT_edit_render_increment_version(bpy.types.Operator):
return {"FINISHED"}
class KITSU_OT_edit_render_import_latest(bpy.types.Operator):
bl_idname = "kitsu.edit_render_import_latest"
bl_label = "Import Latest Edit Render"
bl_description = (
"Find and import the latest editorial render found in the Editorial Render Directory for the current shot. "
"Will only Import if the latest render is not already imported. "
"Will remove any previous renders currently in the file's Video Sequence Editor"
)
_existing_edit_renders = []
_removed_movie = 0
_removed_audio = 0
_latest_render_name = ""
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not prefs.session_auth(context):
cls.poll_message_set("Login to a Kitsu Server")
return False
if not cache.project_active_get():
cls.poll_message_set("Select an active project")
return False
if cache.shot_active_get().id == "":
cls.poll_message_set("Please set an active shot in Kitsu Context UI")
return False
if not prefs.addon_prefs_get(context).is_edit_render_root_valid:
cls.poll_message_set("Edit Render Directory is Invalid, see Add-On Preferences")
return False
return True
def get_filepath(self, strip):
if hasattr(strip, "filepath"):
return strip.filepath
if hasattr(strip, "sound"):
return strip.sound.filepath
def compare_strip_to_path(self, strip: Sequence, compare_path: Path) -> bool:
strip_path = Path(bpy.path.abspath(self.get_filepath(strip)))
return bool(compare_path.absolute() == strip_path.absolute())
def compare_strip_to_paths(self, strip: Sequence, compare_paths: List[Path]) -> bool:
for compare_path in compare_paths:
if self.compare_strip_to_path(strip, compare_path):
return True
return False
def get_existing_edit_renders(
self, context: Context, all_edit_render_paths: List[Path]
) -> List[Sequence]:
sequences = context.scene.sequence_editor.sequences
# Collect Existing Edit Renders
for strip in sequences:
if self.compare_strip_to_paths(strip, all_edit_render_paths):
self._existing_edit_renders.append(strip)
return self._existing_edit_renders
def check_if_latest_edit_render_is_imported(self, context: Context) -> bool:
# Check if latest edit render is already loaded.
for strip in self._existing_edit_renders:
latest_edit_render_path = edit_render_get_latest(context)
if self.compare_strip_to_path(strip, latest_edit_render_path):
self._latest_render_name = latest_edit_render_path.name
return True
def remove_existing_edit_renders(self, context: Context) -> None:
# Remove Existing Strips to make way for new Strip
sequences = context.scene.sequence_editor.sequences
for strip in self._existing_edit_renders:
if strip.type == "MOVIE":
self._removed_movie += 1
if strip.type == "SOUND":
self._removed_audio += 1
sequences.remove(strip)
def execute(self, context: bpy.types.Context) -> Set[str]:
# Reset Values
self._existing_edit_renders = []
self._removed_movie = 0
self._removed_audio = 0
self._latest_render_name = ""
addon_prefs = prefs.addon_prefs_get(context)
# Get paths to all edit renders
all_edit_render_paths = edit_renders_get_all(context)
if all_edit_render_paths == []:
self.report(
{"WARNING"},
f"No Edit Renders found in '{addon_prefs.edit_render_dir}' using pattern '{addon_prefs.edit_render_file_pattern}' See Add-On Preferences",
)
return {"CANCELLED"}
# Collect all existing edit renders
self.get_existing_edit_renders(context, all_edit_render_paths)
# Stop latest render is already imported
if self.check_if_latest_edit_render_is_imported(context):
self.report(
{"WARNING"},
f"Latest Editorial Render already loaded '{self._latest_render_name}'",
)
return {"CANCELLED"}
# Remove old edit renders
self.remove_existing_edit_renders(context)
# Import new edit render
shot = cache.shot_active_get()
strips = edit_render_import_latest(context, shot)
if strips is None:
self.report({"WARNING"}, f"Loaded Latest Editorial Render failed to import!")
return {"CANCELLED"}
# Report.
if self._removed_movie > 0 or self._removed_audio > 0:
removed_msg = (
f"Removed {self._removed_movie} Movie Strips and {self._removed_audio} Audio Strips"
)
self.report(
{"INFO"}, f"Loaded Latest Editorial Render, '{strips[0].name}'. {removed_msg}"
)
else:
self.report({"INFO"}, f"Loaded Latest Editorial Render, '{strips[0].name}'")
return {"FINISHED"}
classes = [
KITSU_OT_edit_render_publish,
KITSU_OT_edit_render_set_version,
KITSU_OT_edit_render_increment_version,
KITSU_OT_edit_render_import_latest,
]

View File

@ -43,17 +43,17 @@ def init_edit_render_file_model(
addon_prefs = prefs.addon_prefs_get(context)
kitsu_props = context.scene.kitsu
edit_export_dir = Path(addon_prefs.edit_export_dir)
edit_render_dir = Path(addon_prefs.edit_render_dir)
# Is None if invalid.
if addon_prefs.edit_export_dir == "" or not edit_export_dir.exists():
if addon_prefs.edit_render_dir == "" or not edit_render_dir.exists():
logger.error(
"Failed to initialize edit render file model. Invalid path. Check addon preferences"
)
return
EDIT_RENDER_FILE_MODEL.reset()
EDIT_RENDER_FILE_MODEL.root_path = edit_export_dir
EDIT_RENDER_FILE_MODEL.root_path = edit_render_dir
if not EDIT_RENDER_FILE_MODEL.versions:
EDIT_RENDER_FILE_MODEL.append_item("v001")

View File

@ -6,6 +6,7 @@ from .ops import (
KITSU_OT_edit_render_set_version,
KITSU_OT_edit_render_increment_version,
KITSU_OT_edit_render_publish,
KITSU_OT_edit_render_import_latest,
)
from ..generic.ops import KITSU_OT_open_path
@ -71,9 +72,34 @@ class KITSU_PT_edit_render_publish(bpy.types.Panel):
)
classes = [
KITSU_PT_edit_render_publish,
]
class KITSU_PT_edit_render_tools(bpy.types.Panel):
"""
Panel in sequence editor that exposes a set of tools that are used to load the latest edit
"""
bl_category = "Kitsu"
bl_label = "General Tools"
bl_space_type = "SEQUENCE_EDITOR"
bl_region_type = "UI"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 50
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not prefs.session_auth(context):
return False
if not (context_core.is_sequence_context() or context_core.is_shot_context()):
return False
return True
def draw(self, context: bpy.types.Context) -> None:
box = self.layout.box()
box.label(text="General", icon="MODIFIER")
box.operator(KITSU_OT_edit_render_import_latest.bl_idname)
classes = [KITSU_PT_edit_render_publish, KITSU_PT_edit_render_tools]
def register():

View File

@ -121,6 +121,12 @@ class KITSU_PT_vi3d_playblast(bpy.types.Panel):
class KITSU_PT_seq_playblast(KITSU_PT_vi3d_playblast):
bl_space_type = "SEQUENCE_EDITOR"
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not context_core.is_sequence_context():
return False
return bool(prefs.session_auth(context))
classes = (KITSU_PT_seq_playblast, KITSU_PT_vi3d_playblast)

View File

@ -41,7 +41,7 @@ from .auth.ops import (
)
from .context.ops import KITSU_OT_con_productions_load
from .lookdev.prefs import LOOKDEV_preferences
from .edit.core import edit_export_get_latest
from .edit.core import edit_render_get_latest
logger = LoggerFactory.getLogger()
@ -358,57 +358,60 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences):
subtype='DIR_PATH',
)
def set_edit_export_dir(self, input):
self['edit_export_dir'] = input
def set_edit_render_dir(self, input):
self['edit_render_dir'] = input
return
def get_edit_export_dir(
def get_edit_render_dir(
self,
) -> str:
if get_safely_string_prop(self, 'edit_export_dir') == "" and self.project_root_path:
if get_safely_string_prop(self, 'edit_render_dir') == "" and self.project_root_path:
dir = self.project_root_path.joinpath("shared/editorial/export/")
if dir.exists():
return dir.as_posix()
return get_safely_string_prop(self, 'edit_export_dir')
return get_safely_string_prop(self, 'edit_render_dir')
edit_export_dir: bpy.props.StringProperty( # type: ignore
name="Editorial Export Directory",
edit_render_dir: bpy.props.StringProperty( # type: ignore
name="Render Directory",
options={"HIDDEN", "SKIP_SAVE"},
description="Directory path to editorial's export folder containing storyboard/animatic exports. Path should be similar to '~/shared-{proj_name}/editorial/export/'",
description="Directory path to editorial's render folder containing storyboard/animatic renders. Path should be similar to '~/shared-{proj_name}/editorial/export/'",
subtype="DIR_PATH",
get=get_edit_export_dir,
set=set_edit_export_dir,
get=get_edit_render_dir,
set=set_edit_render_dir,
)
def set_edit_export_file_pattern(self, input):
self['edit_export_file_pattern'] = input
def set_edit_render_file_pattern(self, input):
self['edit_render_file_pattern'] = input
return
def get_edit_export_file_pattern(
def get_edit_render_file_pattern(
self,
) -> str:
active_project = cache.project_active_get()
if get_safely_string_prop(self, 'edit_export_file_pattern') == "" and active_project:
if get_safely_string_prop(self, 'edit_render_file_pattern') == "" and active_project:
proj_name = active_project.name.replace(' ', bkglobals.SPACE_REPLACER).lower()
# HACK for Project Gold at Blender Studio
if proj_name == "project_gold":
return f"gold-edit-v###.mp4"
return f"{proj_name}-edit-v###.mp4"
return get_safely_string_prop(self, 'edit_export_file_pattern')
return get_safely_string_prop(self, 'edit_render_file_pattern')
edit_export_file_pattern: bpy.props.StringProperty( # type: ignore
name="Editorial Export File Pattern",
edit_render_file_pattern: bpy.props.StringProperty( # type: ignore
name="Render File Pattern",
options={"HIDDEN", "SKIP_SAVE"},
description=(
"File pattern for latest editorial export file. "
"File pattern for latest editorial render file. "
"Typically '{proj_name}-edit-v###.mp4' where # represents a number. "
"Pattern must contain exactly v### representing the version, pattern must end in .mp4"
),
default="",
get=get_edit_export_file_pattern,
set=set_edit_export_file_pattern,
get=get_edit_render_file_pattern,
set=set_edit_render_file_pattern,
)
edit_export_frame_offset: bpy.props.IntProperty( # type: ignore
name="Editorial Export Offset",
description="Shift Editorial Export by this frame-range after set-up.",
edit_render_frame_offset: bpy.props.IntProperty( # type: ignore
name="Render Offset",
description="Shift Editorial Render by this frame-range after import",
default=-101, # HARD CODED FOR GOLD PROJECTS BLENDER FILM
)
@ -487,11 +490,11 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences):
# Editorial Settings
box = col.box()
box.label(text="Video Sequence Editor", icon="SEQ_SEQUENCER")
box.row().prop(self, "edit_export_dir")
box.label(text="Editorial", icon="SEQ_SEQUENCER")
box.row().prop(self, "edit_render_dir")
file_pattern_row = box.row(align=True)
file_pattern_row.alert = not self.is_edit_render_pattern_valid
file_pattern_row.prop(self, "edit_export_file_pattern")
file_pattern_row.prop(self, "edit_render_file_pattern")
# Lookdev tools settings.
self.lookdev.draw(context, col)
@ -524,7 +527,7 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences):
# Shot_Builder settings.
box = col.box()
box.label(text="Shot Builder", icon="MOD_BUILD")
box.row().prop(self, "edit_export_frame_offset")
box.row().prop(self, "edit_render_frame_offset")
box.row().prop(self, "shot_builder_show_advanced")
if self.shot_builder_show_advanced:
start_frame_row = box.row()
@ -571,17 +574,17 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences):
@property
def is_edit_render_root_valid(self) -> bool:
if self.edit_export_dir.strip() == "":
if self.edit_render_dir.strip() == "":
return False
if not Path(self.edit_export_dir).exists():
if not Path(self.edit_render_dir).exists():
return False
return True
@property
def is_edit_render_pattern_valid(self) -> bool:
if not self.edit_export_file_pattern.endswith(".mp4"):
if not self.edit_render_file_pattern.endswith(".mp4"):
return False
if not "###" in self.edit_export_file_pattern:
if not "###" in self.edit_render_file_pattern:
return False
return True
@ -611,15 +614,6 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences):
return True
@property
def is_editorial_dir_valid(self) -> bool:
if edit_export_get_latest(bpy.context) is None:
logger.error(
"Failed to initialize editorial export file model. Invalid path/pattern. Check addon preferences"
)
return False
return True
def session_get(context: bpy.types.Context) -> Session:
"""

View File

@ -170,12 +170,12 @@ def get_playblast_file(self: Any) -> str:
def get_edit_render_file(self: Any) -> str:
addon_prefs = prefs.addon_prefs_get(bpy.context)
if not bool(addon_prefs.edit_export_dir):
if not bool(addon_prefs.edit_render_dir):
return ""
version = self.edit_render_version
file_pattern = addon_prefs.edit_export_file_pattern
file_pattern = addon_prefs.edit_render_file_pattern
file_name = file_pattern.replace('v###', version)
return Path(addon_prefs.edit_export_dir).joinpath(file_name).as_posix()
return Path(addon_prefs.edit_render_dir).joinpath(file_name).as_posix()
_active_category_cache_init: bool = False

View File

@ -1,53 +0,0 @@
import bpy
from .. import prefs
from pathlib import Path
import re
from ..edit import core as edit_core
def edit_export_import_latest(
context: bpy.types.Context, shot
) -> list[bpy.types.Sequence]: # TODO add info to shot
"""Loads latest export from editorial department"""
addon_prefs = prefs.addon_prefs_get(context)
strip_channel = 1
latest_file = edit_core.edit_export_get_latest(context)
if not latest_file:
return None
# Check if Kitsu server returned empty shot
if shot.id == '':
return None
strip_filepath = latest_file.as_posix()
strip_frame_start = addon_prefs.shot_builder_frame_offset
scene = context.scene
if not scene.sequence_editor:
scene.sequence_editor_create()
seq_editor = scene.sequence_editor
movie_strip = seq_editor.sequences.new_movie(
latest_file.name,
strip_filepath,
strip_channel + 1,
strip_frame_start,
fit_method="ORIGINAL",
)
sound_strip = seq_editor.sequences.new_sound(
latest_file.name,
strip_filepath,
strip_channel,
strip_frame_start,
)
new_strips = [movie_strip, sound_strip]
# Update shift frame range prop.
frame_in = shot.data.get("frame_in")
frame_3d_start = shot.get_3d_start()
frame_3d_offset = frame_3d_start - addon_prefs.shot_builder_frame_offset
edit_export_offset = addon_prefs.edit_export_frame_offset
# Set sequence strip start kitsu data.
for strip in new_strips:
strip.frame_start = (
-frame_in + (strip_frame_start * 2) + frame_3d_offset + edit_export_offset
)
return new_strips

View File

@ -16,7 +16,7 @@ from .core import (
)
from ..context import core as context_core
from .editorial import edit_export_import_latest
from ..edit.core import edit_render_import_latest
from .file_save import save_shot_builder_file
from .template import replace_workspace_with_template
from .assets import get_shot_assets
@ -236,7 +236,7 @@ class KITSU_OT_build_new_shot(bpy.types.Operator):
link_task_type_output_collections(shot, task_type)
if bkglobals.LOAD_EDITORIAL_REF.get(task_type_short_name):
edit_export_import_latest(context, shot)
edit_render_import_latest(context, shot)
# Run Hooks
hooks_instance = Hooks()