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

356 lines
13 KiB
Python

# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Strip, Context
import os
from typing import Set, List
from pathlib import Path
from .. import cache, prefs, util, propsdata
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_export_import_latest, edit_export_get_all, edit_export_get_latest
logger = LoggerFactory.getLogger()
class KITSU_OT_edit_export_publish(bpy.types.Operator):
bl_idname = "kitsu.edit_export_publish"
bl_label = "Export & Publish"
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 render. "
)
task_status: bpy.props.EnumProperty(name="Task Status", items=cache.get_all_task_statuses_enum)
comment: bpy.props.StringProperty(name="Comment")
use_frame_start: bpy.props.BoolProperty(name="Submit update to 'frame_start'.", default=False)
frame_start: bpy.props.IntProperty(
name="Frame Start",
description="Send an integer for the 'frame_start' value of the current Kitsu Edit. \nThis is used by Watchtower to pad the edit in the timeline.",
default=0,
)
thumbnail_frame: bpy.props.IntProperty(
name="Thumbnail Frame",
description="Frame to use as the thumbnail on Kitsu",
min=0,
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
kitsu_props = context.scene.kitsu
addon_prefs = prefs.addon_prefs_get(context)
if not prefs.session_auth(context):
cls.poll_message_set("Login to a Kitsu Server")
if not cache.project_active_get():
cls.poll_message_set("Select an active project")
return False
if kitsu_props.edit_active_id == "" or cache.edit_active_get().id == "":
cls.poll_message_set("Select an edit entity from Kitsu Context Menu")
return False
if kitsu_props.task_type_active_id == "":
cls.poll_message_set("Select a task type from Kitsu Context Menu")
return False
if not addon_prefs.is_edit_export_root_valid:
cls.poll_message_set("Edit Export Directory is Invalid, see Add-On preferences")
return False
if not addon_prefs.is_edit_export_pattern_valid:
cls.poll_message_set("Edit Export File Pattern is Invalid, see Add-On preferences")
return False
return True
def invoke(self, context, event):
self.thumbnail_frame = context.scene.frame_current
# Remove file name if set in render filepath
dir_path = bpy.path.abspath(context.scene.render.filepath)
if not os.path.isdir(Path(dir_path)):
dir_path = Path(dir_path).parent
self.render_dir = str(dir_path)
# 'frame_start' is optionally property appearing on all edit_entries for a project if it exists (used in watchtower)
server_frame_start = cache.edit_active_get().get_frame_start()
if server_frame_start is int:
self.frame_start = server_frame_start
self.use_frame_start = bool(server_frame_start is not None)
return context.window_manager.invoke_props_dialog(self)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
layout.prop(self, "comment")
layout.prop(self, 'task_status')
# Only set `frame_start` if exists on current project
if self.use_frame_start:
layout.prop(self, "frame_start")
layout.prop(self, "thumbnail_frame")
def execute(self, context: bpy.types.Context) -> Set[str]:
edit_entity = cache.edit_active_get()
task_type_entity = cache.task_type_active_get()
kitsu_props = context.scene.kitsu
# Ensure thumbnail frame is not outside of frame range
if self.thumbnail_frame not in range(
0, (context.scene.frame_end - context.scene.frame_start) + 1
):
self.report(
{"ERROR"},
f"Thumbnail frame '{self.thumbnail_frame}' is outside of frame range ",
)
return {"CANCELLED"}
# Build render_path
render_path = Path(kitsu_props.edit_export_file)
render_path_str = render_path.as_posix()
render_name = render_path.name
render_path.parent.mkdir(parents=True, exist_ok=True)
if not render_path.parent.exists():
self.report({"ERROR"}, f"Render path is not set to a directory. '{self.render_dir}'")
return {"CANCELLED"}
# Render Sequence to .mp4
with override_render_path(self, context, render_path_str):
with override_render_format(self, context, enable_sequencer=True):
bpy.ops.render.opengl(animation=True, sequencer=True)
# Create comment with video
task_status = TaskStatus.by_id(self.task_status)
task = Task.by_name(edit_entity, task_type_entity)
comment = task.add_comment(
task_status,
comment=self.comment,
)
task.add_preview_to_comment(
comment,
render_path_str,
self.thumbnail_frame,
)
# Update edit_entry's frame_start if 'frame_start' is found on server
if self.use_frame_start:
edit_entity.set_frame_start(self.frame_start)
self.report({"INFO"}, f"Submitted new comment 'Revision {kitsu_props.edit_export_version}'")
return {"FINISHED"}
class KITSU_OT_edit_export_set_version(bpy.types.Operator):
bl_idname = "kitsu.edit_export_set_version"
bl_label = "Version"
bl_property = "versions"
bl_description = (
"Sets version of edit export. Warning triangle in ui "
"indicates that version already exists on disk"
)
versions: bpy.props.EnumProperty(
items=opsdata.get_edit_export_versions_enum_list, name="Versions"
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
addon_prefs = prefs.addon_prefs_get(context)
if addon_prefs.edit_export_dir == "":
cls.poll_message_set("Edit Export Directory is not set, check add-on preferences")
return False
if not addon_prefs.is_edit_export_root_valid:
cls.poll_message_set("Edit Export Directory is Invalid, see Add-On preferences")
return False
return True
def execute(self, context: bpy.types.Context) -> Set[str]:
kitsu_props = context.scene.kitsu
version = self.versions
if not version:
return {"CANCELLED"}
if kitsu_props.get('edit_export_version') == version:
return {"CANCELLED"}
# Update global scene cache version prop.
kitsu_props.edit_export_version = version
logger.info("Set edit export version to %s", version)
# Redraw ui.
util.ui_redraw()
return {"FINISHED"}
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
context.window_manager.invoke_search_popup(self) # type: ignore
return {"FINISHED"}
class KITSU_OT_edit_export_increment_version(bpy.types.Operator):
bl_idname = "kitsu.edit_export_increment_version"
bl_label = "Add Version Increment"
bl_description = "Increment the edit export version by one"
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return True
def execute(self, context: bpy.types.Context) -> Set[str]:
# Incremenet version.
version = opsdata.add_edit_export_version_increment(context)
# Update cache_version prop.
context.scene.kitsu.edit_export_version = version
# Report.
self.report({"INFO"}, f"Add edit export version {version}")
util.ui_redraw()
return {"FINISHED"}
class KITSU_OT_edit_export_import_latest(bpy.types.Operator):
bl_idname = "kitsu.edit_export_import_latest"
bl_label = "Import Latest Edit Export"
bl_description = (
"Find and import the latest editorial render found in the Editorial Export Directory for the current shot. "
"Will only Import if the latest export is not already imported. "
"Will remove any previous exports currently in the file's Video Sequence Editor"
)
_existing_edit_exports = []
_removed_movie = 0
_removed_audio = 0
_latest_export_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_export_root_valid:
cls.poll_message_set("Edit Export 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: Strip, 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: Strip, 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_exports(
self, context: Context, all_edit_export_paths: List[Path]
) -> List[Strip]:
sequences = context.scene.sequence_editor.sequences
# Collect Existing Edit Export
for strip in sequences:
if self.compare_strip_to_paths(strip, all_edit_export_paths):
self._existing_edit_exports.append(strip)
return self._existing_edit_exports
def check_if_latest_edit_export_is_imported(self, context: Context) -> bool:
# Check if latest edit export is already loaded.
for strip in self._existing_edit_exports:
latest_edit_export_path = edit_export_get_latest(context)
if self.compare_strip_to_path(strip, latest_edit_export_path):
self._latest_export_name = latest_edit_export_path.name
return True
def remove_existing_edit_exports(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_exports:
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_exports = []
self._removed_movie = 0
self._removed_audio = 0
self._latest_export_name = ""
addon_prefs = prefs.addon_prefs_get(context)
# Get paths to all edit exports
all_edit_export_paths = edit_export_get_all(context)
if all_edit_export_paths == []:
self.report(
{"WARNING"},
f"No Edit Exports found in '{addon_prefs.edit_export_dir}' using pattern '{addon_prefs.edit_export_file_pattern}' See Add-On Preferences",
)
return {"CANCELLED"}
# Collect all existing edit exports
self.get_existing_edit_exports(context, all_edit_export_paths)
# Stop latest export is already imported
if self.check_if_latest_edit_export_is_imported(context):
self.report(
{"WARNING"},
f"Latest Editorial Export already loaded '{self._latest_export_name}'",
)
return {"CANCELLED"}
# Remove old edit exports
self.remove_existing_edit_exports(context)
# Import new edit export
shot = cache.shot_active_get()
strips = edit_export_import_latest(context, shot)
if strips is None:
self.report({"WARNING"}, f"Loaded Latest Editorial Export 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 Export, '{strips[0].name}'. {removed_msg}"
)
else:
self.report({"INFO"}, f"Loaded Latest Editorial Export, '{strips[0].name}'")
return {"FINISHED"}
classes = [
KITSU_OT_edit_export_publish,
KITSU_OT_edit_export_set_version,
KITSU_OT_edit_export_increment_version,
KITSU_OT_edit_export_import_latest,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)