2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from ..edit import ops, ui
# ---------REGISTER ----------.
def reload():
global ops
global ui
ops = importlib.reload(ops)
ui = importlib.reload(ui)
def register():
ops.register()
ui.register()
def unregister():
ui.unregister()
ops.unregister()
+95
View File
@@ -0,0 +1,95 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .. import prefs, propsdata
import re
from pathlib import Path
def edit_export_get_latest(context: bpy.types.Context):
"""Find latest render in editorial export directory"""
files_list = edit_export_get_all(context)
if len(files_list) >= 1:
files_list = sorted(files_list, reverse=True)
return files_list[0]
return None
def edit_export_get_all(context: bpy.types.Context):
"""Find all renders in editorial export directory"""
addon_prefs = prefs.addon_prefs_get(context)
edit_export_path = propsdata.get_edit_export_dir()
if not edit_export_path.exists():
print(f"Editorial export directory '{edit_export_path}' does not exist.")
return []
files_list = [
f
for f in edit_export_path.iterdir()
if f.is_file() and edit_export_is_valid_name(addon_prefs.edit_export_file_pattern, f.name)
]
return files_list
def edit_export_is_valid_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)
# Replace `#` with `\d` to represent digits
match = re.search(file_pattern.replace('\#', '\d'), filename)
if match:
return True
return False
def edit_export_import_latest(
context: bpy.types.Context, shot
) -> list[bpy.types.Strip]: # TODO add info to shot
"""Loads latest export from editorial department"""
addon_prefs = prefs.addon_prefs_get(context)
strip_channel = 1
latest_file = 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
+355
View File
@@ -0,0 +1,355 @@
# 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)
@@ -0,0 +1,97 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, List, Tuple
from pathlib import Path
from .. import prefs, propsdata
import bpy
from ..models import FileListModel
from ..logger import LoggerFactory
EDIT_EXPORT_FILE_MODEL = FileListModel()
_edit_export_enum_list: List[Tuple[str, str, str]] = []
_edit_export_file_model_init: bool = False
logger = LoggerFactory.getLogger()
# TODO Compare to Playblast opsdata, maybe centralize some repeated code
def init_edit_export_file_model(
context: bpy.types.Context,
) -> None:
global EDIT_EXPORT_FILE_MODEL
global _edit_export_file_model_init
addon_prefs = prefs.addon_prefs_get(context)
kitsu_props = context.scene.kitsu
edit_export_dir = propsdata.get_edit_export_dir()
# Is None if invalid.
if addon_prefs.edit_export_dir == "" or not edit_export_dir.exists():
logger.error(
"Failed to initialize edit export file model. Invalid path. Check addon preferences"
)
return
EDIT_EXPORT_FILE_MODEL.filter_name = Path(kitsu_props.edit_export_file).name
EDIT_EXPORT_FILE_MODEL.reset()
EDIT_EXPORT_FILE_MODEL.root_path = edit_export_dir
if not EDIT_EXPORT_FILE_MODEL.versions:
EDIT_EXPORT_FILE_MODEL.append_item("v001")
# Update edit_export_version prop.
kitsu_props.edit_export_version = "v001"
else:
# Update edit_export_version prop.
kitsu_props.edit_export_version = EDIT_EXPORT_FILE_MODEL.versions[0]
_edit_export_file_model_init = True
def add_edit_export_version_increment(context: bpy.types.Context) -> str:
# Init model if it did not happen.
if not _edit_export_file_model_init:
init_edit_export_file_model(context)
# Should be already sorted.
versions = EDIT_EXPORT_FILE_MODEL.versions
if len(versions) > 0:
latest_version = versions[0]
increment = "v{:03}".format(int(latest_version.replace("v", "")) + 1)
else:
increment = "v001"
EDIT_EXPORT_FILE_MODEL.append_item(increment)
return increment
def get_edit_export_versions_enum_list(
self: Any,
context: bpy.types.Context,
) -> List[Tuple[str, str, str]]:
global _edit_export_enum_list
global EDIT_EXPORT_FILE_MODEL
global init_edit_export_file_model
global _edit_export_file_model_init
# Init model if it did not happen.
if not _edit_export_file_model_init:
init_edit_export_file_model(context)
# Clear all versions in enum list.
_edit_export_enum_list.clear()
_edit_export_enum_list.extend(EDIT_EXPORT_FILE_MODEL.versions_as_enum_list)
return _edit_export_enum_list
def add_version_custom(custom_version: str) -> None:
global _edit_export_enum_list
global EDIT_EXPORT_FILE_MODEL
EDIT_EXPORT_FILE_MODEL.append_item(custom_version)
+116
View File
@@ -0,0 +1,116 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .. import prefs, ui
from ..context import core as context_core
from pathlib import Path
from .ops import (
KITSU_OT_edit_export_set_version,
KITSU_OT_edit_export_increment_version,
KITSU_OT_edit_export_publish,
KITSU_OT_edit_export_import_latest,
)
from ..generic.ops import KITSU_OT_open_path
class KITSU_PT_edit_export_publish(bpy.types.Panel):
"""
Panel in sequence editor that exposes a set of tools that are used to export latest edit
"""
bl_category = "Kitsu"
bl_label = "Export & Publish"
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:
return bool(prefs.session_auth(context) and context_core.is_edit_context())
def draw(self, context: bpy.types.Context) -> None:
addon_prefs = prefs.addon_prefs_get(context)
layout = self.layout
split_factor_small = 0.95
# # ERROR.
if not addon_prefs.is_edit_export_root_valid:
box = ui.draw_error_box(layout)
ui.draw_error_invalid_edit_export_root_dir(box)
# Edit Export version op.
row = layout.row(align=True)
row.operator(
KITSU_OT_edit_export_set_version.bl_idname,
text=context.scene.kitsu.edit_export_version,
icon="DOWNARROW_HLT",
)
# Edit Export increment version op.
row.operator(
KITSU_OT_edit_export_increment_version.bl_idname,
text="",
icon="ADD",
)
# Edit Export op.
row = layout.row(align=True)
row.operator(KITSU_OT_edit_export_publish.bl_idname, icon="RENDER_ANIMATION")
# Edit Export path label.
if Path(context.scene.kitsu.edit_export_file).exists():
split = layout.split(factor=1 - split_factor_small, align=True)
split.label(icon="ERROR")
sub_split = split.split(factor=split_factor_small)
sub_split.label(text=context.scene.kitsu.edit_export_file)
sub_split.operator(
KITSU_OT_open_path.bl_idname, icon="FILE_FOLDER", text=""
).filepath = context.scene.kitsu.edit_export_file
else:
row = layout.row(align=True)
row.label(text=context.scene.kitsu.edit_export_file)
row.operator(KITSU_OT_open_path.bl_idname, icon="FILE_FOLDER", text="").filepath = (
context.scene.kitsu.edit_export_file
)
class KITSU_PT_edit_export_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_export_import_latest.bl_idname)
classes = [KITSU_PT_edit_export_publish, KITSU_PT_edit_export_tools]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)