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

681 lines
24 KiB
Python

# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import webbrowser
from pathlib import Path
from typing import Dict, List, Set, Optional, Tuple, Any
import time
import bpy
from bpy.app.handlers import persistent
from .. import bkglobals
from .. import (
cache,
util,
prefs,
bkglobals,
)
from ..logger import LoggerFactory
from ..types import (
Shot,
Task,
TaskStatus,
TaskType,
)
from ..playblast.core import (
playblast_with_scene_settings,
playblast_with_viewport_settings,
playblast_with_viewport_preset_settings,
playblast_vse,
)
from ..context import core as context_core
from ..playblast import opsdata, core
logger = LoggerFactory.getLogger()
class KITSU_OT_playblast_create(bpy.types.Operator):
bl_idname = "kitsu.playblast_create"
bl_label = "Create Playblast"
bl_description = (
"Creates render either from viewport in which operator was triggered"
"or renderes with the current scene's render settings"
"Saves the set version to disk and uploads it to Kitsu with the specified "
"comment and task type."
"Opens web browser or VSE after playblast if set in addon preferences"
)
comment: bpy.props.StringProperty(
name="Comment",
description="Comment that will be appended to this playblast on Kitsu",
default="",
)
confirm: bpy.props.BoolProperty(name="Confirm", default=False)
task_status: bpy.props.EnumProperty(items=cache.get_all_task_statuses_enum) # type: ignore
thumbnail_frame: bpy.props.IntProperty(
name="Thumbnail Frame",
description="Frame to use as the thumbnail on Kitsu",
min=0,
)
thumbnail_frame_final: bpy.props.IntProperty(name="Thumbnail Frame Final")
_entity = None
_task_status = None
_task = None
_task_type = None
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not prefs.session_auth(context):
cls.poll_message_set("Not logged into Kitsu Server, see Add-On Preferences")
return False
if not context.scene.kitsu.playblast_file:
cls.poll_message_set("Invalid Playblast File/Directory, see Add-On Preferences")
return False
if context.space_data.type == "VIEW_3D":
if not context.scene.camera:
cls.poll_message_set("No Active Camera in active Scene")
return False
if context_core.is_sequence_context():
if not cache.sequence_active_get():
cls.poll_message_set("No Active Sequence set in Kitsu Context UI")
return False
if context_core.is_shot_context():
if not cache.shot_active_get():
cls.poll_message_set("No Active Shot set in Kitsu Context UI")
return False
if not cache.task_type_active_get():
cls.poll_message_set("No Active Task Type set in Kitsu Context UI")
return False
return True
def is_vse(self, context):
return bool(context.space_data.type == "SEQUENCE_EDITOR")
def _get_kitsu_task(self, context: bpy.types.Context):
task_type_name = cache.task_type_active_get().name
# Get task status 'wip' and task type 'Animation'.
self._task_status = TaskStatus.by_id(self.task_status)
self._task_type = TaskType.by_name(task_type_name)
if not self._task_type:
raise RuntimeError("Failed to upload playblast. Task type missing on Kitsu Server")
# Find / get latest task
self._task = Task.by_name(self._entity, self._task_type)
if not self._task:
# An Entity on the server can have 0 tasks even tough task types exist.
# We have to create a task first before being able to upload a thumbnail.
try:
self._task = Task.new_task(
self._entity, self._task_type, task_status=self._task_status
)
except TypeError:
raise RuntimeError(
f"Failed to upload playblast. Task type {self._task_type.name} not present in {self._entity.type} {self._entity.name}"
)
def _get_user_name(self, context: bpy.types.Context) -> str:
session = prefs.session_get(context)
if len(self._task.persons) == 1:
return self._task.persons[0]["full_name"]
elif len(self._task.persons) >= 1:
person = ""
for index, user in enumerate(self._task.persons):
person += user["full_name"]
if index < len(self._task.persons) - 1:
person += ", "
return person
else:
return session.data.user["full_name"]
def execute(self, context: bpy.types.Context) -> Set[str]:
addon_prefs = prefs.addon_prefs_get(context)
kitsu_scene_props = context.scene.kitsu
render_mode = kitsu_scene_props.playblast_render_mode
if not self.task_status:
self.report({"ERROR"}, "Failed to create playblast. Missing task status")
return {"CANCELLED"}
# Playblast file always starts at frame 0, account for this in thumbnail frame selection
self.thumbnail_frame_final = self.thumbnail_frame - context.scene.frame_start
# Ensure thumbnail frame is not outside of frame range
if self.thumbnail_frame_final 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"}
# entity is either a shot or a sequence
self._entity = self._get_active_entity(context)
# get kitsu task info
self._get_kitsu_task(context)
# Save playblast task status id for next time.
kitsu_scene_props.playblast_task_status_id = self.task_status
logger.info("-START- Creating Playblast")
context.window_manager.progress_begin(0, 2)
context.window_manager.progress_update(0)
playblast_file = kitsu_scene_props.playblast_file
username = self._get_user_name(context)
# Render and save playblast
if self.is_vse(context):
output_path = playblast_vse(self, context, playblast_file)
else:
if render_mode == "VIEWPORT":
output_path = playblast_with_viewport_settings(
self, context, playblast_file, username
)
elif render_mode == "VIEWPORT_PRESET":
output_path = playblast_with_viewport_preset_settings(
self, context, playblast_file, username
)
else: # render_mode == "SCENE":
output_path = playblast_with_scene_settings(self, context, playblast_file, username)
context.window_manager.progress_update(1)
# Upload playblast
self._upload_playblast(context, output_path)
if not addon_prefs.version_control:
basename = context_core.get_versioned_file_basename(Path(bpy.data.filepath).stem)
version_filename = basename + "-" + kitsu_scene_props.playblast_version + ".blend"
version_filepath = Path(bpy.data.filepath).parent.joinpath(version_filename).as_posix()
bpy.ops.wm.save_as_mainfile(filepath=version_filepath, copy=True)
context.window_manager.progress_update(2)
context.window_manager.progress_end()
self.report({"INFO"}, f"Created and uploaded playblast for {self._entity.name}")
logger.info("-END- Creating Playblast")
# Redraw UI
util.ui_redraw()
# Post playblast
# Open web browser
if addon_prefs.pb_open_webbrowser:
self._open_webbrowser()
# Open playblast in second scene video sequence editor.
if addon_prefs.pb_open_vse:
# Create new scene.
scene_orig = bpy.context.scene
try:
scene_pb = bpy.data.scenes[bkglobals.SCENE_NAME_PLAYBLAST]
except KeyError:
# Create scene.
bpy.ops.scene.new(type="EMPTY") # changes active scene
scene_pb = bpy.context.scene
scene_pb.name = bkglobals.SCENE_NAME_PLAYBLAST
logger.info(
"Created new scene for playblast playback: %s", scene_pb.name
)
else:
logger.info(
"Use existing scene for playblast playback: %s", scene_pb.name
)
# Change scene.
context.window.scene = scene_pb
# Init video sequence editor.
if not context.scene.sequence_editor:
context.scene.sequence_editor_create() # what the hell
# Setup video sequence editor space.
if "Video Editing" not in [ws.name for ws in bpy.data.workspaces]:
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(),
)
else:
context.window.workspace = bpy.data.workspaces["Video Editing"]
# Add movie strip
# load movie strip file in sequence editor
# in this case we make use of ops.sequencer.movie_strip_add because
# it provides handy auto placing,would be hard to achieve with
# context.scene.sequence_editor.sequences.new_movie().
override = context.copy()
for window in bpy.context.window_manager.windows:
screen = window.screen
for area in screen.areas:
if area.type == "SEQUENCE_EDITOR":
override["window"] = window
override["screen"] = screen
override["area"] = area
bpy.ops.sequencer.movie_strip_add(
override,
filepath=scene_orig.kitsu.playblast_file,
frame_start=context.scene.frame_start,
)
# Playback.
context.scene.frame_current = context.scene.frame_start
bpy.ops.screen.animation_play()
return {"FINISHED"}
def invoke(self, context, event):
# Initialize comment and playblast task status variable.
self.comment = ""
prev_task_status_id = context.scene.kitsu.playblast_task_status_id
if context.scene.frame_current not in range(
context.scene.frame_start, context.scene.frame_end
):
context.scene.frame_current = context.scene.frame_start
self.thumbnail_frame = context.scene.frame_current
# Only use prev_task_status_id if it exists in the task statuses enum list
valid_ids = [status[0] for status in cache.get_all_task_statuses_enum(self, context)]
if prev_task_status_id in valid_ids:
self.task_status = prev_task_status_id
else:
# Find todo.
todo_status = TaskStatus.by_name(bkglobals.PLAYBLAST_DEFAULT_STATUS)
if todo_status:
self.task_status = todo_status.id
return context.window_manager.invoke_props_dialog(self, width=500)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
layout.prop(self, "task_status", text="Status")
layout.prop(self, "comment")
layout.prop(self, "thumbnail_frame")
if not self.is_vse(context):
layout.prop(context.scene.kitsu, "playblast_render_mode", text="Render Mode")
def _upload_playblast(self, context: bpy.types.Context, filepath: Path) -> None:
# Create a comment
comment_text = self._gen_comment_text(context, self._entity)
comment = self._task.add_comment(
self._task_status,
comment=comment_text,
)
# Add_preview_to_comment
self._task.add_preview_to_comment(
comment,
filepath.as_posix(),
self.thumbnail_frame_final,
)
logger.info(
f"Uploaded playblast for shot: {self._entity.name} under: {self._task_type.name}"
)
def _gen_comment_text(self, context: bpy.types.Context, shot: Shot) -> str:
header = f"Playblast {shot.name}: {context.scene.kitsu.playblast_version}"
if self.comment:
return header + f"\n\n{self.comment}"
return header
def _open_webbrowser(self) -> None:
addon_prefs = prefs.addon_prefs_get(bpy.context)
# https://staging.kitsu.blender.cloud/productions/7838e728-312b-499a-937b-e22273d097aa/shots?search=010_0010_A
host_url = addon_prefs.host
if host_url.endswith("/api"):
host_url = host_url[:-4]
if host_url.endswith("/"):
host_url = host_url[:-1]
url = f"{host_url}/productions/{cache.project_active_get().id}/shots?search={cache.shot_active_get().name}"
webbrowser.open(url)
def _get_active_entity(self, context):
if context_core.is_sequence_context():
# Get sequence
return cache.sequence_active_get()
elif context_core.is_asset_context():
return cache.asset_active_get()
else:
# Get shot.
return cache.shot_active_get()
class KITSU_OT_playblast_set_version(bpy.types.Operator):
bl_idname = "kitsu.anim_set_playblast_version"
bl_label = "Version"
bl_property = "versions"
bl_description = (
"Sets version of playblast. Warning triangle in ui "
"indicates that version already exists on disk"
)
versions: bpy.props.EnumProperty(
items=opsdata.get_playblast_versions_enum_list, name="Versions"
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
addon_prefs = prefs.addon_prefs_get(context)
return bool(context.scene.kitsu.playblast_dir)
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('playblast_version') == version:
return {"CANCELLED"}
# Update global scene cache version prop.
kitsu_props.playblast_version = version
logger.info("Set playblast 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_push_frame_range(bpy.types.Operator):
bl_idname = "kitsu.push_frame_range"
bl_label = "Push Frame Start"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Adjusts the start frame of animation file."
frame_start = None
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(prefs.session_auth(context) and cache.shot_active_get())
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
col = layout.column(align=True)
col.label(text="Set 3d_start using current scene frame start.")
col.label(text=f"New Frame Start: {self.frame_start}", icon="ERROR")
def execute(self, context: bpy.types.Context) -> Set[str]:
core.set_frame_range_in(self.frame_start)
self.report({"INFO"}, f"Updated frame range offset {self.frame_start}")
return {"FINISHED"}
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
self.frame_start = context.scene.frame_start
frame_in, _ = core.get_frame_range()
if frame_in == self.frame_start:
self.report(
{"INFO"},
f"Sever's 'Frame In' already matches current Scene's 'Frame Start' {self.frame_start}",
)
return {"FINISHED"}
return context.window_manager.invoke_props_dialog(self, width=500)
class KITSU_OT_pull_frame_range(bpy.types.Operator):
bl_idname = "kitsu.pull_frame_range"
bl_label = "Pull Frame Range"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Pulls frame range of active shot from the server "
"and set the current scene's frame range to it"
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(prefs.session_auth(context) and cache.shot_active_get())
def execute(self, context: bpy.types.Context) -> Set[str]:
frame_in, frame_out = core.get_frame_range()
# Check if current frame range matches the one for active shot.
if core.check_frame_range(context):
self.report({"INFO"}, f"Frame range already up to date")
return {"FINISHED"}
# Update scene frame range.
context.scene.frame_start = frame_in
context.scene.frame_end = frame_out
if not core.check_frame_range(context):
self.report(
{"ERROR"}, f"Failed to update frame range to {frame_in} - {frame_out}"
)
return {"CANCELLED"}
# Log.
self.report({"INFO"}, f"Updated frame range {frame_in} - {frame_out}")
return {"FINISHED"}
class KITSU_OT_check_frame_range(bpy.types.Operator):
bl_idname = "kitsu.check_frame_range"
bl_label = "Check Frame Range"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Checks frame range of active shot from the server matches current file"
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(prefs.session_auth(context) and cache.shot_active_get())
def execute(self, context: bpy.types.Context) -> Set[str]:
if core.check_frame_range(context):
self.report({"INFO"}, f"Frame Range is accurate")
return {"FINISHED"}
self.report({"ERROR"}, f"Failed: Frame Range Check")
return {"CANCELLED"}
class KITSU_OT_playblast_increment_playblast_version(bpy.types.Operator):
bl_idname = "kitsu.anim_increment_playblast_version"
bl_label = "Add Version Increment"
bl_description = "Increment the playblast 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_playblast_version_increment(context)
# Update cache_version prop.
context.scene.kitsu.playblast_version = version
# Report.
self.report({"INFO"}, f"Add playblast version {version}")
util.ui_redraw()
return {"FINISHED"}
@persistent
def load_post_handler_init_version_model(dummy: Any) -> None:
opsdata.init_playblast_file_model(bpy.context)
def draw_frame_range_warning(self, context):
active_shot = cache.shot_active_get()
layout = self.layout
layout.alert = True
layout.label(
text="Frame Range on server does not match the active shot. Please 'pull' the correct frame range from the server"
)
layout.label(text=f" File Frame Range: {context.scene.frame_start}-{context.scene.frame_end}")
if active_shot:
kitsu_3d_start = active_shot.get_3d_start()
layout.label(
text=f'Server Frame Range: {kitsu_3d_start}-{kitsu_3d_start + int(active_shot.nb_frames) - 1}'
)
else:
layout.label(text=f' Server Frame Range: not found')
layout.operator("kitsu.pull_frame_range", icon='TRIA_DOWN')
def draw_kitsu_context_warning(self, context):
layout = self.layout
layout.alert = True
layout.label(
text="Frame Range couldn't be found because current Shot wasn't found, please select an active Shot from Kitsu Context Menu"
)
@persistent
def detect_kitsu_context(dummy: Any) -> None:
# TODO Move this handler should not be part of playblast
# Leaving this here so it can be set in order along with frame range detection
# Skip not logged into Kitsu
if not prefs.session_auth(bpy.context):
return
# Skip project not set
if not cache.project_active_get():
return
# Skip if project root dir is not in file path
project_root_dir = prefs.project_root_dir_get(bpy.context)
if not project_root_dir in Path(bpy.data.filepath).parents:
return
# Skip if Unsaved File
if not bpy.data.is_saved:
return
try:
bpy.ops.kitsu.con_detect_context()
except RuntimeError:
bpy.context.window_manager.popup_menu(
draw_kitsu_context_warning,
title="Warning: Kitsu Context Auto-Detection Failed.",
icon='WARNING',
)
pass
@persistent
def load_post_handler_check_frame_range(dummy: Any) -> None:
# Only show if kitsu context is detected
cat = bpy.context.scene.kitsu.category
project_root_dir = prefs.project_root_dir_get(bpy.context)
shots_dir = project_root_dir.joinpath("pro/shots/")
# Skip if Unsaved File
if not bpy.data.is_saved:
return
# Skip if File Outside of Shots Directory
if not shots_dir in Path(bpy.data.filepath).parents:
return
# Skip if category is not SHOT
if not cat == "SHOT":
return
if not core.check_frame_range(bpy.context):
bpy.context.window_manager.popup_menu(
draw_frame_range_warning,
title="Warning: Frame Range Error.",
icon='ERROR',
)
@persistent
def save_pre_handler_clean_overrides(dummy: Any) -> None:
"""
Removes some Library Override properties that could be accidentally
created and cause problems.
"""
for o in bpy.data.objects:
if not o.override_library:
continue
if o.library:
continue
override = o.override_library
props = override.properties
for prop in props[:]:
rna_path = prop.rna_path
if rna_path in ["active_material_index", "active_material"]:
props.remove(prop)
linked_value = getattr(override.reference, rna_path)
setattr(o, rna_path, linked_value)
o.property_unset(rna_path)
# ---------REGISTER ----------.
classes = [
KITSU_OT_playblast_create,
KITSU_OT_playblast_set_version,
KITSU_OT_playblast_increment_playblast_version,
KITSU_OT_pull_frame_range,
KITSU_OT_push_frame_range,
KITSU_OT_check_frame_range,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
# init_playblast_file_model(bpy.context) not working because of restricted context.
# Handlers.
bpy.app.handlers.load_post.append(load_post_handler_init_version_model)
bpy.app.handlers.load_post.append(detect_kitsu_context)
bpy.app.handlers.load_post.append(load_post_handler_check_frame_range)
bpy.app.handlers.save_pre.append(save_pre_handler_clean_overrides)
def unregister():
# Clear handlers.
bpy.app.handlers.load_post.remove(load_post_handler_check_frame_range)
bpy.app.handlers.load_post.remove(load_post_handler_init_version_model)
bpy.app.handlers.load_post.remove(detect_kitsu_context)
bpy.app.handlers.save_pre.remove(save_pre_handler_clean_overrides)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)