2026-03-11_4

This commit is contained in:
2026-03-17 15:34:28 -06:00
parent 9706bc055f
commit eef5547a2c
474 changed files with 113268 additions and 27500 deletions
@@ -1,5 +1,5 @@
{
"last_check": "2026-03-10 16:36:48.530366",
"last_check": "2026-03-06 09:35:09.644798",
"backup_date": "December-11-2025",
"update_ready": false,
"ignore": false,
Binary file not shown.
Binary file not shown.
+96
View File
@@ -0,0 +1,96 @@
bl_info = {
"name": "Side Shelf",
"author": "Jesse Doyle",
"blender": (3, 2, 0),
"description": "E Shelf on the right side N panel",
"category": "Side Tabs"
}
import os
import bpy
import bpy.utils.previews
from os import listdir
from os.path import isfile, join
class NPanel(bpy.types.Panel):
"""Creates a Panel in the 3D view Tools panel"""
bl_idname = "TEST_PT_Panel"
bl_label = "Test"
bl_category = "Shelf"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_context = "objectmode"
def draw(self, context):
layout = self.layout
pcoll = preview_collections["main"]
row = layout.row()
triangle_icon = pcoll["triangle"]
square_icon = pcoll["square"]
row.scale_x = 10.0
row.scale_y = 8.0
row.operator("mesh.primitive_cube_add", text="", icon_value=triangle_icon.icon_id)
row.operator("mesh.primitive_uv_sphere_add", text="", icon_value=square_icon.icon_id,scale=10)
# global variable to store icons in
# custom_icons = None
# def register():
# global custom_icons
# custom_icons = bpy.utils.previews.new()
# for z in list_raw:
# custom_icons.load(z[:-4], os.path.join(directory, z), 'IMAGE')
# bpy.utils.register_class(Panel)
# def unregister():
# global custom_icons
# bpy.utils.previews.remove(custom_icons)
preview_collections = {}
def register():
# Note that preview collections returned by bpy.utils.previews
# are regular py objects - you can use them to store custom data.
pcoll = bpy.utils.previews.new()
# path to the folder where the icon is
# the path is calculated relative to this py file inside the addon folder
directory = os.path.join(os.path.dirname(__file__), "Data", "icons")
list_raw = []
onlyfiles = [f for f in listdir(directory) if isfile(join(directory, f))]
for f in onlyfiles:
if f[-4:] == ".png":
list_raw.append(f)
for z in list_raw:
# load a preview thumbnail of a file and store in the previews collection
pcoll.load(z[:-4], os.path.join(directory, z), 'IMAGE')
preview_collections["main"] = pcoll
bpy.utils.register_class(NPanel)
def unregister():
for pcoll in preview_collections.values():
bpy.utils.previews.remove(pcoll)
preview_collections.clear()
bpy.utils.unregister_class(NPanel)
if __name__ == "__main__":
register()
-2
View File
@@ -1,2 +0,0 @@
# Blender Kitsu
blender-kitsu is an add-on to interact with Kitsu from within Blender. It also has features that are not directly related to Kitsu but support certain aspects of the Blender Studio Pipeline. You can find the documentation [here](https://studio.blender.org/tools/addons/blender_kitsu).
-114
View File
@@ -1,114 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from . import dependencies
dependencies.preload_modules()
from . import (
shot_builder,
render_review,
lookdev,
bkglobals,
types,
cache,
models,
playblast,
propsdata,
props,
prefs,
sqe,
util,
generic,
auth,
context,
anim,
tasks,
ui,
edit,
)
from .logger import LoggerFactory, LoggerLevelManager
logger = LoggerFactory.getLogger(__name__)
bl_info = {
"name": "Blender Kitsu",
"author": "Paul Golter",
"description": "Blender addon to interact with Kitsu",
"blender": (2, 93, 0),
"version": (0, 1, 6),
"location": "View3D",
"warning": "",
"doc_url": "",
"tracker_url": "",
"category": "Generic",
}
_need_reload = "props" in locals()
if _need_reload:
import importlib
lookdev.reload()
bkglobals = importlib.reload(bkglobals)
cache = importlib.reload(cache)
types = importlib.reload(types)
models = importlib.reload(models)
playblast = importlib.reload(playblast)
propsdata = importlib.reload(propsdata)
props = importlib.reload(props)
prefs = importlib.reload(prefs)
ui = importlib.reload(ui)
sqe.reload()
util = importlib.reload(util)
generic.reload()
auth.reload()
context.reload()
tasks.reload()
anim.reload()
edit.reload()
def register():
lookdev.register()
prefs.register()
cache.register()
props.register()
sqe.register()
generic.register()
auth.register()
context.register()
# tasks.register()
playblast.register()
anim.register()
shot_builder.register()
render_review.register()
edit.register()
LoggerLevelManager.configure_levels()
logger.info("Registered blender-kitsu")
def unregister():
anim.unregister()
# tasks.unregister()
context.unregister()
auth.unregister()
generic.unregister()
sqe.unregister()
props.unregister()
cache.unregister()
prefs.unregister()
lookdev.unregister()
playblast.unregister()
shot_builder.unregister()
render_review.unregister()
edit.unregister()
LoggerLevelManager.restore_levels()
if __name__ == "__main__":
register()
@@ -1,29 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from . import opsdata, ops, ui
# ---------REGISTER ----------.
def reload():
global opsdata
global ops
global ui
opsdata = importlib.reload(opsdata)
ops = importlib.reload(ops)
ui = importlib.reload(ui)
def register():
ops.register()
ui.register()
def unregister():
ui.unregister()
ops.unregister()
-504
View File
@@ -1,504 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import List, Set, Tuple
import bpy
from .. import cache, util
from ..logger import LoggerFactory
from . import opsdata
logger = LoggerFactory.getLogger()
class KITSU_OT_anim_quick_duplicate(bpy.types.Operator):
bl_idname = "kitsu.anim_quick_duplicate"
bl_label = "Quick Duplicate"
bl_description = (
"Duplicate the active collection and add it to the "
"output collection of the current scene"
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
act_coll = context.view_layer.active_layer_collection.collection
return bool(
cache.shot_active_get()
and context.view_layer.active_layer_collection.collection
and not opsdata.is_item_local(act_coll)
)
def execute(self, context: bpy.types.Context) -> Set[str]:
act_coll = context.view_layer.active_layer_collection.collection
amount = context.window_manager.kitsu.quick_duplicate_amount
if not act_coll:
self.report({"ERROR"}, f"No collection selected")
return {"CANCELLED"}
# Check if output colletion exists in scene.
output_coll_name = cache.output_collection_name_get()
try:
output_coll = bpy.data.collections[output_coll_name]
except KeyError:
self.report(
{"ERROR"},
f"Missing output collection: {output_coll_name}",
)
return {"CANCELLED"}
# Get ref coll.
ref_coll = opsdata.get_ref_coll(act_coll)
for i in range(amount):
# Create library override.
coll = ref_coll.override_hierarchy_create(
context.scene, context.view_layer, reference=act_coll
)
# Set color tag to be the same.
coll.color_tag = act_coll.color_tag
# Link coll in output collection.
if coll not in list(output_coll.children):
output_coll.children.link(coll)
# Report.
self.report(
{"INFO"},
f"Created {amount} Duplicates of: {act_coll.name} and added to {output_coll.name}",
)
util.ui_redraw()
return {"FINISHED"}
class KITSU_OT_anim_check_action_names(bpy.types.Operator):
bl_idname = "kitsu.anim_check_action_names"
bl_label = "Check Action Names "
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Inspect all action names of .blend file and check "
"if they follow the Blender Studio naming convention"
)
wrong: List[Tuple[bpy.types.Action, str]] = []
created: List[bpy.types.Action] = []
empty_actions: List[bpy.types.Action] = []
cleanup_empty_actions: bpy.props.BoolProperty(
name="Delete Empty Action Data-Blocks",
default=False,
description="Remove any empty action data-blocks, actions that have 0 Fcurves/Keyframes. Even if the action has a fake user assigned to it",
)
# List of tuples that contains the action on index 0 with the wrong name
# and the name it should have on index 1.
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not cache.shot_active_get():
cls.poll_message_set("No Shot selected")
return False
if not cache.task_type_active_get():
cls.poll_message_set("No Task Type selected")
return False
return True
def get_action(self, action_name: str):
if bpy.data.actions.get(action_name):
return bpy.data.actions.get(action_name)
else:
new_action = bpy.data.actions.new(action_name)
self.created.append(new_action)
return new_action
def execute(self, context: bpy.types.Context) -> Set[str]:
existing_action_names = [a.name for a in bpy.data.actions]
failed = []
succeeded = []
removed = []
# Clean-up Empty Actions
if self.cleanup_empty_actions:
for action in self.empty_actions:
removed.append(action.name)
action.use_fake_user = False
bpy.data.actions.remove(action)
# Rename actions.
for action, name in self.wrong:
if name in existing_action_names:
logger.warning(
"Failed to rename action %s to %s. Action with that name already exists",
action.name,
name,
)
failed.append(action)
continue
old_name = action.name
action.name = name
action.use_fake_user = True
existing_action_names.append(action.name)
succeeded.append(action)
logger.info("Renamed action %s to %s", old_name, action.name)
# Report.
report_str = f"Renamed actions: {len(succeeded)}"
report_state = "INFO"
if self.cleanup_empty_actions:
report_str += f" | Removed Empty Actions: {len(removed)}"
if failed:
report_state = "WARNING"
report_str += f" | Rename Failed: {len(failed)}"
if len(self.created) != 0:
report_str += f" | Created Actions: {len(self.created)}"
self.report(
{report_state},
report_str,
)
# Clear action names cache.
opsdata.action_names_cache.clear()
return {"FINISHED"}
def invoke(self, context, event):
shot_active = cache.shot_active_get()
self.wrong.clear()
self.created.clear()
no_action = []
correct = []
# Clear action names cache.
opsdata.action_names_cache.clear()
opsdata.action_names_cache.extend([a.name for a in bpy.data.actions])
output_col_name = cache.output_collection_name_get()
output_col = context.scene.collection.children.get(output_col_name)
if not output_col:
self.report(
{"ERROR"},
f"Missing output collection: {output_col_name}",
)
return {"CANCELLED"}
# Find all asset collections in .blend.
asset_colls = opsdata.find_asset_collections(output_col)
if not asset_colls:
self.report(
{"WARNING"},
f"Failed to find any asset collections",
)
return {"CANCELLED"}
# Collect Empty Actions
self.empty_actions = []
for action in bpy.data.actions:
if len(action.fcurves) == 0:
self.empty_actions.append(action)
# Find rig of each asset collection.
asset_rigs: List[Tuple[bpy.types.Collection, bpy.types.Armature]] = []
for coll in asset_colls:
rigs = opsdata.find_rig(coll, log=False)
if rigs:
for rig in rigs:
asset_rigs.append((coll, rig))
if not asset_rigs:
self.report(
{"WARNING"},
f"Failed to find any valid rigs",
)
return {"CANCELLED"}
# For each rig check the current action name if it matches the convention.
for coll, rig in asset_rigs:
if not rig.animation_data or not rig.animation_data.action:
logger.info("%s has no animation data", rig.name)
no_action.append(rig)
continue
if rig.animation_data.action in self.empty_actions:
continue
action_name_should = opsdata.gen_action_name(rig, coll, shot_active)
action_name_is = rig.animation_data.action.name
# If action name does not follow convention append it to wrong list.
if action_name_is != action_name_should:
logger.warning(
"Action %s should be named %s", action_name_is, action_name_should
)
self.wrong.append((rig.animation_data.action, action_name_should))
# Extend action_names_cache list so any follow up items in loop can
# access that information and adjust postfix accordingly.
opsdata.action_names_cache.append(action_name_should)
continue
# Action name of rig is correct.
correct.append(rig)
if not self.wrong and self.empty_actions == []:
self.report({"INFO"}, "All actions names are correct, no empty actions found")
return {"FINISHED"}
self.report(
{"INFO"},
f"Checked Rigs: {len(asset_rigs)} | Wrong Actions {len(correct)} | Correct Actions: {len(correct)} | No Actions: {len(no_action)}",
)
return context.window_manager.invoke_props_dialog(self, width=500)
def draw(self, context):
layout = self.layout
for action, name in self.wrong:
row = layout.row()
row.label(text=action.name)
row.label(text="", icon="FORWARD")
row.label(text=name)
layout.prop(
self,
"cleanup_empty_actions",
text=f"Delete {len(self.empty_actions)} Empty Action Data-Blocks",
)
class KITSU_OT_anim_enforce_naming_convention(bpy.types.Operator):
bl_idname = "kitsu.anim_enforce_naming_convention"
bl_label = "Set Name Conventions"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Fix Naming of Scene, Output Collection, Actions, and optionally Find and remove a given string"
remove_str: bpy.props.StringProperty(
name="Find and Replace",
default="",
)
rename_scene: bpy.props.BoolProperty(name="Rename Scene", default=True)
rename_output_col: bpy.props.BoolProperty(
name="Rename Output Collection", default=True
)
find_replace: bpy.props.BoolProperty(
name="Find and Remove",
default=False,
description="Remove this string from Collection, Object and Object Data names. Used to remove suffixes from file names such as '.001'",
)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=500)
def draw(self, context):
layout = self.layout
layout.prop(self, "rename_scene")
layout.prop(self, "rename_actions")
layout.prop(self, "rename_output_col")
layout.prop(self, "find_replace")
if self.find_replace:
layout.prop(self, "remove_str")
def rename_datablock(self, data_block, replace: str):
# Return Early if data_block is linked but not overriden
if data_block is None or data_block.library is not None:
return
if replace in data_block.name:
data_block.name = data_block.name.replace(replace, "")
return data_block.name
def execute(self, context: bpy.types.Context):
shot_base_name = bpy.path.basename(bpy.data.filepath).replace(".anim.blend", "")
scene_col = context.scene.collection
anim_suffix = "anim.output"
if self.find_replace:
for col in scene_col.children_recursive:
self.rename_datablock(col, self.remove_str)
for obj in col.objects:
self.rename_datablock(obj, self.remove_str)
self.rename_datablock(obj.data, self.remove_str)
if self.rename_output_col:
output_cols = [
col
for col in context.scene.collection.children_recursive
if anim_suffix in col.name
]
if len(output_cols) != 1:
self.report(
{"INFO"},
f"Animation Output Collection could not be found",
)
return {"CANCELLED"}
output_col = output_cols[0]
output_col.name = f"{shot_base_name}.{anim_suffix}"
# Rename Scene
if self.rename_scene:
context.scene.name = f"{shot_base_name}.anim"
self.report(
{"INFO"},
f"Naming Conventions Enforced",
)
return {"FINISHED"}
class KITSU_OT_unlink_collection_with_string(bpy.types.Operator):
bl_idname = "kitsu.unlink_collection_with_string"
bl_label = "Find and Unlink Collections"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Unlink any Collection with a given name. By default name is 'OVERRIDE_HIDDEN'"
)
remove_collection_string: bpy.props.StringProperty(
name="Find in Name",
default='OVERRIDE_HIDDEN',
description="Search for this string withing current scene's collections. Collection will be unlinked if it matches given string'",
)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=500)
def draw(self, context):
layout = self.layout
layout.prop(self, "remove_collection_string")
def execute(self, context):
scene_cols = context.scene.collection.children
cols = [col for col in scene_cols if self.remove_collection_string in col.name]
cleaned = False
for col in cols:
cleaned = True
scene_cols.unlink(col)
self.report(
{"INFO"},
f"Removed Collection '{col.name}'",
)
if not cleaned:
self.report(
{"INFO"},
f"No Collections found containing name '{self.remove_collection_string}'",
)
return {"FINISHED"}
class KITSU_PG_anim_exclude_coll(bpy.types.PropertyGroup):
exclude: bpy.props.BoolProperty(
name="Exclude",
description="",
default=False,
override={"LIBRARY_OVERRIDABLE"},
)
class KITSU_OT_anim_update_output_coll(bpy.types.Operator):
bl_idname = "kitsu.anim_update_output_coll"
bl_label = "Update Output Collection"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
"Scans scene for any collections that are not yet in the output collection"
)
output_coll = None
asset_colls = None
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
output_coll_name = cache.output_collection_name_get()
try:
output_coll = bpy.data.collections[output_coll_name]
except KeyError:
output_coll = None
return bool(output_coll)
def invoke(self, context, event):
output_coll_name = cache.output_collection_name_get()
self.output_coll = bpy.data.collections[output_coll_name]
return context.window_manager.invoke_props_dialog(self, width=500)
def get_collections(self, context):
self.asset_colls = opsdata.find_asset_collections_in_scene(context.scene)
# Only take parent colls.
childs = []
for i in range(len(self.asset_colls)):
coll = self.asset_colls[i]
coll_childs = list(opsdata.traverse_collection_tree(coll))
for j in range(i + 1, len(self.asset_colls)):
coll_comp = self.asset_colls[j]
if coll_comp in coll_childs:
childs.append(coll_comp)
return [coll for coll in self.asset_colls if coll not in childs]
def draw(self, context):
parents = self.get_collections(context)
# Must display collections that already exist in output collection so user can exclude them
for col in self.output_coll.children:
parents.append(col)
layout = self.layout
box = layout.box()
box.label(text="Select Collection to Exclude", icon="OUTLINER_COLLECTION")
column = box.column(align=True)
for col in parents:
column.prop(col.anim_output, "exclude", text=col.name)
def execute(self, context: bpy.types.Context) -> Set[str]:
# Clear Out Output Collection before Starting
for collection in self.output_coll.children:
self.output_coll.children.unlink(collection)
bpy.context.view_layer.update()
parents = self.get_collections(context)
parents = [col for col in parents if not col.anim_output.exclude]
for coll in parents:
self.output_coll.children.link(coll)
logger.info("%s linked in %s", coll.name, self.output_coll.name)
# Ensure Camera Rig is Linked
for coll in [col for col in bpy.data.collections]:
if coll.override_library:
if (
coll.override_library.hierarchy_root.name == "CA-camera_rig"
): # TODO Fix this hack to be generic
self.output_coll.children.link(coll)
self.report(
{"INFO"},
f"Found Asset Collections: {len(self.asset_colls)} | Added to output collection: {len(parents)}",
)
return {"FINISHED"}
# ---------REGISTER ----------.
classes = [
KITSU_OT_anim_quick_duplicate,
KITSU_OT_anim_check_action_names,
KITSU_OT_anim_update_output_coll,
KITSU_OT_anim_enforce_naming_convention,
KITSU_OT_unlink_collection_with_string,
KITSU_PG_anim_exclude_coll,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Collection.anim_output = bpy.props.PointerProperty(
type=KITSU_PG_anim_exclude_coll
)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
del bpy.types.Collection.anim_output
@@ -1,281 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from typing import Any, Dict, List, Optional, Tuple, Union, Generator
import bpy
from .. import bkglobals, util
from ..types import Shot
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
def is_item_local(
item: Union[bpy.types.Collection, bpy.types.Object, bpy.types.Camera]
) -> bool:
# Local collection of blend file.
if not item.override_library and not item.library:
return True
return False
def is_item_lib_override(
item: Union[bpy.types.Collection, bpy.types.Object, bpy.types.Camera]
) -> bool:
# Collection from libfile and overwritten.
if item.override_library and not item.library:
return True
return False
def is_item_lib_source(
item: Union[bpy.types.Collection, bpy.types.Object, bpy.types.Camera]
) -> bool:
# Source collection from libfile not overwritten.
if not item.override_library and item.library:
return True
return False
def create_collection_instance(
context: bpy.types.Context,
ref_coll: bpy.types.Collection,
instance_name: str,
) -> bpy.types.Object:
# Use empty to instance source collection.
instance_obj = bpy.data.objects.new(name=instance_name, object_data=None)
instance_obj.instance_collection = ref_coll
instance_obj.instance_type = "COLLECTION"
parent_collection = context.scene.collection
parent_collection.objects.link(instance_obj)
logger.info(
"Instanced collection: %s as: %s",
ref_coll.name,
instance_obj.name,
)
return instance_obj
def find_rig(coll: bpy.types.Collection, log: bool = True) -> Optional[bpy.types.Armature]:
valid_rigs = []
for obj in coll.all_objects:
# Default rig name: 'RIG-rex' / 'RIG-Rex'.
if obj.type != "ARMATURE":
continue
if not obj.name.startswith(bkglobals.PREFIX_RIG):
continue
valid_rigs.append(obj)
if not valid_rigs:
return None
if log:
for rig in valid_rigs:
logger.info("Found rig: %s", rig.name)
return valid_rigs
def find_asset_collections(
top_level_col: bpy.types.Collection, log: bool = True
) -> List[bpy.types.Collection]:
asset_colls: List[bpy.types.Collection] = []
for coll in top_level_col.children_recursive:
if not is_item_lib_override(coll):
continue
for prefix in bkglobals.ASSET_COLL_PREFIXES:
if not coll.name.startswith(prefix):
continue
asset_colls.append(coll)
if log:
logger.info(
"Found asset collections:\n%s", ", ".join([c.name for c in asset_colls])
)
return asset_colls
def traverse_collection_tree(
collection: bpy.types.Collection,
) -> Generator[bpy.types.Collection, None, None]:
yield collection
for child in collection.children:
yield from traverse_collection_tree(child)
def find_asset_collections_in_scene(
scene: bpy.types.Scene, log: bool = True
) -> List[bpy.types.Collection]:
asset_colls: List[bpy.types.Collection] = []
colls: List[bpy.types.Collection] = []
# Get all collections that are linked in this scene.
for coll in scene.collection.children:
colls.extend(list(traverse_collection_tree(coll)))
for coll in colls:
for prefix in bkglobals.ASSET_COLL_PREFIXES:
if not coll.name.startswith(prefix):
continue
asset_colls.append(coll)
if log:
logger.info(
"Found asset collections:\n%s", ", ".join([c.name for c in asset_colls])
)
return asset_colls
def get_ref_coll(coll: bpy.types.Collection) -> bpy.types.Collection:
if not coll.override_library:
return coll
return coll.override_library.reference
def find_asset_name(name: str) -> str:
name = _kill_increment_end(name)
if name.endswith(bkglobals.SPACE_REPLACER + "rig"):
name = name[:-4]
return name.split(bkglobals.DELIMITER)[-1] # CH-rex -> 'rex'
def _kill_increment_end(str_value: str) -> str:
match = re.search(r"\.\d\d\d$", str_value)
if match:
return str_value.replace(match.group(0), "")
return str_value
def is_multi_asset(asset_name: str) -> bool:
if asset_name.startswith("thorn"):
return True
if asset_name.startswith("pine_cone"):
return True
if asset_name.startswith("pee_"):
return True
if asset_name.lower() in bkglobals.MULTI_ASSETS:
return True
return False
action_names_cache: List[str] = []
# We need this in order to increment prefixes of duplications of the same asset correctly
# gets cleared populated during call of KITSU_OT_anim_check_action_names.
_current_asset: str = ""
_current_asset_idx: int = 0
# We need these two variables to track if we are on the first asset that is currently processed
# (if there are multiple ones) because the first one CAN get keep it postfix.
def gen_action_name(
armature: bpy.types.Armature, collection: bpy.types.Collection, shot: Shot
) -> str:
global action_names_cache
global _current_asset
global _current_asset_idx
action_names_cache.sort()
def _find_postfix(action_name: str) -> Optional[str]:
# ANI-lady_bug_A.030_0020_A.v001.
split1 = action_name.split(bkglobals.DELIMITER)[-1] # lady_bug_A.030_0020_A.v001
split2 = split1.split(".")[0] # lady_bug_A
split3 = split2.split(bkglobals.SPACE_REPLACER)[-1] # A
if len(split3) == 1:
# is postfix
# print(f"{action.name} found postfix: {split3}")
return split3
else:
return None
ref_coll = get_ref_coll(collection)
action_prefix = "ANI"
asset_name = find_asset_name(ref_coll.name).lower()
asset_name = asset_name.replace(".", bkglobals.SPACE_REPLACER)
# Track on which repition we are of the same asset.
if asset_name == _current_asset:
_current_asset_idx += 1
else:
_current_asset_idx = 0
_current_asset = asset_name
version = "v001"
shot_name = shot.name
has_action = False
final_postfix = ""
# Overwrite version v001 if there is an action which already contains a version.
if armature.animation_data:
if armature.animation_data.action:
has_action = True
version = util.get_version(armature.animation_data.action.name) or "v001"
# Action name for single aset.
delimiter = bkglobals.DELIMITER # Currently set to '-'
space = bkglobals.SPACE_REPLACER # Currently set to '_'
action_name = (
f"{action_prefix}{delimiter}{asset_name}{delimiter}{shot_name}{delimiter}{version}"
)
if is_multi_asset(asset_name):
existing_postfixes = []
# Find all actions that relate to the same asset except for the asset.
for action_name in action_names_cache:
# Skip action that was input as parameter of this function.
if has_action and action_name == armature.animation_data.action.name:
# Print(f"Skipping action same name: {action_name}").
continue
# print(action_names_cache)
if action_name.startswith(f"{action_prefix}{delimiter}{asset_name}"):
multi_postfix = _find_postfix(action_name)
if multi_postfix:
# print(f"Found postfix {multi_postfix} for aseet : {asset_name}")
existing_postfixes.append(multi_postfix)
# print(f"EXISTING: {existing_postfixes}")
if existing_postfixes:
if _current_asset_idx == 0:
# print(f"{asset_name} is first asset can keep postfix")
final_postfix = multi_postfix
else:
# Otherwise increment the postfix by one.
existing_postfixes.sort()
final_postfix = chr(
ord(existing_postfixes[-1]) + 1
) # handle postfix == Z > [
else:
# If there are no existing postfixes the first one is A.
final_postfix = "A"
if has_action:
# Overwrite multi_postfix if multi_postfix exists.
current_postfix = _find_postfix(armature.animation_data.action.name)
# If existing action already has a postfix check if that one is in
# existing postfixes, if not use the actions post fix.
if current_postfix:
if current_postfix not in existing_postfixes:
final_postfix = current_postfix
# Action name for multi asset.
action_name = f"{action_prefix}{delimiter}{asset_name}{space}{final_postfix}{delimiter}{shot_name}{delimiter}{version}"
return action_name
-103
View File
@@ -1,103 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from . import opsdata
import bpy
from pathlib import Path
from .. import prefs, cache, ui
from .ops import (
KITSU_OT_anim_quick_duplicate,
KITSU_OT_anim_check_action_names,
KITSU_OT_anim_update_output_coll,
)
from ..generic.ops import KITSU_OT_open_path
class KITSU_PT_vi3d_anim_tools(bpy.types.Panel):
"""
Panel in 3dview that exposes a set of tools that are useful for animation
tasks, e.G playblast
"""
bl_category = "Kitsu"
bl_label = "Animation Tools"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 30
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(
prefs.session_auth(context)
# HACK to accomodate custom task types @ blender studio (Anim3D, Anim2D)
and cache.task_type_active_get().name in ['Animation', 'Layout', 'Anim3D', 'Anim2D']
)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
# Scene operators.
box = layout.box()
box.label(text="Scene", icon="SCENE_DATA")
col = box.column(align=True)
col.operator("kitsu.push_frame_range", icon="TRIA_UP")
col.operator(
"kitsu.pull_frame_range",
icon="TRIA_DOWN",
)
# Update output collection.
row = box.row(align=True)
row.operator(
KITSU_OT_anim_update_output_coll.bl_idname,
icon="FILE_REFRESH",
)
# Quick duplicate.
act_coll = context.view_layer.active_layer_collection.collection
dupli_text = "Duplicate: Select Collection"
if act_coll:
dupli_text = f"Duplicate: {act_coll.name}"
if act_coll and opsdata.is_item_local(act_coll):
dupli_text = f"Duplicate: Select Overwritten Collection"
split = box.split(factor=0.85, align=True)
split.operator(
KITSU_OT_anim_quick_duplicate.bl_idname, icon="DUPLICATE", text=dupli_text
)
split.prop(context.window_manager.kitsu, "quick_duplicate_amount", text="")
# Check action names.
row = box.row(align=True)
row.operator(
KITSU_OT_anim_check_action_names.bl_idname,
icon="ACTION",
text="Check Action Names",
)
row = box.row(align=True)
row.operator("kitsu.anim_enforce_naming_convention", icon="SORTALPHA")
# ---------REGISTER ----------.
classes = [
KITSU_PT_vi3d_anim_tools,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,27 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from ..auth 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()
-145
View File
@@ -1,145 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Dict, List, Set, Optional, Tuple, Any
import bpy
import threading
import gazu
from .. import cache, prefs
# TODO: restructure this to not access ops_playblast_data.
from ..playblast import opsdata as ops_playblast_data
from ..playblast import ops as ops_playblast
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
active_thread = False
class KITSU_OT_session_start(bpy.types.Operator):
"""
Starts the Session, which is stored in blender_kitsu addon preferences.
Authenticates user with server until session ends.
Host, email and password are retrieved from blender_kitsu addon preferences.
"""
bl_idname = "kitsu.session_start"
bl_label = "Start Kitsu Session"
bl_options = {"INTERNAL"}
bl_description = (
"Logs in to server with the credentials that are defined in the "
"addon preferences. Session is valid until Blender closes"
)
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return True
def execute(self, context: bpy.types.Context) -> Set[str]:
self.thread_login(context)
if not prefs.session_get(context).is_auth():
self.report({"ERROR"}, "Login data not correct")
logger.error("Login data not correct")
return {"CANCELLED"}
# Init cache variables, will skip if cache already initiated.
cache.init_cache_variables()
# Init startup variables, will skip if cache already initiated.
cache.init_startup_variables(context)
# Init playblast version dir model.
ops_playblast_data.init_playblast_file_model(context)
# Check frame range.
ops_playblast.load_post_handler_check_frame_range(None)
return {"FINISHED"}
def get_config(self, context: bpy.types.Context) -> Dict[str, str]:
addon_prefs = prefs.addon_prefs_get(context)
return {
"email": addon_prefs.email,
"host": addon_prefs.host,
"passwd": addon_prefs.passwd,
}
def kitsu_session_start(self, context):
session = prefs.session_get(context)
session.set_config(self.get_config(context))
try:
session_data = session.start()
self.report({"INFO"}, f"Logged in as {session_data.user['full_name']}")
finally:
return
def thread_login(self, context):
global active_thread
if active_thread:
active_thread._stop()
active_thread = threading.Thread(
target=self.kitsu_session_start(context), daemon=True
)
active_thread.start()
class KITSU_OT_session_end(bpy.types.Operator):
"""
Ends the Session which is stored in blender_kitsu addon preferences.
"""
bl_idname = "kitsu.session_end"
bl_label = "End Kitsu Session"
bl_options = {"INTERNAL"}
bl_description = "Logs active user out"
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return prefs.session_auth(context)
def execute(self, context: bpy.types.Context) -> Set[str]:
session = prefs.session_get(context)
session.end()
# Clear cache variables.
cache.clear_cache_variables()
# Clear startup variables.
cache.clear_startup_variables()
self.report({"INFO"}, "Logged out")
return {"FINISHED"}
def auto_login_on_file_open():
context = bpy.context
session = prefs.session_get(context)
if not session.is_auth() and prefs.addon_prefs_get(context).host:
bpy.ops.kitsu.session_start()
# ---------REGISTER ----------.
classes = [
KITSU_OT_session_start,
KITSU_OT_session_end,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
# Note: Since this timer function does not repeat
# (because it doesn't return a value)
# it automatically un-registers after it runs.
# FIXME: XXX This makes Blender hang if there is no Internet connectivity
# TODO: Rewrite this, so the 'auto' login happens out of the main thread
bpy.app.timers.register(auto_login_on_file_open, first_interval=0.2)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .. import prefs
from ..auth.ops import KITSU_OT_session_end, KITSU_OT_session_start
class KITSU_PT_vi3d_auth(bpy.types.Panel):
"""
Panel in 3dview that displays email, password and login operator.
"""
bl_category = "Kitsu"
bl_label = "Login"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_order = 10
def draw(self, context: bpy.types.Context) -> None:
addon_prefs = prefs.addon_prefs_get(context)
session = prefs.session_get(context)
layout = self.layout
row = layout.row(align=True)
if not session.is_auth():
row.label(text=f"Email: {addon_prefs.email}")
row = layout.row(align=True)
row.label(text=f"Host: {addon_prefs.host}")
row = layout.row(align=True)
row.operator(KITSU_OT_session_start.bl_idname, text="Login", icon="PLAY")
else:
row.label(text=f"Logged in: {session.email}")
row = layout.row(align=True)
row.operator(
KITSU_OT_session_end.bl_idname, text="Logout", icon="PANEL_CLOSE"
)
class KITSU_PT_sqe_auth(KITSU_PT_vi3d_auth):
bl_space_type = "SEQUENCE_EDITOR"
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(not prefs.session_auth(context))
class KITSU_PT_comp_auth(KITSU_PT_vi3d_auth):
bl_space_type = "NODE_EDITOR"
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(not prefs.session_auth(context))
# ---------REGISTER ----------
# classes that inherit from another need to be registered first for some reason
classes = [KITSU_PT_comp_auth, KITSU_PT_sqe_auth, KITSU_PT_vi3d_auth]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
-88
View File
@@ -1,88 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from pathlib import Path
FPS = 24
VERSION_PATTERN = r"v\d\d\d"
FRAME_START = 101
# Naming Conventions Set by https://studio.blender.org/tools/naming-conventions/introduction
DELIMITER = "-" # Seperates items (e.g."{shot_name}-{shot_task}"")
SPACE_REPLACER = "_" # Represents spaces in a single item (e.g. "my shot name" = "my_shot_name")
ASSET_TASK_MAPPING = {
"geometry": "Geometry",
"grooming": "Grooming",
"modeling": "Modeling",
"rigging": "Rigging",
"sculpting": "Sculpting",
"shading": "Shading",
}
ASSET_TYPE_MAPPING = {
"chars": "Character",
"fx": "FX",
"libs": "Library",
"lgt": "Lighting",
"props": "Prop",
"sets": "Set",
}
SEQ_TASK_MAPPING = {
"previs": "Previsualization",
"boards": "Boards",
}
SHOT_TASK_MAPPING = {
"anim2D": "Anim2D",
"anim": "Animation",
"comp": "Compositing",
"fx": "FX",
"layout": "Layout",
"lighting": "Lighting",
"previz": "Previz",
"rendering": "Rendering",
"smear_to_mesh": "Smear to mesh",
"storyboard": "Storyboard",
}
PREFIX_RIG = "RIG-"
MULTI_ASSETS = [
"sprite",
"snail",
"spider",
"peanut",
"peanut_box",
"pretzel",
"corn_dart",
"corn_darts_bag",
"meat_stick",
"salty_twists_bag",
"salt_stick",
"salt_stix_package",
"briny_bear",
"briny_bears_bag",
] # list of assets that gets duplicated and therefore follows another naming sheme
ASSET_COLL_PREFIXES = ["CH-", "PR-", "SE-", "FX-", "EN-"]
# Kitsu Constants
KITSU_TV_PROJECT = 'tvshow'
# Kitsu Metadata Keys
KITSU_FILEPATH_KEY = "filepath"
KITSU_COLLECTION_KEY = "collection"
RES_DIR_PATH = Path(os.path.abspath(__file__)).parent.joinpath("res")
SCENE_NAME_PLAYBLAST = "playblast_playback"
PLAYBLAST_DEFAULT_STATUS = "Todo"
BUILD_SETTINGS_FILENAME = "settings.json"
BUILD_HOOKS_FILENAME = "hooks.py"
EDIT_TASK_TYPE = "Edit"
@@ -1,26 +0,0 @@
schema_version = "1.0.0"
id = "blender_kitsu"
version = "0.1.6"
name = "Blender Kitsu"
tagline = "Blender integration with the Kitsu production tracker system"
maintainer = "Blender Studio"
type = "add-on"
website = "https://studio.blender.org/tools/addons/blender_kitsu"
tags = ["Pipeline"]
blender_version_min = "4.2.0"
license = [
"SPDX:GPL-3.0-or-later",
]
copyright = [
"2019-2025 Paul Golter & Blender Studio",
]
wheels = [
"./wheels/bidict-0.22.1-py3-none-any.whl",
"./wheels/gazu-0.9.4-py2.py3-none-any.whl",
"./wheels/python_engineio-4.5.1-py3-none-any.whl",
"./wheels/python_socketio-5.8.0-py3-none-any.whl",
]
-831
View File
@@ -1,831 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, List, Optional, Union, Dict, Tuple
import bpy
from bpy.app.handlers import persistent
from .types import (
Project,
Episode,
Edit,
Sequence,
Shot,
Asset,
AssetType,
TaskType,
Task,
ProjectList,
TaskStatus,
Cache,
User,
)
from .logger import LoggerFactory
import gazu
from .util import addon_prefs_get
logger = LoggerFactory.getLogger()
# CACHE VARIABLES
# used to cache active entities to prevent a new api request when read only
_project_active: Project = Project()
_episode_active: Episode = Episode()
_sequence_active: Sequence = Sequence()
_shot_active: Shot = Shot()
_asset_active: Asset = Asset()
_edit_active: Edit = Edit()
_asset_type_active: AssetType = AssetType()
_task_type_active: TaskType = TaskType()
_user_active: User = User()
_user_all_tasks: List[Task] = []
_cache_initialized: bool = False
_cache_startup_initialized: bool = False
_episodes_enum_list: List[Tuple[str, str, str]] = []
_sequence_enum_list: List[Tuple[str, str, str]] = []
_shot_enum_list: List[Tuple[str, str, str]] = []
_asset_types_enum_list: List[Tuple[str, str, str]] = []
_asset_enum_list: List[Tuple[str, str, str]] = []
_projects_enum_list: List[Tuple[str, str, str]] = []
_task_types_enum_list: List[Tuple[str, str, str]] = []
_task_types_shots_enum_list: List[Tuple[str, str, str]] = []
_task_statuses_enum_list: List[Tuple[str, str, str]] = []
_user_all_tasks_enum_list: List[Tuple[str, str, str]] = []
_all_edits_enum_list: List[Tuple[str, str, str]] = []
_asset_cache_proj_id: str = ""
_episode_cache_proj_id: str = ""
_all_shot_tasks_cache_proj_id: str = ""
_all_task_type_cache_proj_id: str = ""
_seq_cache_proj_id: str = ""
_seq_cache_episode_id: str = ""
_shot_cache_seq_id: str = ""
_task_type_cache_shot_id: str = ""
_asset_cache_asset_type_id: str = ''
_all_edits_cache_proj_id: str = ""
def user_active_get() -> User:
global _user_active
return _user_active
def project_active_get() -> Project:
global _project_active
return _project_active
def project_active_set_by_id(context: bpy.types.Context, entity_id: str) -> None:
global _project_active
_project_active = Project.by_id(entity_id)
addon_prefs_get(context).project_active_id = entity_id
logger.debug("Set active project to %s", _project_active.name)
def project_active_reset(context: bpy.types.Context) -> None:
global _project_active
_project_active = Project()
addon_prefs_get(context).project_active_id = ""
logger.debug("Reset active project")
def episode_active_get() -> Project:
global _episode_active
return _episode_active
def episode_active_set_by_id(context: bpy.types.Context, entity_id: str) -> None:
global _episode_active
_episode_active = Episode.by_id(entity_id)
addon_prefs_get(context).episode_active_id = entity_id
logger.debug("Set active episode to %s", _episode_active.name)
def episode_active_reset_entity():
global _episode_active
_episode_active = Episode()
def episode_active_reset(context: bpy.types.Context) -> None:
episode_active_reset_entity()
context.scene.kitsu.episode_active_id = ""
context.scene.kitsu.episode_active_name = ""
logger.debug("Reset active episode")
def sequence_active_get() -> Sequence:
return _sequence_active
def sequence_active_set_by_id(context: bpy.types.Context, entity_id: str) -> None:
global _sequence_active
_sequence_active = Sequence.by_id(entity_id)
context.scene.kitsu.sequence_active_id = entity_id
logger.debug("Set active sequence to %s", _sequence_active.name)
def sequence_active_reset_entity() -> None:
global _sequence_active
_sequence_active = Sequence()
def sequence_active_reset(context: bpy.types.Context) -> None:
sequence_active_reset_entity()
context.scene.kitsu.sequence_active_id = ""
context.scene.kitsu.sequence_active_name = ""
logger.debug("Reset active sequence")
def output_collection_name_get() -> str:
active_shot = shot_active_get()
task_type = task_type_active_get()
output_col_name = active_shot.get_output_collection_name(task_type.get_short_name())
return output_col_name
def shot_active_get() -> Shot:
global _shot_active
return _shot_active
def shot_active_pull_update() -> Shot:
global _shot_active
Cache.clear_all()
shot_active_id = bpy.context.scene.kitsu.shot_active_id
_init_cache_entity(shot_active_id, Shot, "_shot_active", "shot")
return _shot_active
def shot_active_set_by_id(context: bpy.types.Context, entity_id: str) -> None:
global _shot_active
_shot_active = Shot.by_id(entity_id)
context.scene.kitsu.shot_active_id = entity_id
logger.debug("Set active shot to %s", _shot_active.name)
def shot_active_reset_entity() -> None:
global _shot_active
_shot_active = Shot()
def shot_active_reset(context: bpy.types.Context) -> None:
shot_active_reset_entity()
context.scene.kitsu.shot_active_id = ""
context.scene.kitsu.shot_active_name = ""
logger.debug("Reset active shot")
def asset_active_get() -> Asset:
global _asset_active
return _asset_active
def asset_active_set_by_id(context: bpy.types.Context, entity_id: str) -> None:
global _asset_active
_asset_active = Asset.by_id(entity_id)
context.scene.kitsu.asset_active_id = entity_id
logger.debug("Set active asset to %s", _asset_active.name)
def asset_active_reset_entity() -> None:
global _asset_active
_asset_active = Asset()
def asset_active_reset(context: bpy.types.Context) -> None:
asset_active_reset_entity()
context.scene.kitsu.asset_active_id = ""
context.scene.kitsu.asset_active_name = ""
logger.debug("Reset active asset")
def asset_type_active_get() -> AssetType:
global _asset_type_active
return _asset_type_active
def asset_type_active_set_by_id(context: bpy.types.Context, entity_id: str) -> None:
global _asset_type_active
if _asset_type_active != AssetType.by_id(entity_id):
asset_active_reset(context)
_asset_type_active = AssetType.by_id(entity_id)
context.scene.kitsu.asset_type_active_id = entity_id
logger.debug("Set active asset type to %s", _asset_type_active.name)
def asset_type_active_reset_entity() -> None:
global _asset_type_active
_asset_type_active = AssetType()
def asset_type_active_reset(context: bpy.types.Context) -> None:
asset_type_active_reset_entity()
context.scene.kitsu.asset_type_active_id = ""
context.scene.kitsu.asset_type_active_name = ""
logger.debug("Reset active asset type")
def task_type_active_get() -> TaskType:
global _task_type_active
return _task_type_active
def task_type_active_set_by_id(context: bpy.types.Context, entity_id: str) -> None:
global _task_type_active
_task_type_active = TaskType.by_id(entity_id)
context.scene.kitsu.task_type_active_id = entity_id
logger.debug("Set active task type to %s", _task_type_active.name)
def task_type_active_reset_entity() -> None:
global _task_type_active
_task_type_active = TaskType()
def task_type_active_reset(context: bpy.types.Context) -> None:
task_type_active_reset_entity()
context.scene.kitsu.task_type_active_id = ""
context.scene.kitsu.task_type_active_name = ""
logger.debug("Reset active task type")
def edit_active_set_by_id(context: bpy.types.Context, entity_id: str) -> None:
global _edit_active
_edit_active = Edit.by_id(entity_id)
context.scene.kitsu.edit_active_id = entity_id
logger.debug("Set active edit to %s", _task_type_active.name)
def edit_active_reset_entity() -> None:
global _edit_active
_edit_active = TaskType()
def edit_active_get() -> Project:
global _edit_active
return _edit_active
def edit_default_get(create: bool = False, episode_id: Optional[str] = None) -> Edit:
global _edit_active
episode = Episode.by_id(episode_id) if episode_id else episode_active_get()
_edit_active = Edit.get_project_default_edit(
project_active_get(), create=create, episode=episode
)
return _edit_active
def task_type_active_reset(context: bpy.types.Context) -> None:
edit_active_reset_entity()
context.scene.kitsu.edit_active_id = ""
context.scene.kitsu.edit_active_name = ""
logger.debug("Reset active edit")
def get_projects_enum_list(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _projects_enum_list
if not addon_prefs_get(context).session.is_auth():
return []
projectlist = ProjectList()
_projects_enum_list.clear()
_projects_enum_list.extend([(p.id, p.name, p.description or "") for p in projectlist.projects])
return _projects_enum_list
def get_episodes_enum_list(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _episodes_enum_list
global _episode_cache_proj_id
if not addon_prefs_get(context).session.is_auth():
return []
project_active = project_active_get()
if not project_active:
return []
# Return Cached list if project hasn't changed
if _episode_cache_proj_id == project_active.id:
return _episodes_enum_list
# Update Cache Sequence ID
_episode_cache_proj_id = project_active.id
_episodes_enum_list.clear()
_episodes_enum_list.extend(
[(e.id, e.name, e.description or "") for e in project_active.get_episodes_all()]
)
return _episodes_enum_list
def reset_sequences_enum_list() -> None:
global _sequence_enum_list
global _seq_cache_proj_id
global _seq_cache_episode_id
_sequence_enum_list.clear()
_seq_cache_proj_id = ""
_seq_cache_episode_id = ""
def get_sequences_enum_list(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _sequence_enum_list
global _seq_cache_proj_id
global _seq_cache_episode_id
project_active = project_active_get()
episode_active = episode_active_get()
if not project_active:
return []
# Return Cached list if project hasn't changed
if _seq_cache_proj_id == project_active.id and _seq_cache_episode_id == episode_active.id:
return _sequence_enum_list
# Update Cache Sequence ID
_seq_cache_proj_id = project_active.id
_sequence_enum_list.clear()
if episode_active:
_sequence_enum_list.extend(
[(s.id, s.name, s.description or "") for s in episode_active.get_sequences_all()]
)
else:
_sequence_enum_list.extend(
[(s.id, s.name, s.description or "") for s in project_active.get_sequences_all()]
)
return _sequence_enum_list
def get_shots_enum_for_active_seq(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _shot_enum_list
global _shot_cache_seq_id
seq_active = sequence_active_get()
if not seq_active:
return []
# Return Cached list if sequence hasn't changed
if _shot_cache_seq_id == seq_active.id:
return _shot_enum_list
# Update Cache Sequence ID
_shot_cache_seq_id = seq_active.id
_shot_enum_list.clear()
_shot_enum_list.extend(
[(s.id, s.name, s.description or "") for s in seq_active.get_all_shots()]
)
return _shot_enum_list
def get_shots_enum_for_seq(
self: bpy.types.Operator, context: bpy.types.Context, sequence: Sequence
) -> List[Tuple[str, str, str]]:
global _shot_enum_list
_shot_enum_list.clear()
_shot_enum_list.extend([(s.id, s.name, s.description or "") for s in sequence.get_all_shots()])
return _shot_enum_list
def get_assetypes_enum_list(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _asset_types_enum_list
global _asset_cache_proj_id
project_active = project_active_get()
if not project_active:
return []
# Return Cached list if project hasn't changed
if _asset_cache_proj_id == project_active.id:
return _asset_types_enum_list
# Update Cache Sequence ID
_asset_cache_proj_id = project_active.id
_asset_types_enum_list.clear()
_asset_types_enum_list.extend(
[(at.id, at.name, "") for at in project_active.get_all_asset_types()]
)
return _asset_types_enum_list
def get_assets_enum_for_active_asset_type(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _asset_enum_list
global _asset_cache_asset_type_id
project_active = project_active_get()
asset_type_active = asset_type_active_get()
episode_active = episode_active_get()
if not project_active or not asset_type_active:
return []
if _asset_cache_asset_type_id == asset_type_active.id:
return _asset_enum_list
_asset_cache_asset_type_id = asset_type_active.id
all_assets = project_active.get_all_assets_for_type(asset_type_active)
_asset_enum_list.clear()
if not episode_active:
_asset_enum_list.extend([(a.id, a.name, a.description or "") for a in all_assets])
else:
episode_assets = filter(
lambda p: p.source_id == episode_active.id or p.source_id is None, all_assets
)
_asset_enum_list.extend([(a.id, a.name, a.description or "") for a in episode_assets])
return _asset_enum_list
def get_task_types_enum_for_current_context(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _task_types_enum_list
global _project_active
# Import within function to avoid circular import
from .context import core as context_core
items = []
if context_core.is_shot_context():
items = [
(t.id, t.name, "")
for t in TaskType.all_shot_task_types()
if t.id in _project_active.task_types
]
if context_core.is_asset_context():
items = [
(t.id, t.name, "")
for t in TaskType.all_asset_task_types()
if t.id in _project_active.task_types
]
if context_core.is_sequence_context():
items = [
(t.id, t.name, "")
for t in TaskType.all_sequence_task_types()
if t.id in _project_active.task_types
]
if context_core.is_edit_context():
items = [
(t.id, t.name, "")
for t in TaskType.all_edit_task_types()
if t.id in _project_active.task_types
]
_task_types_enum_list.clear()
_task_types_enum_list.extend(items)
return _task_types_enum_list
def get_shot_task_types_enum(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
# Returns all avaliable task types across all shots in the current project
global _task_types_shots_enum_list
global _all_shot_tasks_cache_proj_id
project_active = project_active_get()
# Return Cached list if project hasn't changed
if _all_shot_tasks_cache_proj_id == project_active.id:
return _task_types_shots_enum_list
# Update Cache project ID
_all_shot_tasks_cache_proj_id = project_active.id
items = [(t.id, t.name, "") for t in TaskType.all_shot_task_types() if t.id in project_active.task_types]
_task_types_shots_enum_list.clear()
_task_types_shots_enum_list.extend(items)
return _task_types_shots_enum_list
def get_shot_task_types_enum_for_shot( # TODO Rename
self: bpy.types.Operator, context: bpy.types.Context, shot: Shot
) -> List[Tuple[str, str, str]]:
global _task_types_shots_enum_list
global _task_type_cache_shot_id
# Return Cached list if shot hasn't changed
if _task_type_cache_shot_id == shot.id:
return _task_types_shots_enum_list
# Update Cache Sequence ID
_task_type_cache_shot_id = shot.id
items = [(t.id, t.name, "") for t in shot.get_all_task_types()]
_task_types_shots_enum_list.clear()
_task_types_shots_enum_list.extend(items)
return _task_types_shots_enum_list
def reset_all_edits_enum_for_active_project() -> None:
global _all_edits_cache_proj_id
global _all_edits_enum_list
_all_edits_cache_proj_id = None
_all_edits_enum_list.clear()
gazu.cache.clear_all()
def get_all_edits_enum_for_active_project(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _project_active
global _all_edits_cache_proj_id
global _all_edits_enum_list
if _all_edits_cache_proj_id == _project_active.id:
return _all_edits_enum_list
# Update Cache Project ID
_all_edits_cache_proj_id = _project_active.id
items = [(t.id, t.name, "") for t in _project_active.get_all_edits()]
_all_edits_enum_list.clear()
_all_edits_enum_list.extend(items)
return _all_edits_enum_list
def get_all_task_statuses_enum(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _task_statuses_enum_list
global _all_task_type_cache_proj_id
project_active = project_active_get()
# Return Cached list if project hasn't changed
if _all_task_type_cache_proj_id == project_active.id:
return _task_statuses_enum_list
# Update Cache project ID
_all_task_type_cache_proj_id = project_active.id
items = [(t.id, t.name, "") for t in TaskStatus.all_task_statuses()]
_task_statuses_enum_list.clear()
_task_statuses_enum_list.extend(items)
return _task_statuses_enum_list
def load_user_all_tasks(context: bpy.types.Context) -> List[Task]:
global _user_all_tasks
global _user_active
tasks = _user_active.all_tasks_to_do()
_user_all_tasks.clear()
_user_all_tasks.extend(tasks)
_update_tasks_collection_prop(context)
logger.debug("Loaded assigned tasks for: %s", _user_active.full_name)
return _user_all_tasks
def _update_tasks_collection_prop(context: bpy.types.Context) -> None:
global _user_all_tasks
addon_prefs = addon_prefs_get(bpy.context)
tasks_coll_prop = addon_prefs.tasks
# Get current index.
idx = context.window_manager.kitsu.tasks_index
# Clear all old tasks.
tasks_coll_prop.clear()
# Populate with new tasks.
for task in _user_all_tasks:
item = tasks_coll_prop.add()
item.id = task.id
item.entity_id = task.entity_id
item.entity_name = task.entity_name
item.task_type_id = task.task_type_id
item.task_type_name = task.task_type_name
# Update index.
idx = len(tasks_coll_prop) - 1
def get_user_all_tasks_enum(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _user_all_tasks_enum_list
global _user_all_tasks
enum_items = [(t.id, t.name, "") for t in _user_all_tasks]
_user_all_tasks_enum_list.clear()
_user_all_tasks_enum_list.extend(enum_items)
return _user_all_tasks_enum_list
def get_user_all_tasks() -> List[Task]:
global _user_all_tasks
return _user_all_tasks
def _init_cache_entity(
entity_id: str, entity_type: Any, cache_variable_name: Any, cache_name: str
) -> None:
if entity_id:
try:
globals()[cache_variable_name] = entity_type.by_id(entity_id)
logger.debug(
"Initiated active %s cache to: %s",
cache_name,
globals()[cache_variable_name].name,
)
except gazu.exception.RouteNotFoundException:
logger.error(
"Failed to initialize active %s cache. ID not found on server: %s",
cache_name,
entity_id,
)
def init_startup_variables(context: bpy.types.Context) -> None:
addon_prefs = addon_prefs_get(context)
global _cache_startup_initialized
global _user_active
global _user_all_tasks
if not addon_prefs.session.is_auth():
logger.debug("Skip initiating startup cache. Session not authorized")
return
if _cache_startup_initialized:
logger.debug("Startup Cache already initiated")
return
# User.
_user_active = User()
logger.debug("Initiated active user cache to: %s", _user_active.full_name)
# User Tasks.
load_user_all_tasks(context)
logger.debug("Initiated active user tasks")
_cache_startup_initialized = True
def clear_startup_variables():
global _user_active
global _user_all_tasks
global _cache_startup_initialized
_user_active = User()
logger.debug("Cleared active user cache")
_user_all_tasks.clear()
_update_tasks_collection_prop(bpy.context)
logger.debug("Cleared active user all tasks cache")
_cache_startup_initialized = False
def init_cache_variables() -> None:
global _project_active
global _episode_active
global _sequence_active
global _shot_active
global _asset_active
global _asset_type_active
global _task_type_active
global _cache_initialized
addon_prefs = addon_prefs_get(bpy.context)
if not addon_prefs.session.is_auth():
logger.debug("Skip initiating cache. Session not authorized")
return
if _cache_initialized:
logger.debug("Cache already initiated")
return
project_active_id = addon_prefs.project_active_id
episode_active_id = bpy.context.scene.kitsu.episode_active_id
sequence_active_id = bpy.context.scene.kitsu.sequence_active_id
shot_active_id = bpy.context.scene.kitsu.shot_active_id
asset_active_id = bpy.context.scene.kitsu.asset_active_id
asset_type_active_id = bpy.context.scene.kitsu.asset_type_active_id
task_type_active_id = bpy.context.scene.kitsu.task_type_active_id
_init_cache_entity(project_active_id, Project, "_project_active", "project")
_init_cache_entity(episode_active_id, Episode, "_episode_active", "episode")
_init_cache_entity(sequence_active_id, Sequence, "_sequence_active", "sequence")
_init_cache_entity(asset_type_active_id, AssetType, "_asset_type_active", "asset type")
_init_cache_entity(shot_active_id, Shot, "_shot_active", "shot")
_init_cache_entity(asset_active_id, Asset, "_asset_active", "asset")
_init_cache_entity(task_type_active_id, TaskType, "_task_type_active", "task type")
_cache_initialized = True
def clear_cache_variables():
global _project_active
global _episode_active
global _sequence_active
global _shot_active
global _asset_active
global _asset_type_active
global _task_type_active
global _cache_initialized
_user_active = User()
logger.debug("Cleared active user cache")
_shot_active = Shot()
logger.debug("Cleared active shot cache")
_asset_active = Asset()
logger.debug("Cleared active asset cache")
_sequence_active = Sequence()
logger.debug("Cleared active aequence cache")
_asset_type_active = AssetType()
logger.debug("Cleared active asset type cache")
_project_active = Project()
logger.debug("Cleared active project cache")
_task_type_active = TaskType()
logger.debug("Cleared active task type cache")
_cache_initialized = False
@persistent
def load_post_handler_update_cache(dummy: Any) -> None:
clear_cache_variables()
init_cache_variables()
@persistent
def load_post_handler_init_startup_variables(dummy: Any) -> None:
init_startup_variables(bpy.context)
# ---------REGISTER ----------.
def register():
# Handlers.
bpy.app.handlers.load_post.append(load_post_handler_update_cache)
bpy.app.handlers.load_post.append(load_post_handler_init_startup_variables)
def unregister():
# Clear handlers.
bpy.app.handlers.load_post.remove(load_post_handler_init_startup_variables)
bpy.app.handlers.load_post.remove(load_post_handler_update_cache)
@@ -1,27 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from ..context 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()
@@ -1,80 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .. import cache, bkglobals
# Category values are defined in enum props.py KITSU_property_group_scene under category
def is_edit_context():
return bpy.context.scene.kitsu.category == "EDIT"
def is_sequence_context():
return bpy.context.scene.kitsu.category == "SEQ"
def is_asset_context():
return bpy.context.scene.kitsu.category == "ASSET"
def is_shot_context():
return bpy.context.scene.kitsu.category == "SHOT"
def active_project_row(layout: bpy.types.UILayout) -> bpy.types.UILayout:
project_active = cache.project_active_get()
row = layout.row(align=True)
if not project_active:
row.enabled = False
return row
def active_episode_row(layout: bpy.types.UILayout) -> None:
episode_active = cache.episode_active_get()
project_active = cache.project_active_get()
row = active_project_row(layout)
if project_active.production_type == bkglobals.KITSU_TV_PROJECT and not episode_active:
row.enabled = False
return row
def draw_episode_selector(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
row = active_project_row(layout)
row.prop(context.scene.kitsu, "episode_active_name")
def draw_sequence_selector(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
row = active_episode_row(layout)
row.prop(context.scene.kitsu, "sequence_active_name")
def draw_asset_type_selector(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
row = active_project_row(layout)
row.prop(context.scene.kitsu, "asset_type_active_name")
def draw_shot_selector(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
row = active_episode_row(layout)
row.prop(context.scene.kitsu, "shot_active_name")
def draw_asset_selector(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
row = active_project_row(layout)
row.prop(context.scene.kitsu, "asset_active_name")
def draw_edit_selector(context: bpy.types.Context, layout: bpy.types.UILayout) -> None:
row = active_project_row(layout)
row.prop(context.scene.kitsu, "edit_active_name")
def draw_task_type_selector(context: bpy.types.Context, layout: bpy.types.UILayout):
layout.prop(context.scene.kitsu, "task_type_active_name")
def get_versioned_file_basename(name: str) -> str:
"""Removes the version suffix from a file name if it exists and returns basename"""
return name.split(bkglobals.DELIMITER + "v")[0]
-343
View File
@@ -1,343 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Dict, Optional, Set
from pathlib import Path
import bpy
from .. import bkglobals, cache, util, prefs
from ..logger import LoggerFactory
from ..types import TaskType, AssetType
from ..context import core as context_core
logger = LoggerFactory.getLogger()
class KITSU_OT_con_productions_load(bpy.types.Operator):
"""
Gets all productions that are available in server and let's user select. Invokes a search Popup (enum_prop) on click.
"""
bl_idname = "kitsu.con_productions_load"
bl_label = "Productions Load"
bl_property = "enum_prop"
bl_description = "Sets active project"
enum_prop: bpy.props.EnumProperty(items=cache.get_projects_enum_list) # type: ignore
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return prefs.session_auth(context)
def execute(self, context: bpy.types.Context) -> Set[str]:
# Store vars to check if project / seq / shot changed.
project_prev_id = cache.project_active_get().id
# Update kitsu metadata.
cache.project_active_set_by_id(context, self.enum_prop)
# Clear active shot when sequence changes.
if self.enum_prop != project_prev_id:
cache.sequence_active_reset(context)
cache.episode_active_reset(context)
cache.asset_type_active_reset(context)
cache.shot_active_reset(context)
cache.asset_active_reset(context)
util.ui_redraw()
return {"FINISHED"}
def invoke(self, context, event):
context.window_manager.invoke_search_popup(self)
return {"FINISHED"}
class KITSU_OT_con_detect_context(bpy.types.Operator):
bl_idname = "kitsu.con_detect_context"
bl_label = "Detect Context"
bl_description = "Auto detects context by looking at file path"
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(
prefs.session_auth(context) and cache.project_active_get() and bpy.data.filepath
)
def execute(self, context: bpy.types.Context) -> Set[str]:
# Update kitsu metadata.
filepath = Path(bpy.data.filepath)
active_project = cache.project_active_get()
kitsu_props = context.scene.kitsu
addon_prefs = context.preferences.addons['.'.join(__package__.split('.')[:-1])].preferences
is_edit_type = str(filepath.resolve()).startswith(str(prefs.project_root_dir_get(context) / addon_prefs.edit_dir_name))
if is_edit_type:
kitsu_props.category = "EDIT"
if active_project.production_type == bkglobals.KITSU_TV_PROJECT:
episode = active_project.get_episode_by_name(filepath.parents[0].name)
if episode:
kitsu_props.episode_active_name = episode.name
kitsu_props.edit_active_name = context_core.get_versioned_file_basename(filepath.stem)
kitsu_props.task_type_active_name = bkglobals.EDIT_TASK_TYPE
util.ui_redraw()
return {"FINISHED"}
# TODO REFACTOR THIS WHOLE THING, BAD HACK
# Path is different for tvshow
if (
active_project.production_type == bkglobals.KITSU_TV_PROJECT
and filepath.parents[3].name == addon_prefs.shot_dir_name
):
episode = active_project.get_episode_by_name(filepath.parents[2].name)
category = filepath.parents[3].name
else:
episode = None
category = filepath.parents[2].name
item_group = filepath.parents[1].name
item = filepath.parents[0].name
item_task_type = filepath.stem.split(bkglobals.DELIMITER)[-1]
# Sanity check that the folder struture is correct depending on the type.
is_shot_type = category == addon_prefs.shot_dir_name
is_seq_type = category == addon_prefs.seq_dir_name
is_asset_type = category == addon_prefs.asset_dir_name
if not is_shot_type and not is_seq_type and not is_asset_type:
self.report(
{"ERROR"},
(
f"Expected '{addon_prefs.shot_dir_name}' or '{addon_prefs.asset_dir_name}' 3 folders up. "
f"Got: '{filepath.parents[2].as_posix()}' instead. "
"Blend file might not be saved in project structure"
),
)
return {"CANCELLED"}
if is_shot_type or is_seq_type:
# TODO: check if frame range update gets triggered.
# Set category.
if is_shot_type:
kitsu_props.category = "SHOT"
task_mapping = bkglobals.SHOT_TASK_MAPPING
else:
kitsu_props.category = "SEQ"
task_mapping = bkglobals.SEQ_TASK_MAPPING
if episode:
kitsu_props.episode_active_name = episode.name
# Detect and load sequence.
sequence = active_project.get_sequence_by_name(item_group, episode)
if not sequence:
self.report({"ERROR"}, f"Failed to find sequence: '{item_group}' on server")
return {"CANCELLED"}
kitsu_props.sequence_active_name = sequence.name
if is_shot_type:
# Detect and load shot.
shot = active_project.get_shot_by_name(sequence, item)
if not shot:
self.report({"ERROR"}, f"Failed to find shot: '{item}' on server")
return {"CANCELLED"}
kitsu_props.shot_active_name = shot.name
# Detect and load shot task type.
kitsu_task_type_name = self._find_in_mapping(
item_task_type, task_mapping, "shot task type"
)
if not kitsu_task_type_name:
return {"CANCELLED"}
task_type = TaskType.by_name(kitsu_task_type_name)
if not task_type:
self.report(
{"ERROR"},
f"Failed to find task type: '{kitsu_task_type_name}' on server",
)
return {"CANCELLED"}
kitsu_props.task_type_active_name = task_type.name
elif is_asset_type:
# Set category.
kitsu_props.category = "ASSET"
# Detect and load asset type.
kitsu_asset_type_name = self._find_in_mapping(
item_group, bkglobals.ASSET_TYPE_MAPPING, "asset type"
)
if not kitsu_asset_type_name:
return {"CANCELLED"}
asset_type = AssetType.by_name(kitsu_asset_type_name)
if not asset_type:
self.report(
{"ERROR"},
f"Failed to find asset type: '{kitsu_asset_type_name}' on server",
)
return {"CANCELLED"}
kitsu_props.asset_type_active_name = asset_type.name
# Detect and load asset.
asset = active_project.get_asset_by_name(item)
if not asset:
self.report({"ERROR"}, f"Failed to find asset: '{item}' on server")
return {"CANCELLED"}
kitsu_props.asset_active_name = asset.name
# If split == 1 then filepath has no task type in name, skip asset task_type
if len(filepath.stem.split(bkglobals.DELIMITER)) > 1:
# Detect and load asset task_type.
kitsu_task_type_name = self._find_in_mapping(
item_task_type, bkglobals.ASSET_TASK_MAPPING, "task type"
)
if not kitsu_task_type_name:
return {"CANCELLED"}
task_type = TaskType.by_name(kitsu_task_type_name)
if not task_type:
self.report(
{"ERROR"},
f"Failed to find task type: '{kitsu_task_type_name}' on server",
)
return {"CANCELLED"}
kitsu_props.task_type_active_name = task_type.name
util.ui_redraw()
self.report({"INFO"}, f"Context Successfully Set!")
return {"FINISHED"}
def _find_in_mapping(
self, key: str, mapping: Dict[str, str], entity_type: str
) -> Optional[str]:
if not key in mapping:
self.report(
{"ERROR"},
f"Failed to find {entity_type}: '{key}' in {entity_type} remapping",
)
return None
return mapping[key]
class KITSU_OT_con_set_asset(bpy.types.Operator):
bl_idname = "kitsu.con_set_asset"
bl_label = "Set Kitsu Asset"
bl_description = (
"Mark the current file & target collection as an Asset on Kitsu Server "
"Assets marked with this method will be automatically loaded by the "
"Shot Builder, if the Asset is casted to the buider's target shot"
)
_published_file_path: Path = None
use_asset_pipeline_publish: bpy.props.BoolProperty( # type: ignore
name="Use Asset Pipeline Publish",
description=(
"Find the Publish of this file in the 'Publish' folder and use it's filepath for Kitsu Asset`"
"Selected Collection must be named exactly the same between current file and Publish"
),
default=False,
)
@classmethod
def poll(cls, context):
kitsu_props = context.scene.kitsu
if bpy.data.filepath == "":
cls.poll_message_set("Blend file must be saved")
return False
if not bpy.data.filepath.startswith(str(prefs.project_root_dir_get(context))):
cls.poll_message_set("Blend file must be saved in project structure")
return False
if not context_core.is_asset_context():
cls.poll_message_set("Kitsu Context panel must be set to 'Asset'")
return False
if kitsu_props.asset_type_active_name == "":
cls.poll_message_set("Asset Type must be set")
return False
if kitsu_props.asset_active_name == "":
cls.poll_message_set("Asset must be set")
return False
if not kitsu_props.asset_col:
cls.poll_message_set("Asset Collection must be set")
return False
return True
def is_asset_pipeline_enabled(self, context) -> bool:
for addon in context.preferences.addons:
if addon.module == "asset_pipeline":
return True
return False
def is_asset_pipeline_folder(self, context) -> bool:
current_folder = Path(bpy.data.filepath).parent
return current_folder.joinpath("task_layers.json").exists()
def get_asset_pipeline_publish(self, context) -> Path:
from asset_pipeline.merge.publish import find_latest_publish
return find_latest_publish(Path(bpy.data.filepath))
def invoke(self, context, event):
if self.is_asset_pipeline_enabled(context) and self.is_asset_pipeline_folder(context):
self._published_file_path = self.get_asset_pipeline_publish(context)
if self._published_file_path.exists():
self.use_asset_pipeline_publish = True
wm = context.window_manager
return wm.invoke_props_dialog(self)
return self.execute(context)
def draw(self, context):
layout = self.layout
relative_path = self._published_file_path.relative_to(Path(bpy.data.filepath).parent)
box = layout.box()
box.enabled = self.use_asset_pipeline_publish
box.label(text=f"//{str(relative_path)}")
layout.prop(self, "use_asset_pipeline_publish")
def execute(self, context):
project_root = prefs.project_root_dir_get(context)
if self.use_asset_pipeline_publish:
relative_path = self._published_file_path.relative_to(project_root)
else:
relative_path = Path(bpy.data.filepath).relative_to(project_root)
blender_asset = context.scene.kitsu.asset_col
kitsu_asset = cache.asset_active_get()
if not kitsu_asset:
self.report({"ERROR"}, "Failed to find active Kitsu Asset")
return {"CANCELLED"}
kitsu_asset.set_asset_path(str(relative_path), blender_asset.name)
self.report(
{"INFO"},
f"Kitsu Asset '{kitsu_asset.name}' set to Collection '{blender_asset.name}' at path '{relative_path}'",
)
return {"FINISHED"}
# ---------REGISTER ----------.
classes = [
KITSU_OT_con_productions_load,
KITSU_OT_con_detect_context,
KITSU_OT_con_set_asset,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
-156
View File
@@ -1,156 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from ..context import core as context_core
from .. import cache, prefs, ui, bkglobals
from ..context.ops import KITSU_OT_con_detect_context, KITSU_OT_con_set_asset
class KITSU_PT_vi3d_context(bpy.types.Panel):
"""
Panel in 3dview that enables browsing through backend data structure.
Thought of as a menu to setup a context by selecting active production
active sequence, shot etc.
"""
bl_category = "Kitsu"
bl_label = "Context"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 20
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return prefs.session_auth(context)
@classmethod
def poll_error(cls, context: bpy.types.Context) -> bool:
project_active = cache.project_active_get()
return bool(not project_active)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
project_active = cache.project_active_get()
episode_active = cache.episode_active_get()
# Catch errors
if self.poll_error(context):
box = ui.draw_error_box(layout)
if not project_active:
ui.draw_error_active_project_unset(box)
# Production
layout.row().label(text=f"Production: {project_active.name}")
layout.row(align=True)
row = layout.row(align=True)
row.label(text="Browser", icon="FILEBROWSER")
# Detect Context
row.operator(
KITSU_OT_con_detect_context.bl_idname,
icon="FILE_REFRESH",
text="",
emboss=False,
)
flow = layout.grid_flow(
row_major=True, columns=0, even_columns=True, even_rows=False, align=False
)
col = flow.column()
# Entity context
col.prop(context.scene.kitsu, "category")
if not prefs.session_auth(context) or not project_active:
row.enabled = False
# Episode selector
if project_active.production_type == bkglobals.KITSU_TV_PROJECT:
context_core.draw_episode_selector(context, col)
# Sequence selector (if context is Sequence)
if context_core.is_sequence_context():
context_core.draw_sequence_selector(context, col)
# Shot selector
if context_core.is_shot_context():
context_core.draw_sequence_selector(context, col)
context_core.draw_shot_selector(context, col)
# AssetType selector (if context is Asset)
if context_core.is_asset_context():
context_core.draw_asset_type_selector(context, col)
context_core.draw_asset_selector(context, col)
if context_core.is_edit_context():
context_core.draw_edit_selector(context, col)
# Task Type selector
context_core.draw_task_type_selector(context, col)
if context.scene.kitsu_error.frame_range:
box = ui.draw_error_box(layout)
ui.draw_error_frame_range_outdated(box)
class KITSU_PT_set_asset(bpy.types.Panel):
"""
Panel in 3dview that enables browsing through backend data structure.
Thought of as a menu to setup a context by selecting active production
active sequence, shot etc.
"""
bl_category = "Kitsu"
bl_label = "Set Asset"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 25
bl_parent_id = "KITSU_PT_vi3d_context"
@classmethod
def poll(cls, context):
return context_core.is_asset_context()
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
col = layout.column()
col.prop(context.scene.kitsu, "asset_col")
col.operator(KITSU_OT_con_set_asset.bl_idname)
class KITSU_PT_comp_context(KITSU_PT_vi3d_context):
bl_space_type = "NODE_EDITOR"
class KITSU_PT_editorial_context(KITSU_PT_vi3d_context):
bl_space_type = "SEQUENCE_EDITOR"
# ---------REGISTER ----------.
# Classes that inherit from another need to be registered first for some reason.
classes = [
KITSU_PT_comp_context,
KITSU_PT_editorial_context,
KITSU_PT_vi3d_context,
KITSU_PT_set_asset,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,17 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
def preload_modules() -> None:
"""Pre-load the datetime module from a wheel so that the API can find it."""
import sys
if "gazu" in sys.modules:
return
from . import wheels
wheels.load_wheel_global("bidict", "bidict")
wheels.load_wheel_global("engineio", "python_engineio")
wheels.load_wheel_global("socketio", "python_socketio")
wheels.load_wheel_global("gazu", "gazu")
@@ -1,27 +0,0 @@
# 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
@@ -1,95 +0,0 @@
# 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
@@ -1,355 +0,0 @@
# 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)
@@ -1,97 +0,0 @@
# 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
@@ -1,116 +0,0 @@
# 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)
-10
View File
@@ -1,10 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
class ShotInvalidException(Exception):
"""
Error raised when shot is not valid. For example has no self.id.
"""
pass
@@ -1,54 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
filetree_default = {
"working": {
"mountpoint": "",
"root": "",
"folder_path": {
"shot": "<Project>/shots/production/<Sequence>/<Shot>/<TaskType>/work",
"asset": "<Project>/assets/<AssetType>/<Asset>/<TaskType>/work",
"sequence": "<Project>/shots/production/<Sequence>/<TaskType>/work",
"style": "lowercase",
},
"file_name": {
"shot": "<Shot>_<TaskType>_work",
"asset": "<Asset>_<TaskType>_work",
"sequence": "<Sequence>_<TaskType>_work",
"style": "lowercase",
},
},
"output": {
"mountpoint": "",
"root": "",
"folder_path": {
"shot": "<Project>/shots/production/<Sequence>/<Shot>/<TaskType>/publish",
"asset": "<Project>/assets/<AssetType>/<Asset>/<TaskType>/publish",
"sequence": "<Project>/shots/production/<Sequence>/<TaskType>/publish",
"style": "lowercase",
},
"file_name": {
"shot": "<Shot>_<TaskType>_publish",
"asset": "<Asset>_<TaskType>_publish",
"sequence": "<Sequence>_<TaskType>_publish",
"style": "lowercase",
},
},
"preview": {
"mountpoint": "",
"root": "",
"folder_path": {
"shot": "<Project>/shots/production/<Sequence>/<Shot>/<TaskType>/preview",
"asset": "<Project>/assets/<AssetType>/<Asset>/<TaskType>/preview",
"sequence": "<Project>/shots/production/<Sequence>/<TaskType>/preview",
"style": "lowercase",
},
"file_name": {
"shot": "<Shot>_<TaskType>_preview",
"asset": "<Asset>_<TaskType>_preview",
"sequence": "<Sequence>_<TaskType>_preview",
"style": "lowercase",
},
},
}
@@ -1,23 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from ..generic import ops
# ---------REGISTER ----------.
def reload():
global ops
ops = importlib.reload(ops)
def register():
ops.register()
def unregister():
ops.unregister()
@@ -1,79 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import sys
import subprocess
import os
from pathlib import Path
from typing import Dict, List, Set, Optional, Tuple, Any
import bpy
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
class KITSU_OT_open_path(bpy.types.Operator):
bl_idname = "kitsu.open_path"
bl_label = "Open"
bl_description = "Opens specified path in the default system 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)
# ---------REGISTER ----------.
classes = [KITSU_OT_open_path]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
-47
View File
@@ -1,47 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from typing import List, Tuple
class LoggerFactory:
"""
Utility class to streamline logger creation
"""
@staticmethod
def getLogger(name="blender-kitsu"):
name = name
logger = logging.getLogger(name)
return logger
logger = LoggerFactory.getLogger(__name__)
class LoggerLevelManager:
logger_levels: List[Tuple[logging.Logger, int]] = []
@classmethod
def configure_levels(cls):
cls.logger_levels = []
for key in logging.Logger.manager.loggerDict:
if key.startswith("urllib3"):
# Save logger and value.
log = logging.getLogger(key)
cls.logger_levels.append((log, logger.level))
log.setLevel(logging.CRITICAL)
# Set root logger level.
logging.getLogger().setLevel(logging.INFO)
logger.info("Configured logging Levels")
@classmethod
def restore_levels(cls):
for logger, level in cls.logger_levels:
logger.setLevel(level)
logger.info("Restored logging Levels")
@@ -1,41 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from ..lookdev import prefs
from ..lookdev import props
from ..lookdev import opsdata
from ..lookdev import ops
from ..lookdev import ui
# ---------REGISTER ----------.
def reload():
global prefs
global props
global opsdata
global ops
global ui
prefs = importlib.reload(prefs)
props = importlib.reload(props)
opsdata = importlib.reload(opsdata)
ops = importlib.reload(ops)
ui = importlib.reload(ui)
def register():
prefs.register()
props.register()
ops.register()
ui.register()
def unregister():
ops.unregister()
ui.unregister()
props.unregister()
prefs.unregister()
-106
View File
@@ -1,106 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib.util
from pathlib import Path
from typing import Dict, List, Set, Optional, Tuple, Any
import bpy
from ..logger import LoggerFactory
from .. import prefs, util
from . import opsdata
logger = LoggerFactory.getLogger()
class KITSU_OT_lookdev_set_preset(bpy.types.Operator):
bl_idname = "kitsu.lookdev_set_preset"
bl_label = "Render Preset"
bl_property = "files"
bl_description = "Sets active render settings preset that can be applied"
files: bpy.props.EnumProperty(items=opsdata.get_rd_settings_enum_list, name="Files")
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
addon_prefs = prefs.addon_prefs_get(context)
return addon_prefs.lookdev.is_presets_dir_valid
def execute(self, context: bpy.types.Context) -> Set[str]:
file = self.files
if not file:
return {"CANCELLED"}
if context.scene.lookdev.preset_file == file:
return {"CANCELLED"}
# Update global scene cache version prop.
context.scene.lookdev.preset_file = file
logger.info("Set render preset file to %s", file)
# 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_lookdev_apply_preset(bpy.types.Operator):
bl_idname = "kitsu.lookdev_apply_preset"
bl_label = "Apply Preset"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Applies active render settings preset"
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(context.scene.lookdev.preset_file)
def execute(self, context: bpy.types.Context) -> Set[str]:
preset_file = context.scene.lookdev.preset_file
preset_path = Path(preset_file).absolute()
if not preset_file:
return {"CANCELLED"}
# Load module.
spec = importlib.util.spec_from_file_location(
preset_path.name, preset_path.as_posix()
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Exec module main function.
if "main" not in dir(module):
self.report(
{"ERROR"}, f"{preset_path.name} does not contain a 'main' function"
)
return {"CANCELLED"}
module.main()
self.report({"INFO"}, f"Applied: {preset_path.name}")
return {"FINISHED"}
# ---------REGISTER ----------.
classes = [KITSU_OT_lookdev_set_preset, KITSU_OT_lookdev_apply_preset]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,109 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from typing import Any, Dict, List, Tuple, Union
from pathlib import Path
import bpy
from ..models import FileListModel
from ..logger import LoggerFactory
from .. import prefs
logger = LoggerFactory.getLogger()
RD_PRESET_FILE_MODEL = FileListModel()
_rd_preset_enum_list: List[Tuple[str, str, str]] = []
_rd_preset_file_model_init: bool = False
# we need a second data dict because we want the enum properties data value to be the filepath
# but the ui (not only in enum dropdown mode) should display the label defined in the .py
# file with 'bl_label'. This dict is basically a mapping from filepath > label
_rd_preset_data_dict: Dict[str, str] = {}
def init_rd_preset_file_model(
context: bpy.types.Context,
) -> None:
global RD_PRESET_FILE_MODEL
global _rd_preset_file_model_init
addon_prefs = prefs.addon_prefs_get(context)
# Is None if invalid.
if not addon_prefs.lookdev.is_presets_dir_valid:
logger.error(
"Failed to initialize render settings file model. Invalid path. Check addon preferences"
)
return
rd_settings_dir = addon_prefs.lookdev.presets_dir_path
RD_PRESET_FILE_MODEL.reset()
RD_PRESET_FILE_MODEL.root_path = rd_settings_dir
valid_items = [file for file in RD_PRESET_FILE_MODEL.items_as_paths if file.suffix == ".py"]
if not valid_items:
# Update playblast_version prop.
context.scene.lookdev.preset_file = ""
else:
# Update playblast_version prop.
context.scene.lookdev.preset_file = valid_items[0].as_posix()
_rd_preset_file_model_init = True
def get_rd_settings_enum_list(
self: Any,
context: bpy.types.Context,
) -> List[Tuple[str, str, str]]:
global _rd_preset_enum_list
global RD_PRESET_FILE_MODEL
global init_rd_preset_file_model
global _rd_preset_file_model_init
global _rd_preset_data_dict
# Init model if it did not happen.
if not _rd_preset_file_model_init:
init_rd_preset_file_model(context)
# Reload model to update.
RD_PRESET_FILE_MODEL.reload()
# Get all Python files. Ignore hidden files.
py_files = [
f
for f in RD_PRESET_FILE_MODEL.items_as_paths
if f.suffix == ".py" and not f.name.startswith('.')
]
py_labels: List[Tuple[Path, str]] = []
# Get bl_label of each python file, if not use file name as label.
for file in py_files:
spec = importlib.util.spec_from_file_location(file.name, file.as_posix())
# Load module.
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if "bl_label" not in dir(module):
py_labels.append((file, file.name))
continue
py_labels.append((file, module.bl_label))
# Generate final enum list and dict from py_labels.
enum_list = []
data_dict = {}
for file, label in py_labels:
data_dict[file.name] = label
enum_list.append((file.as_posix(), label, ""))
# Update global variables.
_rd_preset_data_dict.clear()
_rd_preset_data_dict.update(data_dict)
_rd_preset_enum_list.clear()
_rd_preset_enum_list.extend(enum_list)
print(data_dict)
return _rd_preset_enum_list
@@ -1,87 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from typing import Optional
from pathlib import Path
from .. import prefs
from ..props import get_safely_string_prop
import bpy
from . import opsdata
class LOOKDEV_preferences(bpy.types.PropertyGroup):
"""
Addon preferences for lookdev module.
"""
def update_rd_preset_file_model(self, context: bpy.types.Context) -> None:
opsdata.init_rd_preset_file_model(context)
def set_look_dev_dir(self, input):
self['presets_dir'] = input
return
def get_look_dev_dir(self) -> str:
proj_root = prefs.project_root_dir_get(bpy.context)
if get_safely_string_prop(self, 'presets_dir') == "" and proj_root:
frames_dir = proj_root.joinpath("pro/assets/scripts/render_presets/")
if frames_dir.exists():
return frames_dir.as_posix()
return get_safely_string_prop(self, 'presets_dir')
presets_dir: bpy.props.StringProperty( # type: ignore
name="Render Presets Directory",
description="Directory path to folder in which render settings python files are stored",
default="",
subtype="DIR_PATH",
update=update_rd_preset_file_model,
get=get_look_dev_dir,
set=set_look_dev_dir,
)
@property
def is_presets_dir_valid(self) -> bool:
# Check if file is saved.
if not self.presets_dir:
return False
if not bpy.data.filepath and self.presets_dir.startswith("//"):
return False
return True
@property
def presets_dir_path(self) -> Optional[Path]:
if not self.presets_dir:
return None
return Path(os.path.abspath(bpy.path.abspath(self.presets_dir)))
def draw(
self,
context: bpy.types.Context,
layout: bpy.types.UILayout,
) -> None:
# Render preset.
box = layout.box()
box.label(text="Lookdev Tools", icon="RESTRICT_RENDER_OFF")
box.row().prop(self, "presets_dir")
# ---------REGISTER ----------.
classes = [LOOKDEV_preferences]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
# Unregister classes.
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,42 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
class LOOKDEV_property_group_scene(bpy.types.PropertyGroup):
""""""
# Render settings.
preset_file: bpy.props.StringProperty( # type: ignore
name="Render Settings File",
description="Path to file that is the active render settings preset",
default="",
subtype="FILE_PATH",
)
# ----------------REGISTER--------------.
classes = [
LOOKDEV_property_group_scene,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
# Scene Properties.
bpy.types.Scene.lookdev = bpy.props.PointerProperty(
name="Render Preset",
type=LOOKDEV_property_group_scene,
description="Metadata that is required for lookdev",
)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,97 +0,0 @@
# SPDX-FileCopyrightText: 2025 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
import bpy
from .. import prefs, lookdev, ui, cache
from .ops import (
KITSU_OT_lookdev_set_preset,
KITSU_OT_lookdev_apply_preset,
)
from . import opsdata
class KITSU_PT_vi3d_lookdev_tools(bpy.types.Panel):
"""
Panel in 3dview that exposes a set of tools that are useful for general tasks
"""
bl_category = "Kitsu"
bl_label = "Lookdev Tools"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 60
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return bool(
cache.task_type_active_get().name
in ["Lighting", "Rendering", "Compositing"]
)
@classmethod
def poll_error(cls, context: bpy.types.Context) -> bool:
addon_prefs = prefs.addon_prefs_get(context)
return bool(not addon_prefs.lookdev.is_presets_dir_valid)
def draw_error(self, context: bpy.types.Context) -> None:
addon_prefs = prefs.addon_prefs_get(context)
layout = self.layout
box = ui.draw_error_box(layout)
if not addon_prefs.lookdev.is_presets_dir_valid:
ui.draw_error_invalid_render_preset_dir(box)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
if self.poll_error(context):
self.draw_error(context)
box = layout.box()
box.label(text="Render Settings", icon="RESTRICT_RENDER_OFF")
# Render settings.
row = box.row(align=True)
rdpreset_text = "Select Render Preset"
if context.scene.lookdev.preset_file:
try:
rdpreset_text = opsdata._rd_preset_data_dict[
Path(context.scene.lookdev.preset_file).name
]
except KeyError:
pass
row.operator(
KITSU_OT_lookdev_set_preset.bl_idname,
text=rdpreset_text,
icon="DOWNARROW_HLT",
)
row.operator(
KITSU_OT_lookdev_apply_preset.bl_idname,
text="",
icon="PLAY",
)
class KITSU_PT_comp_lookdev_tools(KITSU_PT_vi3d_lookdev_tools):
bl_space_type = "NODE_EDITOR"
# ---------REGISTER ----------
# Classes that inherit from another need to be registered first for some reason.
classes = [KITSU_PT_comp_lookdev_tools, KITSU_PT_vi3d_lookdev_tools]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
-191
View File
@@ -1,191 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from pathlib import Path
from typing import Union, Optional, Dict, List, Tuple
from . import bkglobals
from .logger import LoggerFactory
logger = LoggerFactory.getLogger(__name__)
class FolderListModel:
def __init__(self):
self.__root_path: Optional[Path] = None
self.__folders: List[str] = []
self.__appended: List[str] = []
self.__combined: List[str] = []
def rowCount(self) -> int:
return len(self.__combined)
def data(self, row: int) -> Optional[str]:
if len(self.__combined) > 0:
return self.__combined[row]
return None
@property
def root_path(self) -> Optional[Path]:
return self.__root_path
@root_path.setter
def root_path(self, path: Path) -> None:
if not path or not path.absolute().exists():
logger.debug("FolderListModel: Path does not exist: %s", str(path))
self.reset()
else:
self.__root_path = path
logger.debug("FolderListModel: Root path was set to %s", path.as_posix())
self.__load_dir(self.__root_path)
def reset(self) -> None:
self.__root_path = None
self.__folders.clear()
self.__appended.clear()
self.__update_combined()
def reload(self) -> None:
self.__folders.clear()
self.__appended.clear()
self.root_path = self.__root_path
def __load_dir(self, path: Path) -> None:
self.__folders = self.__detect_folders(path)
self.__appended.clear()
self.__update_combined()
def __detect_folders(self, path: Path) -> List[str]:
if path.exists() and path.is_dir():
# Iterate through directory and return all pathes that are dirs, only return their name.
return sorted(
[str(x.name) for x in path.iterdir() if x.is_dir()], reverse=True
)
else:
return []
def append_item(self, item: str) -> None:
self.__appended.append(item)
self.__update_combined()
def __update_combined(self) -> None:
self.__combined.clear()
self.__combined.extend(
sorted(list(set(self.__folders + self.__appended)), reverse=True)
)
@property
def items(self) -> List[str]:
return self.__combined
@property
def items_as_enum_list(self) -> List[Tuple[str, str, str]]:
return [(item, item, "") for item in self.__combined]
class FileListModel:
filter_name: str = ""
def __init__(self):
self.__root_path: Optional[Path] = None
self.__files: List[str] = []
self.__appended: List[str] = []
self.__combined: List[str] = []
def rowCount(self) -> int:
return len(self.__combined)
def data(self, row: int) -> Optional[str]:
if len(self.__combined) > 0:
return self.__combined[row]
return None
@property
def root_path(self) -> Optional[Path]:
return self.__root_path
@root_path.setter
def root_path(self, path: Path) -> None:
if not path or not path.absolute().exists():
logger.debug("FileListModel: Path does not exist: %s", str(path))
self.reset()
else:
self.__root_path = path
self.__load_dir(self.__root_path)
def reset(self) -> None:
self.__root_path = None
self.__files.clear()
self.__appended.clear()
self.__update_combined()
def reload(self) -> None:
self.__files.clear()
self.__appended.clear()
self.root_path = self.__root_path
def __load_dir(self, path: Path) -> None:
self.__files = self.__detect_files(path)
self.__appended.clear()
self.__update_combined()
def __detect_files(self, path: Path) -> List[str]:
if path.exists() and path.is_dir():
# Iterate through directory and return all pathes that are files, only return their name.
return sorted(
[str(x.name) for x in path.iterdir() if x.is_file() and self.filter_name in x.name],
reverse=True,
)
else:
return []
def append_item(self, item: str) -> None:
self.__appended.append(item)
self.__update_combined()
def __update_combined(self) -> None:
self.__combined.clear()
self.__combined.extend(
sorted(list(set(self.__files + self.__appended)), reverse=True)
)
@property
def items(self) -> List[str]:
return self.__combined
@property
def items_as_paths(self) -> List[Path]:
if not self.__root_path:
return []
return [self.__root_path.joinpath(item).absolute() for item in self.items]
@property
def items_as_enum_list(self) -> List[Tuple[str, str, str]]:
return [(item, item, "") for item in self.__combined]
@property
def items_as_path_enum_list(self) -> List[Tuple[str, str, str]]:
return [(item.as_posix(), item.name, "") for item in self.items_as_paths]
@property
def versions(self) -> List[str]:
return [self._get_version(i) for i in self.__combined if self._get_version(i)]
@property
def versions_as_enum_list(self) -> List[Tuple[str, str, str]]:
return [(v, v, "") for v in self.versions]
def _get_version(self, str_value: str, format: type = str) -> Union[str, int, None]:
match = re.search(bkglobals.VERSION_PATTERN, str_value)
if match:
version = match.group()
if format == str:
return version
if format == int:
return int(version.replace("v", ""))
return None
@@ -1,27 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from ..playblast 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()
@@ -1,320 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from pathlib import Path
from bpy.types import Context
import contextlib
from typing import Tuple
from .. import prefs, cache
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
@contextlib.contextmanager
def override_render_format(self, context: Context, enable_sequencer: bool = False):
"""Overrides the render settings for playblast creation"""
rd = context.scene.render
if bpy.app.version >= (5, 0, 0):
# For Blender 5.0 and later, use the new FFMPEG settings.
media_type = rd.image_settings.media_type
use_sequencer = rd.use_sequencer = enable_sequencer
# Format render settings.
# percentage = rd.resolution_percentage
file_format = rd.image_settings.file_format
ffmpeg_constant_rate = rd.ffmpeg.constant_rate_factor
ffmpeg_codec = rd.ffmpeg.codec
ffmpeg_format = rd.ffmpeg.format
ffmpeg_audio_codec = rd.ffmpeg.audio_codec
try:
if bpy.app.version >= (5, 0, 0):
# For Blender 5.0 and later, use the new FFMPEG settings.
rd.image_settings.media_type = "VIDEO"
# rd.resolution_percentage = 100
rd.use_sequencer = enable_sequencer
rd.image_settings.file_format = "FFMPEG"
rd.ffmpeg.constant_rate_factor = "HIGH"
rd.ffmpeg.codec = "H264"
rd.ffmpeg.format = "MPEG4"
rd.ffmpeg.audio_codec = "AAC"
yield
finally:
# rd.resolution_percentage = percentage
if bpy.app.version >= (5, 0, 0):
# For Blender 5.0 and later, use the new FFMPEG settings.
rd.image_settings.media_type = media_type
rd.use_sequencer = use_sequencer
rd.image_settings.file_format = file_format
rd.ffmpeg.codec = ffmpeg_codec
rd.ffmpeg.constant_rate_factor = ffmpeg_constant_rate
rd.ffmpeg.format = ffmpeg_format
rd.ffmpeg.audio_codec = ffmpeg_audio_codec
@contextlib.contextmanager
def override_render_path(self, context: Context, render_file_path: str):
"""Overrides the render settings for playblast creation"""
rd = context.scene.render
# Filepath.
filepath = rd.filepath
try:
# Filepath.
rd.filepath = render_file_path
yield
finally:
# Filepath.
rd.filepath = filepath
@contextlib.contextmanager
def override_hide_viewport_overlays(self, context: Context):
sp = context.space_data
show_gizmo = sp.show_gizmo
show_overlays = sp.overlay.show_overlays
try:
sp.show_gizmo = False
sp.overlay.show_overlays = False
yield
finally:
sp.show_gizmo = show_gizmo
sp.overlay.show_overlays = show_overlays
@contextlib.contextmanager
def override_metadata_stamp_settings(self, context: Context, username: str = "None"):
addon_prefs = prefs.addon_prefs_get(context)
if addon_prefs.pb_manual_burn_in:
try:
yield
finally:
return
rd = context.scene.render
# Get shot name for stamp note text.
shot = cache.shot_active_get()
# Remember current render settings in order to restore them later.
# Stamp metadata settings.
metadata_input = rd.metadata_input
use_stamp_date = rd.use_stamp_date
use_stamp_time = rd.use_stamp_time
use_stamp_render_time = rd.use_stamp_render_time
use_stamp_frame = rd.use_stamp_frame
use_stamp_frame_range = rd.use_stamp_frame_range
use_stamp_memory = rd.use_stamp_memory
use_stamp_hostname = rd.use_stamp_hostname
use_stamp_camera = rd.use_stamp_camera
use_stamp_lens = rd.use_stamp_lens
use_stamp_scene = rd.use_stamp_scene
use_stamp_marker = rd.use_stamp_marker
use_stamp_marker = rd.use_stamp_marker
use_stamp_note = rd.use_stamp_note
stamp_note_text = rd.stamp_note_text
use_stamp = rd.use_stamp
# stamp_font_size = rd.stamp_font_size
stamp_foreground = rd.stamp_foreground
stamp_background = rd.stamp_background
use_stamp_labels = rd.use_stamp_labels
try:
# Stamp metadata settings.
rd.metadata_input = "SCENE"
rd.use_stamp_date = False
rd.use_stamp_time = False
rd.use_stamp_render_time = False
rd.use_stamp_frame = True
rd.use_stamp_frame_range = False
rd.use_stamp_memory = False
rd.use_stamp_hostname = False
rd.use_stamp_camera = False
rd.use_stamp_lens = True
rd.use_stamp_scene = False
rd.use_stamp_marker = False
rd.use_stamp_marker = False
rd.use_stamp_note = True
rd.stamp_note_text = f"Shot: {shot.name} | Animator: {username}"
rd.use_stamp = True
rd.stamp_font_size = 24
rd.stamp_foreground = (0.8, 0.8, 0.8, 1)
rd.stamp_background = (0, 0, 0, 0.25)
rd.use_stamp_labels = True
yield
finally:
# Stamp metadata settings.
rd.metadata_input = metadata_input
rd.use_stamp_date = use_stamp_date
rd.use_stamp_time = use_stamp_time
rd.use_stamp_render_time = use_stamp_render_time
rd.use_stamp_frame = use_stamp_frame
rd.use_stamp_frame_range = use_stamp_frame_range
rd.use_stamp_memory = use_stamp_memory
rd.use_stamp_hostname = use_stamp_hostname
rd.use_stamp_camera = use_stamp_camera
rd.use_stamp_lens = use_stamp_lens
rd.use_stamp_scene = use_stamp_scene
rd.use_stamp_marker = use_stamp_marker
rd.use_stamp_marker = use_stamp_marker
rd.use_stamp_note = use_stamp_note
rd.stamp_note_text = stamp_note_text
rd.use_stamp = use_stamp
# rd.stamp_font_size = stamp_font_size
rd.stamp_foreground = stamp_foreground
rd.stamp_background = stamp_background
rd.use_stamp_labels = use_stamp_labels
@contextlib.contextmanager
def override_viewport_shading(self, context: Context):
"""Overrides the render settings for playblast creation"""
rd = context.scene.render
sps = context.space_data.shading
sp = context.space_data
# Space data settings.
shading_type = sps.type
shading_light = sps.light
studio_light = sps.studio_light
color_type = sps.color_type
background_type = sps.background_type
show_backface_culling = sps.show_backface_culling
show_xray = sps.show_xray
show_shadows = sps.show_shadows
show_cavity = sps.show_cavity
show_object_outline = sps.show_object_outline
show_specular_highlight = sps.show_specular_highlight
try:
# Space data settings.
sps.type = "SOLID"
sps.light = "STUDIO"
sps.studio_light = "paint.sl"
sps.color_type = "MATERIAL"
sps.background_type = "THEME"
sps.show_backface_culling = False
sps.show_xray = False
sps.show_shadows = False
sps.show_cavity = False
sps.show_object_outline = False
sps.show_specular_highlight = True
yield
finally:
# Space data settings.
sps.type = shading_type
sps.light = shading_light
sps.studio_light = studio_light
sps.color_type = color_type
sps.background_type = background_type
sps.show_backface_culling = show_backface_culling
sps.show_xray = show_xray
sps.show_shadows = show_shadows
sps.show_cavity = show_cavity
sps.show_object_outline = show_object_outline
sps.show_specular_highlight = show_specular_highlight
def ensure_render_path(file_path: str) -> Path:
output_path = Path(file_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
return output_path
def playblast_with_scene_settings(self, context: Context, file_path: str, username: str) -> Path:
with override_render_path(self, context, file_path):
with override_render_format(self, context):
with override_metadata_stamp_settings(self, context, username):
output_path = ensure_render_path(file_path)
bpy.ops.render.render(animation=True, use_viewport=False)
return output_path
def playblast_with_viewport_settings(self, context: Context, file_path: str, username: str) -> Path:
with override_render_path(self, context, file_path):
with override_render_format(self, context):
with override_metadata_stamp_settings(self, context, username):
output_path = ensure_render_path(file_path)
bpy.ops.render.opengl(animation=True)
return output_path
def playblast_with_viewport_preset_settings(
self, context: Context, file_path: str, username: str
) -> Path:
with override_render_path(self, context, file_path):
with override_render_format(self, context):
with override_metadata_stamp_settings(self, context, username):
with override_viewport_shading(self, context):
with override_hide_viewport_overlays(self, context):
output_path = ensure_render_path(file_path)
bpy.ops.render.opengl(animation=True)
return output_path
def playblast_vse(self, context: Context, file_path: str) -> Path:
with override_render_path(self, context, file_path):
with override_render_format(self, context, enable_sequencer=True):
output_path = ensure_render_path(file_path)
bpy.ops.render.opengl(animation=True, sequencer=True)
return output_path
def set_frame_range_in(frame_in: int) -> dict:
shot = cache.shot_active_pull_update()
shot.data["3d_start"] = frame_in
shot.update()
return shot
def get_frame_range() -> Tuple[int, int]:
active_shot = cache.shot_active_get()
if not active_shot:
return
# Pull update for shot.
cache.shot_active_pull_update()
kitsu_3d_start = active_shot.get_3d_start()
frame_in = kitsu_3d_start
frame_out = kitsu_3d_start + int(active_shot.nb_frames) - 1
return frame_in, frame_out
def check_frame_range(context: Context) -> bool:
"""
Compare the current scene's frame range with that of the active shot on kitsu.
If there's a mismatch, set kitsu_error.frame_range -> True. This will enable
a warning in the Animation Tools Tab UI.
"""
try:
frame_in, frame_out = get_frame_range()
except TypeError:
return
scene = context.scene
if frame_in == scene.frame_start and frame_out == scene.frame_end:
scene.kitsu_error.frame_range = False
return True
scene.kitsu_error.frame_range = True
logger.warning("Current frame range is outdated!")
return False
@@ -1,680 +0,0 @@
# 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)
@@ -1,96 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, Dict, List, Tuple, Generator
from pathlib import Path
import bpy
from ..models import FileListModel
from ..logger import LoggerFactory
PLAYBLAST_FILE_MODEL = FileListModel()
_playblast_enum_list: List[Tuple[str, str, str]] = []
_playblast_file_model_init: bool = False
logger = LoggerFactory.getLogger()
def init_playblast_file_model(
context: bpy.types.Context,
) -> None:
global PLAYBLAST_FILE_MODEL
global _playblast_file_model_init
kitsu_props = context.scene.kitsu
# TODO Make playblast dir variable (seq or shot)
# Consider removing this string and use prefs instead
# Is None if invalid.
if not context.scene.kitsu.playblast_dir:
logger.error(
"Failed to initialize playblast file model. Invalid path. Check addon preferences"
)
return
playblast_dir = Path(context.scene.kitsu.playblast_dir)
PLAYBLAST_FILE_MODEL.reset()
PLAYBLAST_FILE_MODEL.root_path = playblast_dir
if not PLAYBLAST_FILE_MODEL.versions:
PLAYBLAST_FILE_MODEL.append_item("v001")
# Update playblast_version prop.
kitsu_props.playblast_version = "v001"
else:
# Update playblast_version prop.
kitsu_props.playblast_version = PLAYBLAST_FILE_MODEL.versions[0]
_playblast_file_model_init = True
def add_playblast_version_increment(context: bpy.types.Context) -> str:
# Init model if it did not happen.
if not _playblast_file_model_init:
init_playblast_file_model(context)
# Should be already sorted.
versions = PLAYBLAST_FILE_MODEL.versions
if len(versions) > 0:
latest_version = versions[0]
increment = "v{:03}".format(int(latest_version.replace("v", "")) + 1)
else:
increment = "v001"
PLAYBLAST_FILE_MODEL.append_item(increment)
return increment
def get_playblast_versions_enum_list(
self: Any,
context: bpy.types.Context,
) -> List[Tuple[str, str, str]]:
global _playblast_enum_list
global PLAYBLAST_FILE_MODEL
global init_playblast_file_model
global _playblast_file_model_init
# Init model if it did not happen.
if not _playblast_file_model_init:
init_playblast_file_model(context)
# Clear all versions in enum list.
_playblast_enum_list.clear()
_playblast_enum_list.extend(PLAYBLAST_FILE_MODEL.versions_as_enum_list)
return _playblast_enum_list
def add_version_custom(custom_version: str) -> None:
global _playblast_enum_list
global PLAYBLAST_FILE_MODEL
PLAYBLAST_FILE_MODEL.append_item(custom_version)
@@ -1,119 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from ..anim import opsdata
from ..context import core as context_core
import bpy
from pathlib import Path
from .. import prefs, cache, ui
from ..playblast.ops import (
KITSU_OT_playblast_create,
KITSU_OT_playblast_set_version,
KITSU_OT_playblast_increment_playblast_version,
)
from ..generic.ops import KITSU_OT_open_path
class KITSU_PT_vi3d_playblast(bpy.types.Panel):
"""
Panel in 3dview that exposes a set of tools that are useful for animation
tasks, e.G playblast
"""
bl_category = "Kitsu"
bl_label = "Playblast"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 50
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if context_core.is_edit_context():
return False
return bool(prefs.session_auth(context))
@classmethod
def poll_error(cls, context: bpy.types.Context) -> bool:
addon_prefs = prefs.addon_prefs_get(context)
return bool(not addon_prefs.is_playblast_root_valid or not context.scene.camera)
def draw_error(self, context: bpy.types.Context) -> None:
addon_prefs = prefs.addon_prefs_get(context)
layout = self.layout
box = ui.draw_error_box(layout)
if not addon_prefs.is_playblast_root_valid:
ui.draw_error_invalid_playblast_root_dir(box)
if not context.scene.camera:
ui.draw_error_no_active_camera(box)
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 self.poll_error(context):
self.draw_error(context)
# Playblast version op.
row = layout.row(align=True)
row.operator(
KITSU_OT_playblast_set_version.bl_idname,
text=context.scene.kitsu.playblast_version,
icon="DOWNARROW_HLT",
)
# Playblast increment version op.
row.operator(
KITSU_OT_playblast_increment_playblast_version.bl_idname,
text="",
icon="ADD",
)
# Playblast op.
row = layout.row(align=True)
row.operator(KITSU_OT_playblast_create.bl_idname, icon="RENDER_ANIMATION")
# Playblast path label.
if Path(context.scene.kitsu.playblast_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.playblast_file)
sub_split.operator(
KITSU_OT_open_path.bl_idname, icon="FILE_FOLDER", text=""
).filepath = context.scene.kitsu.playblast_file
else:
row = layout.row(align=True)
row.label(text=context.scene.kitsu.playblast_file)
row.operator(
KITSU_OT_open_path.bl_idname, icon="FILE_FOLDER", text=""
).filepath = context.scene.kitsu.playblast_file
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)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
-818
View File
@@ -1,818 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import hashlib
import sys
import os
from typing import Optional, Set
from pathlib import Path
import bpy
from . import cache, bkglobals, propsdata
from .props import get_safely_string_prop
# TODO: restructure this to not access ops_playblast_data.
from .playblast import opsdata as ops_playblast_data
from .types import Session
from .logger import LoggerFactory
from .auth.ops import (
KITSU_OT_session_end,
KITSU_OT_session_start,
)
from .context.ops import KITSU_OT_con_productions_load
from .lookdev.prefs import LOOKDEV_preferences
from .util import addon_prefs_get
logger = LoggerFactory.getLogger()
def draw_file_path(
self, layout: bpy.types.UILayout, bool_prop: bool, bool_prop_name: str, path_prop_name: str
) -> None:
# Draw auto-filled file path with option to override/edit this path
icon = "MODIFIER_ON" if bool_prop else "MODIFIER_OFF"
seq_row = layout.row(align=True)
seq_row.prop(self, path_prop_name)
seq_row.prop(self, bool_prop_name, icon=icon, text="")
class KITSU_task(bpy.types.PropertyGroup):
# name: StringProperty() -> Instantiated by default
id: bpy.props.StringProperty(name="Task ID", default="")
entity_id: bpy.props.StringProperty(name="Entity ID", default="")
entity_name: bpy.props.StringProperty(name="Entity Name", default="")
task_type_id: bpy.props.StringProperty(name="Task Type ID", default="")
task_type_name: bpy.props.StringProperty(name="Task Type Name", default="")
class KITSU_media_update_search_paths(bpy.types.PropertyGroup):
# name: StringProperty() -> Instantiated by default
filepath: bpy.props.StringProperty(
name="Media Update Search Path",
default="",
subtype="DIR_PATH",
description="Top level directory path in which to search for media updates",
)
class KITSU_OT_prefs_media_search_path_add(bpy.types.Operator):
""""""
bl_idname = "kitsu.prefs_media_search_path_add"
bl_label = "Add Path"
bl_description = "Adds new entry to media update search paths list"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context: bpy.types.Context) -> Set[str]:
addon_prefs = addon_prefs_get(context)
media_update_search_paths = addon_prefs.media_update_search_paths
item = media_update_search_paths.add()
return {"FINISHED"}
class KITSU_OT_prefs_media_search_path_remove(bpy.types.Operator):
""""""
bl_idname = "kitsu.prefs_media_search_path_remove"
bl_label = "Removes Path"
bl_description = "Removes Path from media udpate search paths list"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty(
name="Index",
description="Refers to index that will be removed from collection property",
)
def execute(self, context: bpy.types.Context) -> Set[str]:
addon_prefs = addon_prefs_get(context)
media_update_search_paths = addon_prefs.media_update_search_paths
media_update_search_paths.remove(self.index)
return {"FINISHED"}
class KITSU_addon_preferences(bpy.types.AddonPreferences):
"""
Addon preferences to kitsu. Holds variables that are important for authentication and configuring
how some of the operators work.
"""
def get_metadatastrip_file(self) -> str:
res_dir = bkglobals.RES_DIR_PATH
return res_dir.joinpath("metastrip.mp4").as_posix()
metadatastrip_file: bpy.props.StringProperty( # type: ignore
name="Metadata Strip File",
description=(
"Filepath to black .mp4 file that will be used as metastrip for shots in the sequence editor"
),
default="",
subtype="FILE_PATH",
get=get_metadatastrip_file,
)
def get_datadir(self) -> Path:
"""Returns a Path where persistent application data can be stored.
# linux: ~/.local/share
# macOS: ~/Library/Application Support
# windows: C:/Users/<USER>/AppData/Roaming
"""
# This function is copied from the edit_breakdown addon by Inês Almeida and Francesco Siddi.
home = Path.home()
if sys.platform == "win32":
return home / "AppData/Roaming"
elif sys.platform == "linux":
return home / ".local/share"
elif sys.platform == "darwin":
return home / "Library/Application Support"
def get_thumbnails_dir(self) -> str:
"""Generate a path based on get_datadir and the current file name.
The path is constructed by combining the OS application data dir,
"blender_kitsu" and a hashed version of the filepath.
"""
# This function is copied and modified from the edit_breakdown addon by Inês Almeida and Francesco Siddi.
hashed_filepath = hashlib.md5(bpy.data.filepath.encode()).hexdigest()
storage_dir = self.get_datadir() / "blender_kitsu" / "thumbnails" / hashed_filepath
return storage_dir.as_posix()
def get_sqe_render_dir(self) -> str:
hashed_filepath = hashlib.md5(bpy.data.filepath.encode()).hexdigest()
storage_dir = self.get_datadir() / "blender_kitsu" / "sqe_render" / hashed_filepath
return storage_dir.absolute().as_posix()
def get_config_dir(self) -> str:
if not self.is_project_root_valid:
return ""
return self.project_root_path.joinpath("pipeline/blender_kitsu").as_posix()
def init_playblast_file_model(self, context: bpy.types.Context) -> None:
ops_playblast_data.init_playblast_file_model(context)
bl_idname = __package__
host: bpy.props.StringProperty( # type: ignore
name="Host",
default="",
)
email: bpy.props.StringProperty( # type: ignore
name="Email",
default="",
)
passwd: bpy.props.StringProperty( # type: ignore
name="Password", default="", options={"HIDDEN", "SKIP_SAVE"}, subtype="PASSWORD"
)
thumbnail_dir: bpy.props.StringProperty( # type: ignore
name="Thumbnail Directory",
description="Directory in which thumbnails will be saved",
default="",
subtype="DIR_PATH",
get=get_thumbnails_dir,
)
sqe_render_dir: bpy.props.StringProperty( # type: ignore
name="Sequence Editor Render Directory",
description="Directory in which sequence renders will be saved",
default="",
subtype="DIR_PATH",
get=get_sqe_render_dir,
)
lookdev: bpy.props.PointerProperty( # type: ignore
name="Lookdev Preferences",
type=LOOKDEV_preferences,
description="Metadata that is required for lookdev",
)
def set_shot_playblast_root_dir(self, input):
self.bl_system_properties_get(do_create=True)['shot_playblast_root_dir'] = input
return
def get_shot_playblast_root_dir(
self,
) -> str:
path = get_safely_string_prop(self.bl_system_properties_get(), 'shot_playblast_root_dir')
if path == "" and self.project_root_path:
dir = self.project_root_path.joinpath("shared/editorial/footage/pro/")
if dir.exists():
return dir.as_posix()
return path
shot_playblast_root_dir: bpy.props.StringProperty( # type: ignore
name="Shot Playblasts",
description="Directory path to shot playblast root folder. Should point to: {project}/editorial/footage/pro",
default="",
subtype="DIR_PATH",
update=init_playblast_file_model,
get=get_shot_playblast_root_dir,
set=set_shot_playblast_root_dir,
)
def set_seq_playblast_root_dir(self, input):
self.bl_system_properties_get(do_create=True)['seq_playblast_root_dir'] = input
return
def get_seq_playblast_root_dir(
self,
) -> str:
path = get_safely_string_prop(self.bl_system_properties_get(), 'seq_playblast_root_dir')
if path == "" and self.project_root_path:
dir = self.project_root_path.joinpath("shared/editorial/footage/pre/")
if dir.exists():
return dir.as_posix()
return path
seq_playblast_root_dir: bpy.props.StringProperty( # type: ignore
name="Sequence Playblasts",
description="Directory path to sequence playblast root folder. Should point to: {project}/editorial/footage/pre",
default="",
subtype="DIR_PATH",
get=get_seq_playblast_root_dir,
set=set_seq_playblast_root_dir,
options=set(),
)
def set_frames_root_dir(self, input):
self.bl_system_properties_get(do_create=True)['frames_root_dir'] = input
return
def get_frames_root_dir(
self,
) -> str:
path = get_safely_string_prop(self.bl_system_properties_get(), 'frames_root_dir')
if path == "" and self.project_root_path:
dir = self.project_root_path.joinpath("shared/editorial/footage/post/")
if dir.exists():
return dir.as_posix()
return path
frames_root_dir: bpy.props.StringProperty( # type: ignore
name="Rendered Frames",
description="Directory path to rendered frames root folder. Should point to: {project}/editorial/footage/post",
default="",
subtype="DIR_PATH",
get=get_frames_root_dir,
set=set_frames_root_dir,
)
project_root_dir: bpy.props.StringProperty( # type: ignore
name="Project Root Directory",
description=(
"Directory path to the root of the project"
"In this directory blender kitsu searches for the svn/ & shared/ directories"
"Directory should follow `you_project_name/` format without any subdirectories"
),
default="/data/gold/",
subtype="DIR_PATH",
)
config_dir: bpy.props.StringProperty( # type: ignore
name="Config Directory",
description=(
"Configuration directory of blender_kitsu"
"See readme.md how you can configurate the addon on a per project basis"
),
default="",
subtype="DIR_PATH",
get=get_config_dir,
)
project_active_id: bpy.props.StringProperty( # type: ignore
name="Project Active ID",
description="Server Id that refers to the last active project",
default="",
)
enable_debug: bpy.props.BoolProperty( # type: ignore
name="Enable Debug Operators",
description="Enables Operatots that provide debug functionality",
)
show_advanced: bpy.props.BoolProperty( # type: ignore
name="Show Advanced Settings",
description="Show advanced settings that should already have good defaults",
)
shot_builder_show_advanced: bpy.props.BoolProperty( # type: ignore
name="Show Advanced Settings",
description="Show advanced settings that should already have good defaults",
)
def set_shot_pattern(self, input):
self.bl_system_properties_get(do_create=True)['shot_pattern'] = input
return
def get_shot_pattern(
self,
) -> str:
active_project = cache.project_active_get()
pattern = get_safely_string_prop(self.bl_system_properties_get(), 'shot_pattern')
if pattern == "":
if active_project.production_type == bkglobals.KITSU_TV_PROJECT:
return "<Episode>_<Sequence>_<Counter>"
return "<Sequence>_<Counter>"
return pattern
shot_pattern: bpy.props.StringProperty( # type: ignore
name="Shot Pattern",
description="Pattern to define how Bulk Init will name the shots. Supported wildcards: <Project>, <Episode>, <Sequence>, <Counter>",
default="<Sequence>_<Counter>",
get=get_shot_pattern,
set=set_shot_pattern,
)
shot_counter_digits: bpy.props.IntProperty( # type: ignore
name="Shot Counter Digits",
description="How many digits the counter should contain",
default=4,
min=0,
)
shot_counter_increment: bpy.props.IntProperty( # type: ignore
name="Shot Counter Increment",
description="By which Increment counter should be increased",
default=10,
step=5,
min=0,
)
pb_open_webbrowser: bpy.props.BoolProperty( # type: ignore
name="Open Webbrowser after Playblast",
description="Toggle if the default webbrowser should be opened to kitsu after playblast creation",
default=False,
)
pb_open_vse: bpy.props.BoolProperty( # type: ignore
name="Open Sequence Editor after Playblast",
description="Toggle if the movie clip should be loaded in the seqeuence editor in a seperate scene after playblast creation",
default=False,
)
pb_manual_burn_in: bpy.props.BoolProperty( # type: ignore
name="Manual Playblast Burn-Ins",
description=(
"Blender Kitsu will override all Shot/Sequence playblasts with it's own metadata burn in. "
"This includes frame, lens, Shot name & Animator name at font size of 24. "
"To use a file's metadata burn in settings during playblast enable this option"
),
default=False,
)
media_update_search_paths: bpy.props.CollectionProperty(type=KITSU_media_update_search_paths)
production_path: bpy.props.StringProperty( # type: ignore
name="Production Root",
description="The location to load configuration files from when "
"they couldn't be found in any parent folder of the current "
"file. Folder must contain a sub-folder named `shot-builder` "
"that holds the configuration files",
subtype='DIR_PATH',
)
def set_edit_export_dir(self, input):
self.bl_system_properties_get(do_create=True)['edit_export_dir'] = input
return
def get_edit_export_dir(
self,
) -> str:
path = get_safely_string_prop(self.bl_system_properties_get(), 'edit_export_dir')
active_project = cache.project_active_get()
if path == "" and self.project_root_path:
if active_project.production_type == bkglobals.KITSU_TV_PROJECT:
dir = self.project_root_path.joinpath("shared/editorial/export/<episode>/")
else:
dir = self.project_root_path.joinpath("shared/editorial/export/")
return dir.as_posix()
return path
edit_export_dir: bpy.props.StringProperty( # type: ignore
name="Edit Export Directory",
options={"HIDDEN", "SKIP_SAVE"},
description="Directory path to editorial's export folder containing storyboard/animatic renders. Path should be similar to '{project}/shared/editorial/export/'",
subtype="DIR_PATH",
get=get_edit_export_dir,
set=set_edit_export_dir,
)
def set_edit_export_file_pattern(self, input):
self.bl_system_properties_get(do_create=True)['edit_export_file_pattern'] = input
return
def get_edit_export_file_pattern(
self,
) -> str:
active_project = cache.project_active_get()
pattern = get_safely_string_prop(self.bl_system_properties_get(), 'edit_export_file_pattern')
if 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 pattern
edit_export_file_pattern: bpy.props.StringProperty( # type: ignore
name="Edit Export File Pattern",
options={"HIDDEN", "SKIP_SAVE"},
description=(
"File pattern for latest editorial export 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,
)
edit_export_frame_offset: bpy.props.IntProperty( # type: ignore
name="Edit Export Offset",
description="Shift Editorial Export by this frame-range after import",
default=-101,
)
shot_builder_frame_offset: bpy.props.IntProperty( # type: ignore
name="Start Frame Offset",
description="All Shots built by 'Shot_builder' should begin at this frame",
default=101,
)
session: Session = Session()
tasks: bpy.props.CollectionProperty(type=KITSU_task)
version_control: bpy.props.BoolProperty( # type: ignore
name="Use Version Control",
description="Indicates if SVN or GIT-LFS is being used for version control. If disabled local backups of production files will be created when Publishing to Kitsu",
default=True,
)
####################
# Render Review
####################
def set_farm_dir(self, input):
self.bl_system_properties_get(do_create=True)['farm_output_dir'] = input
return
def get_farm_dir(
self,
) -> str:
path = get_safely_string_prop(self.bl_system_properties_get(), 'farm_output_dir')
if path == "" and self.project_root_path:
dir = self.project_root_path.joinpath("render/")
if dir.exists():
return dir.as_posix()
return path
farm_output_dir: bpy.props.StringProperty( # type: ignore
name="Farm Output Directory",
description="Directory used as 'Render Output Root' when submitting job to Flamenco. Usually points to: {project}/render/",
default="",
subtype="DIR_PATH",
get=get_farm_dir,
set=set_farm_dir,
)
shot_dir_name: bpy.props.StringProperty(
name="Shot Directory Name",
description="Name of the shot directory",
default="shots"
)
seq_dir_name: bpy.props.StringProperty(
name="Sequence Directory Name",
description="Name of the sequence directory",
default="sequences"
)
asset_dir_name: bpy.props.StringProperty(
name="Asset Directory Name",
description="Name of the asset directory",
default="assets"
)
edit_dir_name: bpy.props.StringProperty(
name="Edit Directory Name",
description="Name of the edit directory",
default="edit"
)
shot_name_filter: bpy.props.StringProperty( # type: ignore
name="Shot Name Filter",
description="Shot name must include this string, otherwise it will be ignored",
default="",
)
use_video: bpy.props.BoolProperty(
name="Use Video",
description="Load video versions of renders rather than image sequences for faster playback",
)
use_video_latest_only: bpy.props.BoolProperty(
default=True,
name="Latest Only",
description="Only load video files for the latest versions by default, to avoid running out of memory and crashing",
)
match_latest_length: bpy.props.BoolProperty( # type: ignore
default=True,
name="Match Latest Length",
description="Only include renders that are matching the last rendered length for a given shot, (i.e. missing frames)",
)
versions_max_count: bpy.props.IntProperty(
name="Max Versions",
description="Desired number of versions to load for each shot",
min=1,
max=128,
default=32,
)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
flow = layout.grid_flow(
row_major=True, columns=0, even_columns=True, even_rows=True, align=False
)
col = flow.column()
project_active = cache.project_active_get()
# Login.
box = col.box()
box.label(text="Login and Host Settings", icon="URL")
if not self.session.is_auth():
box.row().prop(self, "host")
box.row().prop(self, "email")
box.row().prop(self, "passwd")
box.row().operator(KITSU_OT_session_start.bl_idname, text="Login", icon="PLAY")
else:
row = box.row()
row.prop(self, "host")
row.enabled = False
box.row().label(text=f"Logged in: {self.session.email}")
box.row().operator(KITSU_OT_session_end.bl_idname, text="Logout", icon="PANEL_CLOSE")
# Project
box = col.box()
box.label(text="Project", icon="FILEBROWSER")
row = box.row(align=True)
if not project_active:
prod_load_text = "Select Project"
else:
prod_load_text = project_active.name
row.operator(
KITSU_OT_con_productions_load.bl_idname,
text=prod_load_text,
icon="DOWNARROW_HLT",
)
box.row().prop(self, "project_root_dir")
box.prop(self, 'shot_dir_name')
box.prop(self, 'seq_dir_name')
box.prop(self, 'asset_dir_name')
box.prop(self, 'edit_dir_name')
# Previews
box = col.box()
box.label(text="Previews", icon="RENDER_ANIMATION")
box.row().prop(self, "shot_playblast_root_dir")
box.row().prop(self, "seq_playblast_root_dir")
box.row().prop(self, "frames_root_dir")
box.row().prop(self, "pb_open_webbrowser")
box.row().prop(self, "pb_open_vse")
box.row().prop(self, "pb_manual_burn_in")
# Editorial Settings
box = col.box()
box.label(text="Editorial", icon="SEQ_SEQUENCER")
box.row().prop(self, "edit_export_dir", text="Export Directory")
file_pattern_row = box.row(align=True)
file_pattern_row.alert = not self.is_edit_export_pattern_valid
file_pattern_row.prop(self, "edit_export_file_pattern", text="Export File Pattern")
box.row().prop(self, "edit_export_frame_offset")
# Render Review
self.draw_render_review(col)
# Shot_Builder settings.
box = col.box()
box.label(text="Shot Builder", icon="MOD_BUILD")
box.prop(self, "shot_builder_frame_offset")
row = box.row(align=True)
# Avoids circular import error
from .shot_builder.ops import (
KITSU_OT_build_config_save_settings,
KITSU_OT_build_config_save_hooks,
KITSU_OT_build_config_save_templates,
)
box.row().operator(KITSU_OT_build_config_save_hooks.bl_idname, icon='FILE_SCRIPT')
box.row().operator(KITSU_OT_build_config_save_settings.bl_idname, icon="TEXT")
box.row().operator(KITSU_OT_build_config_save_templates.bl_idname, icon="FILE_BLEND")
# Lookdev tools settings.
self.lookdev.draw(context, col)
# Sequence editor include paths.
box = col.box()
box.label(text="Media Update Search Paths", icon="SEQUENCE")
box.label(
text="Only the movie strips that have their source media coming from one of these folders (recursive) will be checked for media updates"
)
for i, item in enumerate(self.media_update_search_paths):
row = box.row()
row.prop(item, "filepath", text="")
row.operator(
KITSU_OT_prefs_media_search_path_remove.bl_idname,
text="",
icon="X",
emboss=False,
).index = i
row = box.row()
row.alignment = "LEFT"
row.operator(
KITSU_OT_prefs_media_search_path_add.bl_idname,
text="",
icon="ADD",
emboss=False,
)
# Misc settings.
box = col.box()
box.label(text="Miscellaneous", icon="MODIFIER")
box.row().prop(self, "thumbnail_dir")
box.row().prop(self, "sqe_render_dir")
box.row().prop(self, "enable_debug")
box.row().prop(self, "show_advanced")
if self.show_advanced:
box.row().prop(self, "shot_pattern")
box.row().prop(self, "shot_counter_digits")
box.row().prop(self, "shot_counter_increment")
box.row().prop(self, "version_control")
def draw_render_review(self, layout: bpy.types.UILayout) -> None:
box = layout.box()
box.label(text="Render Review", icon="FILEBROWSER")
# Farm outpur dir.
box.row().prop(self, "farm_output_dir")
if not self.farm_output_dir:
row = box.row()
row.label(text="Please specify the Farm Output Directory", icon="ERROR")
if not bpy.data.filepath and self.farm_output_dir.startswith("//"):
row = box.row()
row.label(
text="In order to use a relative path the current file needs to be saved.",
icon="ERROR",
)
box.row().prop(self, "shot_name_filter")
box.row().prop(self, "versions_max_count", slider=True)
@property
def shot_playblast_root_path(self) -> Optional[Path]:
if not self.is_playblast_root_valid:
return None
return Path(os.path.abspath(bpy.path.abspath(self.shot_playblast_root_dir)))
@property
def seq_playblast_root_path(self) -> Optional[Path]:
if not self.is_playblast_root_valid:
return None
return Path(os.path.abspath(bpy.path.abspath(self.seq_playblast_root_dir)))
@property
def is_playblast_root_valid(self) -> bool:
if not self.shot_playblast_root_dir:
return False
if not bpy.data.filepath and self.shot_playblast_root_dir.startswith("//"):
return False
return True
@property
def is_edit_export_root_valid(self) -> bool:
if self.edit_export_dir.strip() == "":
return False
edit_export_path = Path(self.edit_export_dir)
if '<episode>' in edit_export_path.parts:
edit_export_path = propsdata.get_edit_export_dir()
if edit_export_path.parent.exists():
return True
if not edit_export_path.exists():
return False
return True
@property
def is_edit_export_pattern_valid(self) -> bool:
if not self.edit_export_file_pattern.endswith(".mp4"):
return False
if not "###" in self.edit_export_file_pattern:
return False
return True
@property
def project_root_path(self) -> Optional[Path]:
if not self.project_root_dir:
return None
return Path(os.path.abspath(bpy.path.abspath(self.project_root_dir)))
@property
def is_project_root_valid(self) -> bool:
if not self.project_root_dir:
return False
if not bpy.data.filepath and self.project_root_dir.startswith("//"):
return False
return True
@property
def is_config_dir_valid(self) -> bool:
if not self.config_dir:
return False
if not bpy.data.filepath and self.config_dir.startswith("//"):
return False
return True
@property
def farm_output_path(self) -> Optional[Path]:
if not self.is_farm_output_valid:
return None
return Path(os.path.abspath(bpy.path.abspath(self.farm_output_dir)))
@property
def is_farm_output_valid(self) -> bool:
# Check if file is saved.
if not self.farm_output_dir:
return False
if not bpy.data.filepath and self.farm_output_dir.startswith("//"):
return False
return True
def session_get(context: bpy.types.Context) -> Session:
"""
Shortcut to get session from blender_kitsu addon preferences
"""
prefs = context.preferences.addons[__package__].preferences
return prefs.session # type: ignore
def project_root_dir_get(context: bpy.types.Context):
addon_prefs = addon_prefs_get(context)
return Path(addon_prefs.project_root_dir).joinpath('svn').resolve()
def session_auth(context: bpy.types.Context) -> bool:
"""
Shortcut to check if session is authorized
"""
return session_get(context).is_auth()
# ---------REGISTER ----------.
classes = [
KITSU_OT_prefs_media_search_path_remove,
KITSU_OT_prefs_media_search_path_add,
KITSU_task,
KITSU_media_update_search_paths,
KITSU_addon_preferences,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
# Log user out.
addon_prefs = bpy.context.preferences.addons[__package__].preferences
if addon_prefs.session.is_auth():
addon_prefs.session.end()
# Unregister classes.
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
-949
View File
@@ -1,949 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, Union, List, Dict, Optional, Tuple
import bpy
from bpy.app.handlers import persistent
from . import propsdata, bkglobals
from .logger import LoggerFactory
from . import cache
from .types import Sequence
logger = LoggerFactory.getLogger()
class KITSU_sequence_colors(bpy.types.PropertyGroup):
# name: StringProperty() -> Instantiated by default
color: bpy.props.FloatVectorProperty(
name="Sequence Color",
subtype="COLOR",
default=(1.0, 1.0, 1.0),
min=0.0,
max=1.0,
description="Sequence color that will be used as line overlay",
)
class KITSU_property_group_sequence(bpy.types.PropertyGroup):
"""
Property group that will be registered on sequence strips.
They hold metadata that will be used to compose a data structure that can
be pushed to backend.
"""
def _get_shot_description(self):
return self.shot_description
def _get_sequence_entity(self):
try:
return Sequence.by_id(self.sequence_id)
except AttributeError:
return None
manual_shot_name: bpy.props.StringProperty(
name="Shot",
description="Enter a new Shot name to submit to Kitsu Server",
default="",
) # type: ignore
###########
# Shot
###########
shot_id: bpy.props.StringProperty( # type: ignore
name="Shot ID",
description="ID that refers to the strip's shot on server",
default="",
)
def get_shot_via_name(self):
return get_safely_string_prop(self, "shot_name")
def set_shot_via_name(self, input):
seq = self._get_sequence_entity()
if seq is None:
return
# Attempt to set with matching Kitsu Entry
kitsu_set = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_shots_enum_for_seq(self, bpy.context, seq),
name_prop='shot_name',
id_prop='shot_id',
)
# Set manually so users can submit new shots
if not kitsu_set:
self['shot_name'] = input
return
def get_shot_search_list(self, context, edit_text):
seq = self._get_sequence_entity()
if seq is None:
return []
return get_enum_item_names(cache.get_shots_enum_for_seq(self, bpy.context, seq))
shot_name: bpy.props.StringProperty( # type: ignore
name="Shot",
description="Name that refers to the strip's shot on server, Reminder: press ENTER to save shot name after entering",
default="",
get=get_shot_via_name,
set=set_shot_via_name,
options=set(),
search=get_shot_search_list,
search_options={'SORT', 'SUGGESTION'},
)
shot_description: bpy.props.StringProperty(name="Description", default="", options={"HIDDEN"}) # type: ignore
###########
# Sequence
###########
sequence_id: bpy.props.StringProperty( # type: ignore
name="Seq ID",
description="ID that refers to the active sequence on server",
default="",
)
def get_sequences_via_name(self):
return get_safely_string_prop(self, "sequence_name")
def set_sequences_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_sequences_enum_list(self, bpy.context),
name_prop='sequence_name',
id_prop='sequence_id',
)
return
def get_sequence_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_sequences_enum_list(self, bpy.context))
sequence_name: bpy.props.StringProperty( # type: ignore
name="Sequence",
description="Sequence",
default="",
get=get_sequences_via_name,
set=set_sequences_via_name,
options=set(),
search=get_sequence_search_list,
search_options={'SORT'},
)
# Project.
project_name: bpy.props.StringProperty(name="Project", default="") # type: ignore
project_id: bpy.props.StringProperty(name="Project ID", default="") # type: ignore
# Meta.
initialized: bpy.props.BoolProperty( # type: ignore
name="Initialized", default=False, description="Is Kitsu shot"
)
linked: bpy.props.BoolProperty( # type: ignore
name="Linked", default=False, description="Is linked to an ID on server"
)
# Frame range.
frame_start_offset: bpy.props.IntProperty(name="Frame Start Offset")
# Media.
media_outdated: bpy.props.BoolProperty(
name="Source Media Outdated",
default=False,
description="Indicated if there is a newer version of the source media available",
)
# Display props.
shot_description_display: bpy.props.StringProperty(name="Description", get=_get_shot_description) # type: ignore
def to_dict(self):
return {
"id": self.id,
"name": self.shot,
"sequence_name": self.sequence,
"description": self.description,
}
def clear(self):
self.shot_id = ""
self.shot_name = ""
self.shot_description = ""
self.sequence_id = ""
self.sequence_name = ""
self.project_name = ""
self.project_id = ""
self.initialized = False
self.linked = False
self.frame_start_offset = 0
def unlink(self):
self.sequence_id = ""
self.project_name = ""
self.project_id = ""
self.linked = False
def set_kitsu_entity_id_via_enum_name(
self,
items: List[Tuple[str, str, str]],
input_name: str,
name_prop: str,
id_prop: str,
) -> str:
"""Set the ID and Name of a Kitsu entity by finding the matching ID for a given name
and updating both values in their corrisponding String Properties (Kitsu Context Properties)
Args:
items (List[Tuple[str, str, str]]): Enum items in the format List[Tuple[id, name, description]]
input_name (str): Name, used to find matching ID in a tuple
name_prop (str): Name of String Property where Kitsu Enitity's Name is stored
id_prop (str): Name of String Property where Kitsu Enitity's ID is stored
Returns:
str: ID of Kitsu entity
"""
if input_name == "":
self[id_prop] = ""
self[name_prop] = input_name
return
for key, value, _ in items:
if value == input_name:
self[id_prop] = key
self[name_prop] = input_name
return key
def get_enum_item_names(enum_items: List[Tuple[str, str, str]]) -> List[str]:
"""Return a list of names from a list of enum items used by (Kitsu Context Properties)
Args:
enum_items (List[Tuple[str, str, str]]): Enum items in the format List[Tuple[id, name, description]]
Returns:
List[str]: List of avaliable names
"""
return [item[1] for item in enum_items]
def get_safely_string_prop(self, name: str) -> str:
"""Return Value of String Property, and return "" if value isn't set"""
try:
return self[name]
except KeyError:
return ""
class KITSU_property_group_scene(bpy.types.PropertyGroup):
""""""
################################
# Kitsu Context Properties
################################
"""
Kitsu Context Properties
These are properties used to store/manage the current "Context"
for a Kitsu Entity. Kitsu Entities represents things like Asset,
Shot and Sequence for a given production.
Each Entity has an 'ID' Property which is used internally by the add-on
and a 'Name' Property which is used as part of the user interface. When a user selects
the 'Name' of a Kitsu Entity a custom Set function on the property will also update
the Entity's ID.
NOTE: It would be nice to have searchable enums instead of doing all this work manually.
"""
asset_col: bpy.props.PointerProperty(type=bpy.types.Collection, name="Collection")
###########
# Sequence
###########
sequence_active_id: bpy.props.StringProperty( # type: ignore
name="Active Sequence ID",
description="ID that refers to the active sequence on server",
default="",
)
def get_sequences_via_name(self):
return get_safely_string_prop(self, "sequence_active_name")
def set_sequences_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_sequences_enum_list(self, bpy.context),
name_prop='sequence_active_name',
id_prop='sequence_active_id',
)
if key:
cache.sequence_active_set_by_id(bpy.context, key)
else:
cache.sequence_active_reset_entity()
return
def get_sequence_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_sequences_enum_list(self, bpy.context))
sequence_active_name: bpy.props.StringProperty(
name="Sequence",
description="Sequence",
default="", # type: ignore
get=get_sequences_via_name,
set=set_sequences_via_name,
options=set(),
search=get_sequence_search_list,
search_options={'SORT'},
)
###########
# Episode
###########
episode_active_id: bpy.props.StringProperty( # type: ignore
name="Active Episode ID",
description="ID that refers to the active episode on server",
default="",
)
def get_episode_via_name(self):
return get_safely_string_prop(self, "episode_active_name")
def set_episode_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_episodes_enum_list(self, bpy.context),
name_prop='episode_active_name',
id_prop='episode_active_id',
)
# Clear active shot when sequence changes.
if cache.episode_active_get().id != key:
cache.sequence_active_reset(bpy.context)
cache.asset_type_active_reset(bpy.context)
cache.shot_active_reset(bpy.context)
cache.asset_active_reset(bpy.context)
if key:
cache.episode_active_set_by_id(bpy.context, key)
else:
cache.episode_active_reset_entity()
return
def get_episode_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_episodes_enum_list(self, bpy.context))
episode_active_name: bpy.props.StringProperty(
name="Episode",
description="Selet Active Episode",
default="", # type: ignore
get=get_episode_via_name,
set=set_episode_via_name,
options=set(),
search=get_episode_search_list,
search_options={'SORT'},
)
###########
# Shot
###########
shot_active_id: bpy.props.StringProperty( # type: ignore
name="Active Shot ID",
description="IDthat refers to the active shot on server",
default="",
# update=propsdata.on_shot_change,
)
def get_shot_via_name(self):
return get_safely_string_prop(self, "shot_active_name")
def set_shot_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_shots_enum_for_active_seq(self, bpy.context),
name_prop='shot_active_name',
id_prop='shot_active_id',
)
if key:
cache.shot_active_set_by_id(bpy.context, key)
else:
cache.shot_active_reset_entity()
return
def get_shot_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_shots_enum_for_active_seq(self, bpy.context))
shot_active_name: bpy.props.StringProperty(
name="Shot",
description="Shot",
default="", # type: ignore
get=get_shot_via_name,
set=set_shot_via_name,
options=set(),
search=get_shot_search_list,
search_options={'SORT'},
)
###########
# Asset
###########
asset_active_id: bpy.props.StringProperty( # type: ignore
name="Active Asset ID",
description="ID that refers to the active asset on server",
default="",
)
def get_asset_via_name(self):
return get_safely_string_prop(self, "asset_active_name")
def set_asset_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_assets_enum_for_active_asset_type(self, bpy.context),
name_prop='asset_active_name',
id_prop='asset_active_id',
)
if key:
cache.asset_active_set_by_id(bpy.context, key) # TODO
else:
cache.asset_active_reset_entity()
return
def get_asset_type_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_assets_enum_for_active_asset_type(self, bpy.context))
asset_active_name: bpy.props.StringProperty(
name="Asset",
description="Active Asset",
default="", # type: ignore
get=get_asset_via_name,
set=set_asset_via_name,
options=set(),
search=get_asset_type_search_list,
search_options={'SORT'},
)
############
# Asset Type
############
asset_type_active_id: bpy.props.StringProperty( # type: ignore
name="Active Asset Type ID",
description="ID that refers to the active asset type on server",
default="",
)
def get_asset_type_via_name(self):
return get_safely_string_prop(self, "asset_type_active_name")
def set_asset_type_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_assetypes_enum_list(self, bpy.context),
name_prop='asset_type_active_name',
id_prop='asset_type_active_id',
)
if key:
cache.asset_type_active_set_by_id(bpy.context, key)
else:
cache.asset_type_active_reset_entity()
return
def get_asset_type_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_assetypes_enum_list(self, bpy.context))
asset_type_active_name: bpy.props.StringProperty(
name="Asset Type",
description="Active Asset Type Name",
default="", # type: ignore
get=get_asset_type_via_name,
set=set_asset_type_via_name,
options=set(),
search=get_asset_type_search_list,
search_options={'SORT'},
)
############
# Task Type
############
task_type_active_id: bpy.props.StringProperty( # type: ignore
name="Active Task Type ID",
description="ID that refers to the active task type on server",
default="",
)
def get_task_type_via_name(self):
return get_safely_string_prop(self, "task_type_active_name")
def set_task_type_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_task_types_enum_for_current_context(self, bpy.context),
name_prop='task_type_active_name',
id_prop='task_type_active_id',
)
if key:
cache.task_type_active_set_by_id(bpy.context, key)
else:
cache.task_type_active_reset_entity()
return
def get_task_type_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_task_types_enum_for_current_context(self, bpy.context))
task_type_active_name: bpy.props.StringProperty(
name="Task Type",
description="Active Task Type Name",
default="", # type: ignore
get=get_task_type_via_name,
set=set_task_type_via_name,
options=set(),
search=get_task_type_search_list,
search_options={'SORT'},
)
category: bpy.props.EnumProperty( # type: ignore
name="Type",
description="Kitsu entity type",
items=(
("ASSET", "Asset", "Asset related tasks", 0),
("SHOT", "Shot", "Shot related tasks", 1),
("SEQ", "Sequence", "Sequence related tasks", 2),
("EDIT", "Edit", "Edit related tasks", 3),
),
default="SHOT",
update=propsdata.reset_all_kitsu_props,
)
############
# Edit
############
edit_active_id: bpy.props.StringProperty( # type: ignore
name="Active Edit ID",
description="ID that refers to the active edit on server",
default="",
)
def get_edit_via_name(self):
return get_safely_string_prop(self, "edit_active_name")
def set_edit_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_all_edits_enum_for_active_project(self, bpy.context),
name_prop='edit_active_name',
id_prop='edit_active_id',
)
if key:
cache.edit_active_set_by_id(bpy.context, key)
else:
cache.edit_active_reset_entity()
return
def get_edit_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_all_edits_enum_for_active_project(self, bpy.context))
edit_active_name: bpy.props.StringProperty(
name="Edit",
description="Active Edit Name",
default="", # type: ignore
get=get_edit_via_name,
set=set_edit_via_name,
options=set(),
search=get_edit_search_list,
search_options={'SORT'},
)
edit_export_version: bpy.props.StringProperty(name="Version", default="v001")
edit_export_file: bpy.props.StringProperty( # type: ignore
name="Edit Export Filepath",
description="Output filepath of Edit Export",
default="",
subtype="FILE_PATH",
get=propsdata.get_edit_export_file,
)
# Thumbnail props.
task_type_thumbnail_id: bpy.props.StringProperty( # type: ignore
name="Thumbnail Task Type ID",
description="ID that refers to the task type on server for which thumbnails will be uploaded",
default="",
)
task_type_thumbnail_name: bpy.props.StringProperty( # type: ignore
name="Thumbnail Task Type Name",
description="Name that refers to the task type on server for which thumbnails will be uploaded",
default="",
)
# Sqe render props.
task_type_sqe_render_id: bpy.props.StringProperty( # type: ignore
name="Sqe Render Task Type ID",
description="ID that refers to the task type on server for which the sqe render will be uploaded",
default="",
)
task_type_sqe_render_name: bpy.props.StringProperty( # type: ignore
name="Sqe Render Task Type Name",
description="Name that refers to the task type on server for which the sqe render will be uploaded",
default="",
)
# Playblast props.
playblast_version: bpy.props.StringProperty(name="Version", default="v001")
playblast_dir: bpy.props.StringProperty( # type: ignore
name="Playblast Directory",
description="Directory in which playblasts will be saved",
default="",
subtype="DIR_PATH",
get=propsdata.get_playblast_dir,
)
playblast_file: bpy.props.StringProperty( # type: ignore
name="Playblast Filepath",
description="Output filepath of playblast",
default="",
subtype="FILE_PATH",
get=propsdata.get_playblast_file,
)
playblast_task_status_id: bpy.props.StringProperty( # type: ignore
name="Plablast Task Status ID",
description="ID that refers to the task status on server which the playblast will set",
default="",
)
playblast_render_mode: bpy.props.EnumProperty( # type: ignore
name="Playblast Render Mode",
description="Choose to either Render Playblast from current Viewport or use scene's render settings",
items=[
(
"SCENE",
"Scene",
"Render using the scene's render settings, playblast will match 'Render Animation's' output exactly",
),
(
"VIEWPORT",
"Viewport",
"Render from the current viewport, with viewport shading settings and viewport overlays; what you see is what you get",
),
(
"VIEWPORT_PRESET",
"Viewport with Preset Shading",
"Render from the current viewport with Add-On's default shading settings, and automatically hide all overlays",
),
],
)
# Sequence editor tools.
pull_edit_channel: bpy.props.IntProperty(
name="Channel",
description="On which channel the operator will create the color strips",
default=1,
min=1,
max=32,
)
sequence_colors: bpy.props.CollectionProperty(type=KITSU_sequence_colors)
class KITSU_property_group_error(bpy.types.PropertyGroup):
""""""
frame_range: bpy.props.BoolProperty( # type: ignore
name="Frame Range Error",
description="Indicates if the scene frame range does not match the one in Kitsu",
default=False,
)
class KITSU_property_group_window_manager(bpy.types.PropertyGroup):
""""""
tasks_index: bpy.props.IntProperty(name="Tasks Index", default=0)
quick_duplicate_amount: bpy.props.IntProperty(
name="Amount",
default=1,
min=1,
description="Specifies the number of copies that will be created",
)
def clear(self):
pass
def _add_window_manager_props():
# Multi Edit Properties.
bpy.types.WindowManager.show_advanced = bpy.props.BoolProperty(
name="Show Advanced",
description="Shows advanced options to fine control shot pattern",
default=False,
)
bpy.types.WindowManager.var_use_custom_seq = bpy.props.BoolProperty(
name="Use Custom",
description="Enables to type in custom sequence name for <Sequence> wildcard",
default=False,
)
bpy.types.WindowManager.var_use_custom_project = bpy.props.BoolProperty(
name="Use Custom",
description="Enables to type in custom project name for <Project> wildcard",
default=False,
)
bpy.types.WindowManager.var_sequence_custom = bpy.props.StringProperty( # type: ignore
name="Custom Sequence Variable",
description="Value that will be used to insert in <Sequence> wildcard if custom sequence is enabled",
default="",
)
bpy.types.WindowManager.var_project_custom = bpy.props.StringProperty( # type: ignore
name="Custom Project Variable",
description="Value that will be used to insert in <Project> wildcard if custom project is enabled",
default="",
)
bpy.types.WindowManager.shot_counter_start = bpy.props.IntProperty(
description="Value that defines where the shot counter starts",
step=10,
min=0,
)
bpy.types.WindowManager.shot_preview = bpy.props.StringProperty(
name="Shot Pattern",
description="Preview result of current settings on how a shot will be named",
get=propsdata._gen_shot_preview,
)
bpy.types.WindowManager.var_project_active = bpy.props.StringProperty(
name="Active Project",
description="Value that will be inserted in <Project> wildcard",
get=propsdata._get_project_active,
)
###########
# Sequence
###########
bpy.types.WindowManager.selected_sequence_id = bpy.props.StringProperty( # type: ignore
name="Active Sequence ID",
description="ID that refers to the active sequence on server",
default="",
)
def get_sequences_via_name(self):
return get_safely_string_prop(self, "selected_sequence_name")
def set_sequences_via_name(self, input):
key = set_kitsu_entity_id_via_enum_name(
self=self,
input_name=input,
items=cache.get_sequences_enum_list(self, bpy.context),
name_prop='selected_sequence_name',
id_prop='selected_sequence_id',
)
if key:
cache.sequence_active_set_by_id(bpy.context, key)
else:
cache.sequence_active_reset_entity()
return
def get_sequence_search_list(self, context, edit_text):
return get_enum_item_names(cache.get_sequences_enum_list(self, bpy.context))
bpy.types.WindowManager.selected_sequence_name = bpy.props.StringProperty(
name="Sequence",
description="Name of Sequence the generated Shots will be assinged to",
default="", # type: ignore
get=get_sequences_via_name,
set=set_sequences_via_name,
options=set(),
search=get_sequence_search_list,
search_options={'SORT'},
)
# Advanced delete props.
bpy.types.WindowManager.advanced_delete = bpy.props.BoolProperty(
name="Advanced Delete",
description="Checkbox to show advanced shot deletion operations",
default=False,
)
def _clear_window_manager_props():
del bpy.types.WindowManager.show_advanced
del bpy.types.WindowManager.var_use_custom_seq
del bpy.types.WindowManager.var_use_custom_project
del bpy.types.WindowManager.var_sequence_custom
del bpy.types.WindowManager.var_project_custom
del bpy.types.WindowManager.shot_counter_start
del bpy.types.WindowManager.shot_preview
del bpy.types.WindowManager.var_project_active
del bpy.types.WindowManager.selected_sequence_id
del bpy.types.WindowManager.selected_sequence_name
def _calc_kitsu_3d_start(self):
"""
Calculates strip.kitsu_3d_start, little hack because it seems like we cant access the strip from a property group
But we need acess to seqeuence properties.
"""
return int(self.frame_final_start - self.frame_start + bkglobals.FRAME_START)
def _calc_kitsu_frame_end(self):
"""
Calculates strip.kitsu_frame_end, little hack because it seems like we cant access the strip from a property group
But we need acess to seqeuence properties.
"""
frame_start = _calc_kitsu_3d_start(self)
frame_end_final = frame_start + (self.frame_final_duration - 1)
return int(frame_end_final)
def _get_frame_final_duration(self):
return self.frame_final_duration
def _get_strip_filepath(self):
return self.filepath
@persistent
def update_sequence_colors_coll_prop(dummy: Any) -> None:
sqe = bpy.context.scene.sequence_editor
if not sqe:
return
sequences = sqe.sequences_all
sequence_colors = bpy.context.scene.kitsu.sequence_colors
existings_seq_ids: List[str] = []
# Append missing sequences to scene.kitsu.seqeuence_colors.
for seq in sequences:
if not seq.kitsu.sequence_id:
continue
if seq.kitsu.sequence_id not in sequence_colors.keys():
logger.info("Added %s to scene.kitsu.seqeuence_colors", seq.kitsu.sequence_name)
item = sequence_colors.add()
item.name = seq.kitsu.sequence_id
existings_seq_ids.append(seq.kitsu.sequence_id)
# Delete sequence colors that are not in edit anymore.
existings_seq_ids = set(existings_seq_ids)
to_be_removed = [seq_id for seq_id in sequence_colors.keys() if seq_id not in existings_seq_ids]
for seq_id in to_be_removed:
idx = sequence_colors.find(seq_id)
if idx == -1:
continue
sequence_colors.remove(idx)
logger.info(
"Removed %s from scene.kitsu.seqeuence_colors. Is not used in the sequence editor anymore",
seq_id,
)
# ----------------REGISTER--------------.
classes = [
KITSU_sequence_colors,
KITSU_property_group_sequence,
KITSU_property_group_scene,
KITSU_property_group_error,
KITSU_property_group_window_manager,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
# FRAME RANGE PROPERTIES
# because we cant acess strip properties from a sequence group we need to create this properties
# directly on the strip, as we need strip properties to calculate
bpy.types.Strip.kitsu_3d_start = bpy.props.IntProperty(
name="3D In",
get=_calc_kitsu_3d_start,
)
bpy.types.Strip.kitsu_frame_end = bpy.props.IntProperty(
name="3D Out",
get=_calc_kitsu_frame_end,
)
bpy.types.Strip.kitsu_frame_duration = bpy.props.IntProperty(
name="Duration",
get=_get_frame_final_duration,
)
# Used in general tools panel next to sqe_change_strip_source operator.
bpy.types.MovieStrip.filepath_display = bpy.props.StringProperty(
name="Filepath Display", get=_get_strip_filepath
)
# Sequence Properties.
bpy.types.Strip.kitsu = bpy.props.PointerProperty(
name="Kitsu",
type=KITSU_property_group_sequence,
description="Metadata that is required for blender_kitsu",
)
# Scene Properties.
bpy.types.Scene.kitsu = bpy.props.PointerProperty(
name="Kitsu",
type=KITSU_property_group_scene,
description="Metadata that is required for blender_kitsu",
)
# Window Manager.
bpy.types.WindowManager.kitsu = bpy.props.PointerProperty(
name="Kitsu",
type=KITSU_property_group_window_manager,
description="Metadata that is required for blender_kitsu",
)
# Error Properties.
bpy.types.Scene.kitsu_error = bpy.props.PointerProperty(
name="Kitsu Error",
type=KITSU_property_group_error,
description="Error property group",
)
# Window Manager Properties.
_add_window_manager_props()
# Handlers.
bpy.app.handlers.load_post.append(update_sequence_colors_coll_prop)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
# Clear properties.
_clear_window_manager_props()
# Handlers.
bpy.app.handlers.load_post.remove(update_sequence_colors_coll_prop)
-225
View File
@@ -1,225 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from typing import Any, Dict, List, Tuple
from pathlib import Path
import bpy
from . import bkglobals
from . import cache, prefs
# TODO: restructure that to not import from anim.
from .playblast import ops as ops_playblast
from .playblast import opsdata as ops_playblast_data
from .logger import LoggerFactory
from .context import core as context_core
logger = LoggerFactory.getLogger()
# Get functions for window manager properties.
def _get_project_active(self):
return cache.project_active_get().name
def _resolve_pattern(pattern: str, var_lookup_table: Dict[str, str]) -> str:
matches = re.findall(r"\<(\w+)\>", pattern)
matches = list(set(matches))
# If no variable detected just return value.
if len(matches) == 0:
return pattern
else:
result = pattern
for to_replace in matches:
if to_replace in var_lookup_table:
to_insert = var_lookup_table[to_replace]
result = result.replace("<{}>".format(to_replace), to_insert)
else:
logger.warning("Failed to resolve variable: %s not defined!", to_replace)
return ""
return result
def _get_sequences(self: Any, context: bpy.types.Context) -> List[Tuple[str, str, str]]:
addon_prefs = bpy.context.preferences.addons[__package__].preferences
project_active = cache.project_active_get()
if not project_active or not addon_prefs.session.is_auth:
return [("None", "None", "")]
enum_list = [(s.name, s.name, "") for s in project_active.get_sequences_all()]
return enum_list
def _gen_shot_preview(self: Any) -> str:
addon_prefs = bpy.context.preferences.addons[__package__].preferences
shot_counter_increment = addon_prefs.shot_counter_increment
shot_counter_digits = addon_prefs.shot_counter_digits
shot_counter_start = self.shot_counter_start
shot_pattern = addon_prefs.shot_pattern
examples: List[str] = []
sequence = self.selected_sequence_name
episode = cache.episode_active_get()
var_project = (
self.var_project_custom if self.var_use_custom_project else self.var_project_active
)
var_sequence = self.var_sequence_custom if self.var_use_custom_seq else sequence
var_lookup_table = {
"Sequence": var_sequence,
"Project": var_project,
"Episode": episode.name,
}
for count in range(3):
counter_number = shot_counter_start + (shot_counter_increment * count)
counter = str(counter_number).rjust(shot_counter_digits, "0")
var_lookup_table["Counter"] = counter
examples.append(_resolve_pattern(shot_pattern, var_lookup_table))
return " | ".join(examples) + "..."
def get_task_type_name_file_suffix() -> str:
name = cache.task_type_active_get().name.lower()
task_mappings = {**bkglobals.SHOT_TASK_MAPPING, **bkglobals.SEQ_TASK_MAPPING}
for key, value in task_mappings.items():
if name == value.lower():
return key
return name
def get_playblast_dir(self: Any) -> str:
# shared/editorial/footage/{dev,pre,pro,post}
# shared/editorial/<episode>/footage/{dev,pre,pro,post}
# shared/editorial/footage//110_rextoria/110_0030/110_0030-anim
addon_prefs = prefs.addon_prefs_get(bpy.context)
if not addon_prefs.is_playblast_root_valid:
return ""
episode = cache.episode_active_get()
sequence = cache.sequence_active_get()
shot = cache.shot_active_get()
delimiter = bkglobals.DELIMITER
# Start building path
if context_core.is_sequence_context():
playblast_dir = addon_prefs.seq_playblast_root_path
else:
playblast_dir = addon_prefs.shot_playblast_root_path
playblast_dir = set_episode_variable(playblast_dir)
if context_core.is_sequence_context():
playblast_dir = playblast_dir / sequence.name / 'sequence_previews'
return playblast_dir.as_posix()
task_type_name_suffix = get_task_type_name_file_suffix()
playblast_dir = (
playblast_dir / sequence.name / shot.name / f"{shot.name}{delimiter}{task_type_name_suffix}"
)
return playblast_dir.as_posix()
def get_playblast_file(self: Any) -> str:
if not self.playblast_dir:
return ""
task_type_name_suffix = get_task_type_name_file_suffix()
version = self.playblast_version
shot = cache.shot_active_get()
episode = cache.episode_active_get()
sequence = cache.sequence_active_get()
delimiter = bkglobals.DELIMITER
# 070_0010_A-anim-v001.mp4.
kitsu_props = bpy.context.scene.kitsu
kitsu_props.get("category")
if context_core.is_sequence_context():
# Assuming sequences called 000_name, get 000
entity_name = sequence.name.split('_')[0]
if episode:
# If episode is present, append to the name
entity_name = f"{episode.name}_{entity_name}"
elif context_core.is_shot_context():
entity_name = shot.name
else:
entity_name = ''
file_name = f"{entity_name}{delimiter}{task_type_name_suffix}{delimiter}{version}.mp4"
return Path(self.playblast_dir).joinpath(file_name).as_posix()
def get_edit_export_dir() -> str:
addon_prefs = prefs.addon_prefs_get(bpy.context)
return set_episode_variable(Path(addon_prefs.edit_export_dir))
def get_edit_export_file(self: Any) -> str:
addon_prefs = prefs.addon_prefs_get(bpy.context)
export_dir = get_edit_export_dir()
version = self.edit_export_version
file_pattern = addon_prefs.edit_export_file_pattern
file_name = file_pattern.replace('v###', version)
return export_dir.joinpath(file_name).as_posix()
_active_category_cache_init: bool = False
_active_category_cache: str = ""
def reset_task_type(self: Any, context: bpy.types.Context) -> None:
global _active_category_cache_init
global _active_category_cache
if not _active_category_cache_init:
_active_category_cache = self.category
_active_category_cache_init = True
return
if self.category == _active_category_cache:
return None
cache.task_type_active_reset(context)
_active_category_cache = self.category
return None
def on_shot_change(self: Any, context: bpy.types.Context) -> None:
# Reset versions.
ops_playblast_data.init_playblast_file_model(context)
# Check frame range.
ops_playblast.load_post_handler_check_frame_range(context)
def reset_all_kitsu_props(self: Any, context: bpy.types.Context) -> None:
cache.sequence_active_reset(context)
cache.asset_type_active_reset(context)
cache.shot_active_reset(context)
cache.asset_active_reset(context)
cache.episode_active_reset(context)
cache.task_type_active_reset(context)
def set_episode_variable(base_path: Path) -> Path:
episode = cache.episode_active_get()
active_project = cache.project_active_get()
if not (
episode
and '<episode>' in base_path.parts
and active_project.production_type == bkglobals.KITSU_TV_PROJECT
):
return base_path
i = base_path.parts.index('<episode>')
return Path(*base_path.parts[:i]).joinpath(episode.name, *base_path.parts[i + 1 :])
@@ -1,44 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from . import (
util,
props,
opsdata,
checksqe,
ops,
ui,
draw,
)
_need_reload = "ops" in locals()
if _need_reload:
import importlib
util = importlib.reload(util)
props = importlib.reload(props)
opsdata = importlib.reload(opsdata)
checksqe = importlib.reload(checksqe)
ops = importlib.reload(ops)
ui = importlib.reload(ui)
draw = importlib.reload(draw)
def register():
props.register()
ops.register()
ui.register()
draw.register()
def unregister():
draw.unregister()
ui.unregister()
ops.unregister()
props.unregister()
@@ -1,78 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Dict, List, Set, Optional, Tuple, Any
import bpy
def _do_ranges_collide(range1: range, range2: range) -> bool:
"""Whether the two ranges collide with each other ."""
# usual strip setup strip1(101, 120)|strip2(120, 130)|strip3(130, 140)
# first and last frame can be the same for each strip
range2 = range(range2.start + 1, range2.stop - 1)
if not range1:
return True # empty range is subset of anything
if not range2:
return False # non-empty range can't be subset of empty range
if len(range1) > 1 and range1.step % range2.step:
return False # must have a single value or integer multiple step
if range(range1.start + 1, range1.stop - 1) == range2:
return True
if range2.start in range1 or range2[-1] in range1:
return True
return range1.start in range2 or range1[-1] in range2
def get_occupied_ranges(context: bpy.types.Context) -> Dict[str, List[range]]:
"""
Scans sequence editor and returns a dictionary. It contains a key for each channel
and a list of ranges with the occupied frame ranges as values.
"""
# {'1': [(101, 213), (300, 320)]}.
ranges: Dict[str, List[range]] = {}
# Populate ranges.
for strip in context.scene.sequence_editor.sequences_all:
ranges.setdefault(str(strip.channel), [])
ranges[str(strip.channel)].append(
range(strip.frame_final_start, strip.frame_final_end + 1)
)
# Sort ranges tuple list.
for channel in ranges:
liste = ranges[channel]
ranges[channel] = sorted(liste, key=lambda item: item.start)
return ranges
def get_occupied_ranges_for_strips(sequences: List[bpy.types.Strip]) -> List[range]:
"""
Scans input list of sequences and returns a list of ranges that represent the occupied frame ranges.
"""
ranges: List[range] = []
# Populate ranges.
for strip in sequences:
ranges.append(range(strip.frame_final_start, strip.frame_final_end + 1))
# Sort ranges tuple list.
ranges.sort(key=lambda item: item.start)
return ranges
def is_range_occupied(range_to_check: range, occupied_ranges: List[range]) -> bool:
for r in occupied_ranges:
# Range(101, 150).
if _do_ranges_collide(range_to_check, r):
return True
continue
return False
@@ -1,207 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import typing
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
APPROVED_COLOR = (0.24, 1, 0.139, 0.7)
PUSHED_TO_EDIT_COLOR = (0.8, .8, 0.1, 0.5)
Float2 = typing.Tuple[float, float]
Float3 = typing.Tuple[float, float, float]
Float4 = typing.Tuple[float, float, float, float]
LINE_WIDTH = 6
class LineDrawer:
def draw(self, coords: typing.List[Float2], colors: typing.List[Float4]):
line_shader = gpu.shader.from_builtin('FLAT_COLOR')
global LINE_WIDTH
if not coords:
return
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(LINE_WIDTH)
batch = batch_for_shader(
line_shader, 'LINES',
{"pos": coords, "color": colors}
)
batch.draw(line_shader)
def get_strip_rectf(strip) -> Float4:
# Get x and y in terms of the grid's frames and channels.
x1 = strip.frame_final_start
x2 = strip.frame_final_end
# Seems to be a 5 % offset from channel top start of strip.
y1 = strip.channel + 0.05
y2 = strip.channel - 0.05 + 1
return x1, y1, x2, y2
def line_in_strip(
strip_coords: Float4,
pixel_size_x: float,
color: Float4,
line_height_factor: float,
out_coords: typing.List[Float2],
out_colors: typing.List[Float4],
):
# Strip coords.
s_x1, s_y1, s_x2, s_y2 = strip_coords
# Calculate line height with factor.
line_y = (1 - line_height_factor) * s_y1 + line_height_factor * s_y2
# if strip is shorter than line_width use stips s_x2
# line_x2 = s_x1 + line_width if (s_x2 - s_x1 > line_width) else s_x2
line_x2 = s_x2
# Be careful not to draw over the current frame line.
cf_x = bpy.context.scene.frame_current_final
# TODO(Sybren): figure out how to pass one colour per line,
# instead of one colour per vertex.
out_coords.append((s_x1, line_y))
out_colors.append(color)
if s_x1 < cf_x < line_x2:
# Bad luck, the line passes our strip, so draw two lines.
out_coords.append((cf_x - pixel_size_x, line_y))
out_colors.append(color)
out_coords.append((cf_x + pixel_size_x, line_y))
out_colors.append(color)
out_coords.append((line_x2, line_y))
out_colors.append(color)
def draw_callback_px(line_drawer: LineDrawer):
global LINE_WIDTH
context = bpy.context
if not context.scene.sequence_editor:
return
# From . import shown_strips.
region = context.region
xwin1, ywin1 = region.view2d.region_to_view(0, 0)
xwin2, ywin2 = region.view2d.region_to_view(region.width, region.height)
one_pixel_further_x, one_pixel_further_y = region.view2d.region_to_view(1, 1)
pixel_size_x = one_pixel_further_x - xwin1
# Strips = shown_strips(context).
strips = context.scene.sequence_editor.sequences_all
coords = [] # type: typing.List[Float2]
colors = [] # type: typing.List[Float4]
# Collect all the lines (vertex coords + vertex colours) to draw.
for strip in strips:
# Get corners (x1, y1), (x2, y2) of the strip rectangle in px region coords.
strip_coords = get_strip_rectf(strip)
# Check if any of the coordinates are out of bounds.
if (
strip_coords[0] > xwin2
or strip_coords[2] < xwin1
or strip_coords[1] > ywin2
or strip_coords[3] < ywin1
):
continue
if strip.rr.is_approved:
line_in_strip(
strip_coords,
pixel_size_x,
APPROVED_COLOR,
0.05,
coords,
colors,
)
elif strip.rr.is_pushed_to_edit:
line_in_strip(
strip_coords,
pixel_size_x,
PUSHED_TO_EDIT_COLOR,
0.05,
coords,
colors,
)
line_drawer.draw(coords, colors)
def tag_redraw_all_sequencer_editors():
context = bpy.context
# Py cant access notifiers.
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.type == "SEQUENCE_EDITOR":
for region in area.regions:
if region.type == "WINDOW":
region.tag_redraw()
# This is a list so it can be changed instead of set
# if it is only changed, it does not have to be declared as a global everywhere
cb_handle = []
def callback_enable():
global cb_handle
if cb_handle:
return
# Doing GPU stuff in the background crashes Blender, so let's not.
if bpy.app.background:
return
line_drawer = LineDrawer()
cb_handle[:] = (
bpy.types.SpaceSequenceEditor.draw_handler_add(
draw_callback_px, (line_drawer,), "WINDOW", "POST_VIEW"
),
)
tag_redraw_all_sequencer_editors()
def callback_disable():
global cb_handle
if not cb_handle:
return
try:
bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], "WINDOW")
except ValueError:
# Thrown when already removed.
pass
cb_handle.clear()
tag_redraw_all_sequencer_editors()
# ---------REGISTER ----------.
def register():
callback_enable()
def unregister():
callback_disable()
@@ -1,8 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
class NoImageStripAvailableException(Exception):
"""
Error raised when trying to gather image sequence in folder but no files are existent
"""
File diff suppressed because it is too large Load Diff
@@ -1,431 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import math
import shutil
from pathlib import Path
from typing import Set, Union, Optional, List, Dict, Any, Tuple
import bpy
from . import vars, checksqe, util
from .. import prefs, cache
from ..sqe import opsdata as sqe_opsdata
from .exception import NoImageStripAvailableException
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
copytree_list: List[Path] = []
copytree_num_of_items: int = 0
copytree_prev_printed_len: int = 0
def copytree_verbose(src: Union[str, Path], dest: Union[str, Path], **kwargs):
_copytree_init_progress_update(Path(src))
shutil.copytree(src, dest, copy_function=_copy2_tree_progress, **kwargs)
_copytree_clear_progress_update()
def _copytree_init_progress_update(source_dir: Path):
global copytree_num_of_items
file_list = [f for f in source_dir.glob("**/*") if f.is_file()]
copytree_num_of_items = len(file_list)
def _copy2_tree_progress(src, dst):
"""
Function that can be used for copy_function
argument on shutil.copytree function.
Logs every item that is currently copied.
"""
global copytree_num_of_items
global copytree_list
global copytree_prev_printed_len
term_col_len = shutil.get_terminal_size(fallback=(0, 0))[0]
if term_col_len != 0:
delete_lines = math.ceil(copytree_prev_printed_len / term_col_len)
else:
delete_lines = 0
copytree_list.append(Path(src))
progress = round((len(copytree_list) * 100) / copytree_num_of_items)
delete_prev_line = "\033[1A\x1b[2K"
message = "Copying %s (%i%%)" % (src, progress)
logger.warn(delete_lines * delete_prev_line + message)
shutil.copy2(src, dst)
copytree_prev_printed_len = len(message)
def _copytree_clear_progress_update():
global copytree_num_of_items
global copytree_list
global copytree_prev_printed_len
copytree_num_of_items = 0
copytree_list.clear()
copytree_prev_printed_len = 0
def get_valid_cs_sequences(
context: bpy.types.Context, sequence_list: List[bpy.types.Strip] = []
) -> List[bpy.types.Strip]:
sequences: List[bpy.types.Strip] = []
if sequence_list:
sequences = sequence_list
else:
sequences = context.selected_sequences or context.scene.sequence_editor.sequences_all
if cache.project_active_get():
valid_sequences = [
s
for s in sequences
if s.type in ["MOVIE", "IMAGE"] and not s.mute and not s.kitsu.initialized
]
else:
valid_sequences = [s for s in sequences if s.type in ["MOVIE", "IMAGE"] and not s.mute]
return valid_sequences
def get_frames_root_dir(strip: bpy.types.Strip) -> Path:
# sf = shot_frames | fo = farm_output.
addon_prefs = prefs.addon_prefs_get(bpy.context)
fo_dir = get_strip_folder(strip)
sf_dir = addon_prefs.frames_root_dir / fo_dir.parent.relative_to(fo_dir.parents[3])
return sf_dir
def get_strip_folder(strip: bpy.types.Strip) -> Path:
if hasattr(strip, 'directory'):
return Path(strip.directory)
else:
return Path(strip.filepath).parent
def get_shot_previews_path(strip: bpy.types.Strip) -> Path:
# Fo > farm_output.
addon_prefs = prefs.addon_prefs_get(bpy.context)
fo_dir = get_strip_folder(strip)
shot_previews_dir = addon_prefs.shot_playblast_root_dir / fo_dir.parent.relative_to(
fo_dir.parents[3]
)
return shot_previews_dir
def get_shot_dot_task_type(path: Path):
return path.parent.name
def get_farm_output_mp4_path(strip: bpy.types.Strip) -> Path:
render_dir = get_strip_folder(strip)
return get_farm_output_mp4_path_from_folder(render_dir)
def get_farm_output_mp4_path_from_folder(render_dir: str) -> Path:
render_dir = Path(render_dir)
shot_name = render_dir.parent.name
# 070_0040_A.lighting-101-136.mp4 #farm always does .lighting not .comp
# because flamenco writes in and out frame in filename we need check the first and
# last frame in the folder
preview_seq = get_best_preview_sequence(render_dir)
mp4_filename = f"{shot_name}-{int(preview_seq[0].stem)}-{int(preview_seq[-1].stem)}.mp4"
return render_dir / mp4_filename
def get_best_preview_sequence(dir: Path) -> List[Path]:
files: List[List[Path]] = gather_files_by_suffix(
dir, output=dict, search_suffixes=[".jpg", ".png"]
)
if not files:
raise NoImageStripAvailableException(f"No preview files found in: {dir.as_posix()}")
# Select the right images sequence.
if len(files) == 1:
# If only one image sequence available take that.
preview_seq = files[list(files.keys())[0]]
# Both jpg and png available.
else:
# If same amount of frames take png.
if len(files[".jpg"]) == len(files[".png"]):
preview_seq = files[".png"]
else:
# If not, take whichever is longest.
preview_seq = [files[".jpg"], files[".png"]].sort(key=lambda x: len(x))[-1]
return preview_seq
def get_shot_frames_backup_path(strip: bpy.types.Strip) -> Path:
fs_dir = get_frames_root_dir(strip)
return fs_dir.parent / f"_backup.{fs_dir.name}"
def get_shot_frames_metadata_path(strip: bpy.types.Strip) -> Path:
fs_dir = get_frames_root_dir(strip)
return fs_dir.parent / "metadata.json"
def get_shot_previews_metadata_path(strip: bpy.types.Strip) -> Path:
fs_dir = get_shot_previews_path(strip)
return fs_dir / "metadata.json"
def load_json(path: Path) -> Any:
with open(path.as_posix(), "r") as file:
obj = json.load(file)
return obj
def save_to_json(obj: Any, path: Path) -> None:
with open(path.as_posix(), "w") as file:
json.dump(obj, file, indent=4)
def update_sequence_statuses(
context: bpy.types.Context,
) -> List[bpy.types.Strip]:
return update_is_approved(context), update_is_pushed_to_edit(context)
def update_is_approved(
context: bpy.types.Context,
) -> List[bpy.types.Strip]:
sequences = [s for s in context.scene.sequence_editor.sequences_all if s.rr.is_render]
approved_strips = []
for s in sequences:
metadata_path = get_shot_frames_metadata_path(s)
if not metadata_path.exists():
continue
json_obj = load_json(metadata_path) # TODO: prevent opening same json multi times
if Path(json_obj["source_current"]) == get_strip_folder(s):
s.rr.is_approved = True
approved_strips.append(s)
logger.info("Detected approved strip: %s", s.name)
else:
s.rr.is_approved = False
return approved_strips
def update_is_pushed_to_edit(
context: bpy.types.Context,
) -> List[bpy.types.Strip]:
sequences = [s for s in context.scene.sequence_editor.sequences_all if s.rr.is_render]
pushed_strips = []
for s in sequences:
metadata_path = get_shot_previews_metadata_path(s)
if not metadata_path.exists():
continue
json_obj = load_json(metadata_path)
valid_paths = {Path(value).parent for _key, value in json_obj.items()}
if get_strip_folder(s) in valid_paths:
s.rr.is_pushed_to_edit = True
pushed_strips.append(s)
logger.info("Detected pushed strip: %s", s.name)
else:
s.rr.is_pushed_to_edit = False
return pushed_strips
def gather_files_by_suffix(
dir: Path, output=str, search_suffixes: List[str] = [".jpg", ".png", ".exr"]
) -> Union[str, List, Dict]:
"""
Gathers files in dir that end with an extension in search_suffixes.
Supported values for output: str, list, dict
"""
files: Dict[str, List[Path]] = {}
# Gather files.
for f in dir.iterdir():
if not f.is_file():
continue
for suffix in search_suffixes:
if f.suffix == suffix:
files.setdefault(suffix, [])
files[suffix].append(f)
# Sort.
for suffix, file_list in files.items():
files[suffix] = sorted(file_list, key=lambda f: f.name)
# Return.
if output == str:
return_str = ""
for suffix, file_list in files.items():
return_str += f" | {suffix}: {len(file_list)}"
# Replace first occurence, we dont want that at the beginning.
return_str = return_str.replace(" | ", "", 1)
return return_str
elif output == dict:
return files
elif output == list:
output_list = []
for suffix, file_list in files.items():
output_list.append(file_list)
return output_list
else:
raise ValueError(
f"Supported output types are: str, dict, list. {str(output)} not implemented yet."
)
def gen_frames_found_text(dir: Path, search_suffixes: List[str] = [".jpg", ".png", ".exr"]) -> str:
files_dict = gather_files_by_suffix(dir, output=dict, search_suffixes=search_suffixes)
frames_found_text = "" # frames found text will be used in ui
for suffix, file_list in files_dict.items():
frames_found_text += f" | {suffix}: {len(file_list)}"
# Replace first occurence, we dont want that at the beginning.
frames_found_text = frames_found_text.replace(
" | ",
"",
1,
)
return frames_found_text
def is_sequence_dir(dir: Path) -> bool:
return dir.parent.name == "shots"
def is_shot_dir(dir: Path) -> bool:
return dir.parent.parent.name == "shots"
def get_shot_name_from_dir(dir: Path) -> str:
return dir.stem # 060_0010_A.lighting > 060_0010_A
def get_image_editor(context: bpy.types.Context) -> Optional[bpy.types.Area]:
image_editor = None
for area in context.screen.areas:
if area.type == "IMAGE_EDITOR":
image_editor = area
return image_editor
def get_sqe_editor(context: bpy.types.Context) -> Optional[bpy.types.Area]:
sqe_editor = None
for area in context.screen.areas:
if area.type == "SEQUENCE_EDITOR":
sqe_editor = area
return sqe_editor
def fit_frame_range_to_strips(
context: bpy.types.Context, strips: Optional[List[bpy.types.Strip]] = None
) -> Tuple[int, int]:
def get_sort_tuple(strip: bpy.types.Strip) -> Tuple[int, int]:
return (strip.frame_final_start, strip.frame_final_duration)
if not strips:
strips = context.scene.sequence_editor.sequences_all
if not strips:
return (0, 0)
strips = list(strips)
strips.sort(key=get_sort_tuple)
context.scene.frame_start = strips[0].frame_final_start
context.scene.frame_end = strips[-1].frame_final_end - 1
return (context.scene.frame_start, context.scene.frame_end)
def get_top_level_valid_strips_continious(
context: bpy.types.Context,
) -> List[bpy.types.Strip]:
sequences_tmp = get_valid_cs_sequences(
context, sequence_list=list(context.scene.sequence_editor.sequences_all)
)
sequences_tmp.sort(key=lambda s: (s.channel, s.frame_final_start), reverse=True)
sequences: List[bpy.types.Strip] = []
for strip in sequences_tmp:
occ_ranges = checksqe.get_occupied_ranges_for_strips(sequences)
s_range = range(strip.frame_final_start, strip.frame_final_end + 1)
if not checksqe.is_range_occupied(s_range, occ_ranges):
sequences.append(strip)
return sequences
def setup_color_management(context: bpy.types.Context) -> None:
if context.scene.view_settings.view_transform != 'Standard':
context.scene.view_settings.view_transform = 'Standard'
logger.info("Set view transform to: Standard")
def is_active_project() -> bool:
return bool(cache.project_active_get())
def link_strip_by_name(
context: bpy.types.Context,
strip: bpy.types.Strip,
shot_name: str,
sequence_name: str,
) -> None:
# Get seq and shot.
active_project = cache.project_active_get()
seq = active_project.get_sequence_by_name(sequence_name)
shot = active_project.get_shot_by_name(seq, shot_name)
if not shot:
logger.error("Unable to find shot %s on kitsu", shot_name)
return
sqe_opsdata.link_metadata_strip(context, shot, seq, strip)
# Log.
t = "Linked strip: %s to shot: %s with ID: %s" % (
strip.name,
shot.name,
shot.id,
)
logger.info(t)
util.redraw_ui()
@@ -1,86 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Set, Union, Optional, List, Dict, Any
from pathlib import Path
import bpy
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
class RR_isolate_collection_prop(bpy.types.PropertyGroup):
mute: bpy.props.BoolProperty()
class RR_property_group_scene(bpy.types.PropertyGroup):
""""""
render_dir: bpy.props.StringProperty(name="Render Directory", subtype="DIR_PATH")
isolate_view: bpy.props.CollectionProperty(type=RR_isolate_collection_prop)
@property
def render_dir_path(self):
if not self.is_render_dir_valid:
return None
return Path(bpy.path.abspath(self.render_dir)).absolute()
@property
def is_render_dir_valid(self) -> bool:
if not self.render_dir:
return False
if not bpy.data.filepath and self.render_dir.startswith("//"):
return False
return True
class RR_property_group_sequence(bpy.types.PropertyGroup):
"""
Property group that will be registered on sequence strips.
"""
is_render: bpy.props.BoolProperty(name="Is Render")
is_approved: bpy.props.BoolProperty(name="Is Approved")
is_pushed_to_edit: bpy.props.BoolProperty(name="Is Pushed To Edit")
frames_found_text: bpy.props.StringProperty(name="Frames Found")
shot_name: bpy.props.StringProperty(name="Shot")
# ----------------REGISTER--------------.
classes = [
RR_isolate_collection_prop,
RR_property_group_scene,
RR_property_group_sequence,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
# Scene Properties.
bpy.types.Scene.rr = bpy.props.PointerProperty(
name="Render Review",
type=RR_property_group_scene,
description="Metadata that is required for render_review",
)
# Strip Properties.
bpy.types.Strip.rr = bpy.props.PointerProperty(
name="Render Review",
type=RR_property_group_sequence,
description="Metadata that is required for render_review",
)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,166 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from typing import Set, Union, Optional, List, Dict, Any
import bpy
from .ops import (
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_push_to_edit,
)
from . import opsdata
from .. import prefs
class RR_PT_render_review(bpy.types.Panel):
""" """
bl_category = "Render Review"
bl_label = "Render Review"
bl_space_type = "SEQUENCE_EDITOR"
bl_region_type = "UI"
bl_order = 10
def draw(self, context: bpy.types.Context) -> None:
addon_prefs = prefs.addon_prefs_get(context)
# Create box.
layout = self.layout
box = layout.box()
# Label and setup workspace.
row = box.row(align=True)
row.label(text="Review", icon="CAMERA_DATA")
row.operator(RR_OT_setup_review_workspace.bl_idname, text="", icon="WINDOW")
# Render dir prop.
row = box.row(align=True)
row.prop(context.scene.rr, "render_dir")
# Create session.
render_dir = context.scene.rr.render_dir_path
text = f"Invalid Render Directory"
if render_dir:
if opsdata.is_sequence_dir(render_dir):
text = f"Review Sequence: {render_dir.name}"
elif opsdata.is_shot_dir(render_dir):
text = f"Review Shot: {render_dir.stem}"
row = box.row(align=True)
row.operator(RR_OT_sqe_create_review_session.bl_idname, text=text, icon="PLAY")
row = box.row(align = True)
row.prop(addon_prefs, 'match_latest_length')
row = box.row(align=True)
row.prop(addon_prefs, 'use_video')
if addon_prefs.use_video:
row.prop(addon_prefs, 'use_video_latest_only')
row = box.row(align = False)
row.prop(addon_prefs, 'shot_name_filter')
# Warning if kitsu on but not logged in.
if not prefs.session_auth(context):
row = box.split(align=True, factor=0.7)
row.label(text="Kitsu enabled but not logged in", icon="ERROR")
row.operator("kitsu.session_start", text="Login")
elif not opsdata.is_active_project():
row = box.row(align=True)
row.label(text="Kitsu enabled but no active project", icon="ERROR")
sqe = context.scene.sequence_editor
if not sqe:
return
active_strip = sqe.active_strip
if active_strip and active_strip.rr.is_render:
# Create box.
layout = self.layout
box = layout.box()
box.label(text=f"Render: {active_strip.rr.shot_name}", icon="RESTRICT_RENDER_OFF")
box.separator()
# Render dir name label and open file op.
row = box.row(align=True)
directory = opsdata.get_strip_folder(active_strip)
row.label(text=f"Folder: {directory.name}")
row.operator(
RR_OT_open_path.bl_idname, icon="FILEBROWSER", text="", emboss=False
).filepath = bpy.path.abspath(directory.as_posix())
# Nr of frames.
box.row(align=True).label(text=f"Frames: {active_strip.rr.frames_found_text}")
# Inspect exr.
text = "Inspect EXR"
icon = "VIEWZOOM"
if not opsdata.get_image_editor(context):
text = "Inspect EXR: Needs Image Editor"
icon = "ERROR"
row = box.row(align=True)
row.operator(RR_OT_sqe_inspect_exr_sequence.bl_idname, icon=icon, text=text)
row.operator(RR_OT_sqe_clear_exr_inspect.bl_idname, text="", icon="X")
# Approve render & udpate approved.
row = box.row(align=True)
text = "Push To Edit & Approve Render"
if active_strip.rr.is_pushed_to_edit:
text = "Approve Render"
row.operator(RR_OT_sqe_approve_render.bl_idname, icon="CHECKMARK", text=text)
row.operator(RR_OT_sqe_update_sequence_statuses.bl_idname, text="", icon="FILE_REFRESH")
# Push to edit.
if not addon_prefs.shot_playblast_root_dir:
shot_previews_dir = "" # ops handle invalid path
else:
shot_previews_dir = Path(opsdata.get_shot_previews_path(active_strip)).as_posix()
row = box.row(align=True)
row.operator(RR_OT_sqe_push_to_edit.bl_idname, icon="EXPORT")
row.operator(RR_OT_open_path.bl_idname, icon="FILEBROWSER", text="").filepath = (
shot_previews_dir
)
# Push strip to Kitsu.
box.row().operator('kitsu.sqe_push_shot', icon='URL')
def RR_topbar_file_new_draw_handler(self: Any, context: bpy.types.Context) -> None:
layout = self.layout
op = layout.operator(RR_OT_setup_review_workspace.bl_idname, text="Render Review")
# ----------------REGISTER--------------.
classes = [
RR_PT_render_review,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
# Append to topbar file new.
bpy.types.TOPBAR_MT_file_new.append(RR_topbar_file_new_draw_handler)
def unregister():
# Remove to topbar file new.
bpy.types.TOPBAR_MT_file_new.remove(RR_topbar_file_new_draw_handler)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,28 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from typing import Union, Dict, List, Any
import bpy
from . import vars
def redraw_ui() -> None:
"""
Forces blender to redraw the UI.
"""
for screen in bpy.data.screens:
for area in screen.areas:
area.tag_redraw()
def get_version(str_value: str, format: type = str) -> Union[str, int, None]:
match = re.search(vars.VERSION_PATTERN, str_value)
if match:
version = match.group()
if format == str:
return version
if format == int:
return int(version.replace("v", ""))
return None
@@ -1,8 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
RESOLUTION = (2048, 858)
VERSION_PATTERN = r"v\d\d\d"
FPS = 24
DELIMITER = "-"
Binary file not shown.
@@ -1,16 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from . import ops, ui
def register():
ops.register()
ui.register()
def unregister():
ui.unregister()
ops.unregister()
@@ -1,45 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .. import prefs, bkglobals
from ..types import Shot
from . import core, config
def get_shot_assets(
scene: bpy.types.Scene,
output_collection: bpy.types.Collection,
shot: Shot,
):
kitsu_assets = shot.get_all_assets()
for kitsu_asset in kitsu_assets:
asset_path = kitsu_asset.data.get(bkglobals.KITSU_FILEPATH_KEY)
collection_name = kitsu_asset.data.get(bkglobals.KITSU_COLLECTION_KEY)
if not asset_path or not collection_name:
print(
f"Asset '{kitsu_asset.name}' is missing filepath or collection metadata. Skipping"
)
continue
filepath = prefs.project_root_dir_get(bpy.context).joinpath(asset_path).absolute()
if not filepath.exists():
print(f"Asset '{kitsu_asset.name}' filepath '{str(filepath)}' does not exist. Skipping")
continue
if config.ASSET_TYPE_TO_OVERRIDE.get(collection_name.split('-')[0]):
linked_collection = core.link_and_override_collection(
collection_name=collection_name, file_path=str(filepath), scene=scene
)
core.add_action_to_armature(linked_collection, shot)
print(f"'{collection_name}': Successfully Linked & Overridden")
else:
linked_collection = core.link_data_block(
file_path=str(filepath),
data_block_name=collection_name,
data_block_type="Collection",
)
print(f"'{collection_name}': Successfully Linked")
output_collection.children.link(linked_collection)
@@ -1,121 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from pathlib import Path
from .. import bkglobals
from . import core
import json
OUTPUT_COL_CREATE = {
"anim": True,
"comp": False,
"fx": True,
"layout": True,
"lighting": False,
"previz": True,
"rendering": False,
"smear_to_mesh": False,
"storyboard": True,
}
OUTPUT_COL_LINK_MAPPING = {
"anim": None,
"comp": ['anim', 'fx'],
"fx": ['anim'],
"layout": None,
"lighting": ['anim'],
"previz": None,
"rendering": None,
"smear_to_mesh": None,
"storyboard": None,
}
LOAD_EDITORIAL_REF = {
"anim": True,
"comp": False,
"fx": False,
"layout": True,
"lighting": False,
"previz": False,
"rendering": False,
"smear_to_mesh": False,
"storyboard": False,
}
ASSET_TYPE_TO_OVERRIDE = {
"CH": True, # Character
"PR": True, # Rigged Prop
"LI": True, # Library/Environment Asset
"SE": False, # Set
"LG": True, # Lighting Rig
"CA": True, # Camera Rig
}
def settings_filepath_get() -> Path:
directory = core.get_shot_builder_config_dir(bpy.context)
json_file_path = directory.joinpath(bkglobals.BUILD_SETTINGS_FILENAME)
return json_file_path
def settings_dict_get(file_path_str: str = "") -> dict:
if file_path_str == "":
json_file_path = settings_filepath_get()
else:
json_file_path = Path(file_path_str)
if not json_file_path.exists():
return
return json.load(open(json_file_path))
def settings_load(json_file_path: str = ""):
global OUTPUT_COL_CREATE
global OUTPUT_COL_LINK_MAPPING
global LOAD_EDITORIAL_REF
global ASSET_TYPE_TO_OVERRIDE
json_content = settings_dict_get(json_file_path)
if not json_content:
return
try:
OUTPUT_COL_CREATE = json_content["OUTPUT_COL_CREATE"]
OUTPUT_COL_LINK_MAPPING = json_content["OUTPUT_COL_LINK_MAPPING"]
LOAD_EDITORIAL_REF = json_content["LOAD_EDITORIAL_REF"]
ASSET_TYPE_TO_OVERRIDE = json_content["ASSET_TYPE_TO_OVERRIDE"]
return True
except KeyError:
return
def filepath_get(filename: str = "") -> Path:
config_dir = core.get_shot_builder_config_dir(bpy.context)
if not config_dir.exists():
config_dir.mkdir(parents=True)
return config_dir.joinpath(filename)
def example_filepath_get(filename: str = "") -> Path:
config_dir = Path(__file__).parent
return config_dir.joinpath(f"config_examples/{filename}")
def copy_json_file(source_file: Path, target_file: Path) -> None:
# Read contents
with source_file.open() as source:
contents = source.read()
# Write contents to target file
with target_file.open('w') as target:
target.write(contents)
def template_example_dir_get() -> Path:
return Path(__file__).parent.joinpath(f"templates")
def template_dir_get() -> Path:
return core.get_shot_builder_config_dir(bpy.context).joinpath("templates")
@@ -1,53 +0,0 @@
import bpy
# The import paths here are relative to the "blender_kitsu/shot_builder/hooks.py" file.
# The example imports here imports the "blender_kitsu/shot_builder/hooks.py" and "blender_kistsu/types.py" file.
from .hooks import hook
from ..types import Shot, Asset
import logging
'''
Arguments to use in hooks
scene: bpy.types.Scene # current scene
shot: Shot class from blender_kitsu.types.py
prod_path: str # path to production root dir (your_project/svn/)
shot_path: str # path to shot file (your_project/svn/pro/shots/{sequence_name}/{shot_name}/{shot_task_name}.blend})
Notes
matching_task_type = ['anim', 'lighting', 'fx', 'comp'] # either use list or just one string
output_col_name = shot.get_output_collection_name(task_type_short_name="anim")
'''
logger = logging.getLogger(__name__)
# ---------- Global Hook ----------
@hook()
def set_eevee_render_engine(scene: bpy.types.Scene, **kwargs):
"""
By default, we set EEVEE as the renderer.
"""
scene.render.engine = 'BLENDER_EEVEE'
print("HOOK SET RENDER ENGINE")
# ---------- Overrides for animation files ----------
@hook(match_task_type='anim')
def test_args(
scene: bpy.types.Scene, shot: Shot, prod_path: str, shot_path: str, **kwargs
):
"""
Set output parameters for animation rendering
"""
print(f"Scene = {scene.name}")
print(f"Shot = {shot.name}")
print(f"Prod Path = {prod_path}")
print(f"Shot Path = {shot_path}")
@@ -1,50 +0,0 @@
{
"OUTPUT_COL_CREATE": {
"anim": true,
"comp": false,
"fx": true,
"layout": true,
"lighting": false,
"previz": true,
"rendering": false,
"smear_to_mesh": false,
"storyboard": true
},
"OUTPUT_COL_LINK_MAPPING": {
"anim": null,
"comp": [
"anim",
"fx"
],
"fx": [
"anim"
],
"layout": null,
"lighting": [
"anim"
],
"previz": null,
"rendering": null,
"smear_to_mesh": null,
"storyboard": null
},
"LOAD_EDITORIAL_REF": {
"anim": true,
"comp": false,
"fx": false,
"layout": true,
"lighting": false,
"previz": false,
"rendering": false,
"smear_to_mesh": false,
"storyboard": false
},
"ASSET_TYPE_TO_OVERRIDE": {
"CH": true,
"PR": true,
"LI": true,
"SE": false,
"LG": true,
"CA": true
}
}
@@ -1,219 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from pathlib import Path
from .. import bkglobals
from ..types import (
Sequence,
Shot,
TaskType,
)
from ..cache import Project
from . import config
from .. import prefs
from ..anim import opsdata as anim_opsdata
#################
# Constants
#################
CAMERA_NAME = 'CAM-camera'
def get_shot_builder_config_dir(context: bpy.types.Context) -> Path:
"""Returns directory Shot Builder Hooks are stored in
Args:
context (bpy.types.Context): Blender Context
Returns:
Path: Path object to Shot Builder Hooks Directory
"""
root_dir = prefs.project_root_dir_get(context)
return root_dir.joinpath("pro/config/shot_builder")
def get_file_dir(seq: Sequence, shot: Shot, task_type: TaskType) -> Path:
"""Returns Path to Directory for Current Shot, will ensure that
file path exists if it does not.
Args:
seq (Sequence): Sequence Class from blender_kitsu.types
shot (Shot): Shot Class from blender_kitsu.types
task_type TaskType Class from blender_kitsu.types
Returns:
Path: Returns Path for Shot Directory
"""
project_root_dir = prefs.project_root_dir_get(bpy.context)
all_shots_dir = project_root_dir.joinpath('pro').joinpath('shots')
shot_dir = all_shots_dir.joinpath(seq.name).joinpath(shot.name)
if not shot_dir.exists():
shot_dir.mkdir(parents=True)
return shot_dir
def set_render_engine(scene: bpy.types.Scene, engine='CYCLES'):
scene.render.engine = engine
def remove_all_data():
for lib in bpy.data.libraries:
bpy.data.libraries.remove(lib)
for col in bpy.data.collections:
bpy.data.collections.remove(col)
for obj in bpy.data.objects:
bpy.data.objects.remove(obj)
for action in bpy.data.actions:
bpy.data.actions.remove(action)
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
def set_shot_scene(context: bpy.types.Context, scene_name: str) -> bpy.types.Scene:
print(f"create scene with name {scene_name}")
for scene in bpy.data.scenes:
scene.name = 'REMOVE-' + scene.name
keep_scene = bpy.data.scenes.new(name=scene_name)
for scene in bpy.data.scenes:
if scene.name == scene_name:
continue
print(f"remove scene {scene.name}")
bpy.data.scenes.remove(scene)
context.window.scene = keep_scene
return keep_scene
def set_resolution_and_fps(project: Project, scene: bpy.types.Scene):
scene.render.fps = int(project.fps) # set fps
resolution = project.resolution.split('x')
scene.render.resolution_x = int(resolution[0])
scene.render.resolution_y = int(resolution[1])
scene.render.resolution_percentage = 100
def set_frame_range(shot: Shot, scene: bpy.types.Scene):
if not shot.nb_frames:
return
kitsu_start_3d = shot.get_3d_start()
scene.frame_start = kitsu_start_3d
scene.frame_end = kitsu_start_3d + shot.nb_frames - 1
scene.frame_current = kitsu_start_3d
def link_data_block(file_path: str, data_block_name: str, data_block_type: str):
bpy.ops.wm.link(
filepath=file_path,
directory=file_path + "/" + data_block_type,
filename=data_block_name,
instance_collections=False,
)
# TODO This doesn't return anything but collections
return bpy.data.collections.get(data_block_name)
def link_and_override_collection(
file_path: str, collection_name: str, scene: bpy.types.Scene
) -> bpy.types.Collection:
"""_summary_
Args:
file_path (str): File Path to .blend file to link from
collection_name (str): Name of collection to link from given filepath
scene (bpy.types.Scene): Current Scene to link collection to
Returns:
bpy.types.Collection: Overriden Collection linked to Scene Collection
"""
collection = link_data_block(file_path, collection_name, "Collection")
override_collection = collection.override_hierarchy_create(
scene, bpy.context.view_layer, do_fully_editable=True
)
scene.collection.children.unlink(collection)
# Make library override.
return override_collection
def link_camera_rig(
scene: bpy.types.Scene,
output_collection: bpy.types.Collection,
):
"""
Function to load the camera rig. The rig will be added to the output collection
of the shot and the camera will be set as active camera.
"""
# Load camera rig.
project_path = prefs.project_root_dir_get(bpy.context)
path = f"{project_path}/pro/assets/cam/camera_rig.blend"
if not Path(path).exists():
camera_data = bpy.data.cameras.new(name=CAMERA_NAME)
camera_object = bpy.data.objects.new(name=CAMERA_NAME, object_data=camera_data)
scene.collection.objects.link(camera_object)
output_collection.objects.link(camera_object)
return
collection_name = "CA-camera_rig" # TODO Rename the asset itself, this breaks convention
override_camera_col = link_and_override_collection(
file_path=path, collection_name=collection_name, scene=scene
)
output_collection.children.link(override_camera_col)
# Set the camera of the camera rig as active scene camera.
camera = override_camera_col.objects.get(CAMERA_NAME)
scene.camera = camera
def create_task_type_output_collection(
scene: bpy.types.Scene, shot: Shot, task_type: TaskType
) -> bpy.types.Collection:
collections = bpy.data.collections
output_col_name = shot.get_output_collection_name(task_type.get_short_name())
if not collections.get(output_col_name):
bpy.data.collections.new(name=output_col_name)
output_collection = collections.get(output_col_name)
output_collection.use_fake_user = True
if not scene.collection.children.get(output_col_name):
scene.collection.children.link(output_collection)
for view_layer in scene.view_layers:
view_layer_output_collection = view_layer.layer_collection.children.get(output_col_name)
view_layer_output_collection.exclude = True
return output_collection
def link_task_type_output_collections(shot: Shot, task_type: TaskType):
task_type_short_name = task_type.get_short_name()
if config.OUTPUT_COL_LINK_MAPPING.get(task_type_short_name) == None:
return
for short_name in config.OUTPUT_COL_LINK_MAPPING.get(task_type_short_name):
external_filepath = shot.get_filepath(bpy.context, short_name)
if not Path(external_filepath).exists():
print(f"Unable to link output collection for {Path(external_filepath).name}")
continue
file_path = external_filepath.__str__()
colection_name = shot.get_output_collection_name(short_name)
link_data_block(file_path, colection_name, 'Collection')
def add_action_to_armature(collection: bpy.types.Collection, shot_active: Shot):
for obj in collection.all_objects:
# Skip Armatures that are hidden from viewport because they aren't intended to be animated
if obj.type == 'ARMATURE' and not obj.hide_viewport:
if not obj.animation_data:
obj.animation_data_create()
name = anim_opsdata.gen_action_name(obj, collection, shot_active)
if obj.animation_data.action and obj.animation_data.action.name == name:
continue
obj.animation_data.action = bpy.data.actions.new(name=name)
@@ -1,22 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
import bpy
import functools
def save_shot_builder_file(file_path: str) -> bool:
"""Save Shot File within Folder of matching name.
Set Shot File to relative Paths."""
if Path(file_path).exists():
print(f"Shot Builder cannot overwrite existing file '{file_path}'")
return False
dir_path = Path(file_path).parent
dir_path.mkdir(parents=True, exist_ok=True)
bpy.app.timers.register(
functools.partial(bpy.ops.wm.save_mainfile, filepath=file_path, relative_remap=True),
first_interval=0.1,
)
return True
@@ -1,162 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import sys
import pathlib
from typing import *
import typing
import types
from collections.abc import Iterable
import importlib.util
from .. import prefs
import logging
from .core import get_shot_builder_config_dir
logger = logging.getLogger(__name__)
class Wildcard:
pass
class DoNotMatch:
pass
MatchCriteriaType = typing.Union[
str, typing.List[str], typing.Type[Wildcard], typing.Type[DoNotMatch]
]
"""
The MatchCriteriaType is a type definition for the parameters of the `hook` decorator.
The matching parameters can use multiple types to detect how the matching criteria
would work.
* `str`: would perform an exact string match.
* `typing.Iterator[str]`: would perform an exact string match with any of the given strings.
* `typing.Type[Wildcard]`: would match any type for this parameter. This would be used so a hook
is called for any value.
* `typing.Type[DoNotMatch]`: would ignore this hook when matching the hook parameter. This is the default
value for the matching criteria and would normally not be set directly in a
production configuration.
"""
MatchingRulesType = typing.Dict[str, MatchCriteriaType]
"""
Hooks are stored as `_shot_builder_rules' attribute on the function.
The MatchingRulesType is the type definition of the `_shot_builder_rules` attribute.
"""
HookFunction = typing.Callable[[typing.Any], None]
def _match_hook_parameter(
hook_criteria: MatchCriteriaType, match_query: typing.Optional[str]
) -> bool:
if hook_criteria == None:
return True
if hook_criteria == DoNotMatch:
return match_query is None
if hook_criteria == Wildcard:
return True
if isinstance(hook_criteria, str):
return match_query == hook_criteria
if isinstance(hook_criteria, list):
return match_query in hook_criteria
logger.error(f"Incorrect matching criteria {hook_criteria}, {match_query}")
return False
class Hooks:
def __init__(self):
self._hooks: typing.List[HookFunction] = []
def matches(
self,
hook: HookFunction,
match_task_type: typing.Optional[str] = None,
match_asset_type: typing.Optional[str] = None,
**kwargs: typing.Optional[str],
) -> bool:
assert not kwargs
rules = typing.cast(MatchingRulesType, getattr(hook, '_shot_builder_rules'))
return all(
(
_match_hook_parameter(rules['match_task_type'], match_task_type),
_match_hook_parameter(rules['match_asset_type'], match_asset_type),
)
)
def filter(self, **kwargs: typing.Optional[str]) -> typing.Iterator[HookFunction]:
for hook in self._hooks:
if self.matches(hook=hook, **kwargs):
yield hook
def execute_hooks(
self, match_task_type: str = None, match_asset_type: str = None, *args, **kwargs
) -> None:
for hook in self._hooks:
if self.matches(
hook, match_task_type=match_task_type, match_asset_type=match_asset_type
):
hook(*args, **kwargs)
def load_hooks(self, context):
shot_builder_config_dir = get_shot_builder_config_dir(context)
if not shot_builder_config_dir.exists():
print("Shot Builder Hooks directory does not exist. See add-on preferences")
return
hook_file_path = shot_builder_config_dir.resolve() / "hooks.py"
module_name = __package__ + ".production_hooks"
try:
spec = importlib.util.spec_from_file_location(module_name, hook_file_path)
hooks_module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = hooks_module
spec.loader.exec_module(hooks_module)
self.register_hooks(hooks_module)
except FileNotFoundError:
raise Exception("Production has no `hooks.py` configuration file")
return False
def register(self, func: HookFunction) -> None:
logger.info(f"registering hook '{func.__name__}'")
self._hooks.append(func)
def register_hooks(self, module: types.ModuleType) -> None:
"""
Register all hooks inside the given module.
"""
for module_item_str in dir(module):
module_item = getattr(module, module_item_str)
if not isinstance(module_item, types.FunctionType):
continue
if module_item.__module__ != module.__name__:
continue
if not hasattr(module_item, "_shot_builder_rules"):
continue
self.register(module_item)
def hook(
match_task_type: MatchCriteriaType = None,
match_asset_type: MatchCriteriaType = None,
) -> typing.Callable[[types.FunctionType], types.FunctionType]:
"""
Decorator to add custom logic when building a shot.
Hooks are used to extend the configuration that would be not part of the core logic of the shot builder tool.
"""
rules = {
'match_task_type': match_task_type,
'match_asset_type': match_asset_type,
}
def wrapper(func: types.FunctionType) -> types.FunctionType:
setattr(func, '_shot_builder_rules', rules)
return func
return wrapper
@@ -1,632 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
import shutil
from .. import bkglobals, prefs, cache
from pathlib import Path
from typing import List, Any, Tuple, Set, cast
from . import core, config
from ..context import core as context_core
from ..edit.core import edit_export_import_latest
from .file_save import save_shot_builder_file
from .template import replace_workspace_with_template
from .assets import get_shot_assets
from .hooks import Hooks
ACTIVE_PROJECT = None
def get_shots_for_seq(self: Any, context: bpy.types.Context) -> List[Tuple[str, str, str]]:
if self.seq_id != '':
seq = ACTIVE_PROJECT.get_sequence(self.seq_id)
shot_enum = cache.get_shots_enum_for_seq(self, context, seq)
if shot_enum != []:
return shot_enum
return [('NONE', "No Shots Found", '')]
def get_tasks_for_shot(self: Any, context: bpy.types.Context) -> List[Tuple[str, str, str]]:
global ACTIVE_PROJECT
if not (self.shot_id == '' or self.shot_id == 'NONE'):
shot = ACTIVE_PROJECT.get_shot(self.shot_id)
task_enum = cache.get_shot_task_types_enum_for_shot(self, context, shot)
if task_enum != []:
return task_enum
return [('NONE', "No Tasks Found", '')]
class KITSU_OT_build_config_base_class(bpy.types.Operator):
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
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 not addon_prefs.is_project_root_valid:
cls.poll_message_set(
"Check project root directory is configured in 'Blender Kitsu' addon preferences."
)
return False
return True
class KITSU_OT_build_config_save_hooks(KITSU_OT_build_config_base_class):
bl_idname = "kitsu.build_config_save_hooks"
bl_label = "Save Shot Builder Hook File"
bl_description = "Save hook.py file to `your_project_name/svn/pro/config/shot_builder` directory. Hooks are used to customize shot builder behaviour."
def execute(self, context: bpy.types.Context):
hooks_target_filepath = config.filepath_get(bkglobals.BUILD_HOOKS_FILENAME)
if hooks_target_filepath.exists():
self.report(
{'WARNING'},
f"{hooks_target_filepath.name} already exists, cannot overwrite",
)
return {'CANCELLED'}
hooks_example = config.example_filepath_get(bkglobals.BUILD_HOOKS_FILENAME)
if not hooks_example.exists():
self.report(
{'ERROR'},
f"Cannot find {hooks_target_filepath.name} example file",
)
return {'CANCELLED'}
config.copy_json_file(hooks_example, hooks_target_filepath)
self.report({'INFO'}, f"Hook File saved to {str(hooks_target_filepath)}")
return {'FINISHED'}
class KITSU_OT_build_config_save_settings(KITSU_OT_build_config_base_class):
bl_idname = "kitsu.build_config_save_settings"
bl_label = "Save Shot Builder Settings File"
bl_description = "Save settings.json file to `your_project_name/svn/pro/config/shot_builder` Config are used to customize shot builder behaviour."
def execute(self, context: bpy.types.Context):
settings_target_filepath = config.filepath_get(bkglobals.BUILD_SETTINGS_FILENAME)
if settings_target_filepath.exists():
self.report(
{'WARNING'},
f"{settings_target_filepath.name} already exists, cannot overwrite",
)
return {'CANCELLED'}
settings_example = config.example_filepath_get(bkglobals.BUILD_SETTINGS_FILENAME)
if not settings_example.exists():
self.report(
{'ERROR'},
"Cannot find example settings file",
)
return {'CANCELLED'}
config.copy_json_file(settings_example, settings_target_filepath)
self.report({'INFO'}, f"Settings File saved to {str(settings_target_filepath)}")
return {'FINISHED'}
class KITSU_OT_build_config_save_templates(KITSU_OT_build_config_base_class):
bl_idname = "kitsu.build_config_save_templates"
bl_label = "Create Shot Builder Template Files"
bl_description = (
"Save template files for shot builder in config directory."
"Templates are used to customize the workspaces for each per task type"
"Template names match each task type found on Kitsu Server"
)
def execute(self, context: bpy.types.Context):
source_dir = config.template_example_dir_get()
target_dir = config.template_dir_get()
# Ensure Target Directory Exists
target_dir.mkdir(parents=True, exist_ok=True)
source_files = list(source_dir.glob('*.blend')) + list(source_dir.glob('*.md'))
for source_file in source_files:
target_file = target_dir.joinpath(source_file.name)
if target_file.exists():
print(f"Cannot overwrite file {str(target_file)}")
continue
shutil.copy2(source_file, target_dir.joinpath(source_file.name))
self.report({'INFO'}, f"Saved template files to {str(target_dir)}")
return {'FINISHED'}
class KITSU_OT_build_new_file_baseclass(bpy.types.Operator):
bl_idname = "kitsu.build_new_file"
bl_label = "Build New File"
bl_description = "Build a new file based on the current context and save it"
bl_options = {"REGISTER"}
_kitsu_context_type = "" # Default context for this operator
_current_kitsu_context = ""
production_name: bpy.props.StringProperty( # type: ignore
name="Production",
description="Name of the production to create a shot file for",
options=set(),
)
save_file: bpy.props.BoolProperty( # type:ignore
name="Save after building.",
description="Automatically save build file after 'Shot Builder' is complete.",
default=True,
)
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
global ACTIVE_PROJECT
# Temporarily change kitsu context to asset
self._current_kitsu_context = context.scene.kitsu.category
context.scene.kitsu.category = self._kitsu_context_type
addon_prefs = prefs.addon_prefs_get(bpy.context)
project = cache.project_active_get()
ACTIVE_PROJECT = project
if addon_prefs.session.is_auth() is False:
self.report(
{'ERROR'},
"Must be logged into Kitsu to continue. \nCheck login status in 'Blender Kitsu' addon preferences.",
)
return {'CANCELLED'}
if project.id == "":
self.report(
{'ERROR'},
"Operator is not able to determine the Kitsu production's name. \nCheck project is selected in 'Blender Kitsu' addon preferences.",
)
return {'CANCELLED'}
if not addon_prefs.is_project_root_valid:
self.report(
{'ERROR'},
"Operator is not able to determine the project root directory. \nCheck project root directory is configured in 'Blender Kitsu' addon preferences.",
)
return {'CANCELLED'}
self.production_name = project.name
return cast(Set[str], context.window_manager.invoke_props_dialog(self, width=400))
def cancel(self, context: bpy.types.Context):
# Restore kitsu context if cancelled
context.scene.kitsu.category = self._current_kitsu_context
class KITSU_OT_build_new_asset(KITSU_OT_build_new_file_baseclass):
bl_idname = "kitsu.build_new_asset"
bl_label = "Build New Asset"
bl_description = "Build a New Asset file, based on project data from Kitsu Server"
bl_options = {"REGISTER"}
_kitsu_context_type = "ASSET" # Default context for this operator
def draw(self, context: bpy.types.Context) -> None:
global ACTIVE_PROJECT
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
flow = layout.grid_flow(
row_major=True, columns=0, even_columns=True, even_rows=False, align=False
)
col = flow.column()
row = col.row()
row.enabled = False
row.prop(self, "production_name")
context_core.draw_asset_type_selector(context, col)
context_core.draw_asset_selector(context, col)
col.prop(self, "save_file")
def execute(self, context: bpy.types.Context):
# Get Properties
scene = context.scene
asset_type = cache.asset_type_active_get()
asset = cache.asset_active_get()
if asset_type.id == "" or asset.id == "":
self.report({'ERROR'}, "Please select a asset type and asset to build a shot file")
return {'CANCELLED'}
asset_file_path_str = asset.get_filepath(context)
replace_workspace_with_template(context, "Asset")
# Remove All Collections from Scene
for collection in context.scene.collection.children:
context.scene.collection.children.unlink(collection)
bpy.data.collections.remove(collection)
# Remove All Objects from Scene
for object in context.scene.objects:
context.scene.objects.unlink(object)
bpy.data.objects.remove(object)
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
asset_collection = bpy.data.collections.new(asset.get_collection_name())
context.scene.collection.children.link(asset_collection)
# Set Kitsu Context
scene.kitsu.category = "ASSET"
scene.kitsu.asset_type_active_name = asset_type.name
scene.kitsu.asset_active_name = asset.name
scene.kitsu.asset_col = asset_collection
relative_path = Path(asset_file_path_str).relative_to(prefs.project_root_dir_get(context))
asset.set_asset_path(str(relative_path), asset_collection.name)
# Save File
if self.save_file:
if not save_shot_builder_file(file_path=asset_file_path_str):
self.report(
{"WARNING"},
f"Failed to save file at path `{asset_file_path_str}`",
)
return {"FINISHED"}
self.report({"INFO"}, f"Successfully Built Shot:`{asset.name}`")
return {"FINISHED"}
class KITSU_OT_open_asset_file(KITSU_OT_build_new_file_baseclass):
bl_idname = "kitsu.open_asset_file"
bl_label = "Open Asset File"
bl_description = "Open an Asset File from the current project"
_kitsu_context_type = "ASSET"
save_current: bpy.props.BoolProperty( # type: ignore
name="Save Current File",
description="Automatically save the current file before opening a new one.",
default=True,
)
def draw(self, context: bpy.types.Context) -> None:
global ACTIVE_PROJECT
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
flow = layout.grid_flow(
row_major=True, columns=0, even_columns=True, even_rows=False, align=False
)
col = flow.column()
row = col.row()
row.enabled = False
row.prop(self, "production_name")
context_core.draw_asset_type_selector(context, col)
context_core.draw_asset_selector(context, col)
if bpy.data.is_dirty:
col.prop(self, "save_current")
def execute(self, context: bpy.types.Context):
asset = cache.asset_active_get()
asset_file_path_str = asset.get_filepath(context)
if not Path(asset_file_path_str).exists():
self.report({'ERROR'}, f"Asset file does not exist: {asset_file_path_str}")
return {'CANCELLED'}
if bpy.data.is_dirty and self.save_current:
bpy.ops.wm.save_mainfile()
bpy.ops.wm.open_mainfile(filepath=asset_file_path_str)
return {'FINISHED'}
class KITSU_OT_build_new_shot(KITSU_OT_build_new_file_baseclass):
bl_idname = "kitsu.build_new_shot"
bl_label = "Build New Shot"
bl_description = "Build a New Shot file, based on project information found on Kitsu Server"
bl_options = {"REGISTER"}
_kitsu_context_type = "SHOT"
def draw(self, context: bpy.types.Context) -> None:
global ACTIVE_PROJECT
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
flow = layout.grid_flow(
row_major=True, columns=0, even_columns=True, even_rows=False, align=False
)
col = flow.column()
row = col.row()
row.enabled = False
row.prop(self, "production_name")
if ACTIVE_PROJECT.production_type == bkglobals.KITSU_TV_PROJECT:
context_core.draw_episode_selector(context, col)
context_core.draw_sequence_selector(context, col)
context_core.draw_shot_selector(context, col)
context_core.draw_task_type_selector(context, col)
col.prop(self, "save_file")
def _get_task_type_for_shot(self, context, shot):
for task_type in shot.get_all_task_types():
if task_type.id == self.task_type:
return task_type
def _frame_range_invalid(self, context, shot) -> bool:
if not (getattr(shot, "data") or getattr(shot, "nb_frames")):
return True
try:
shot.data.get("frame_in")
except AttributeError:
return True
def execute(self, context: bpy.types.Context):
# Get Properties
active_project = cache.project_active_get()
seq = cache.sequence_active_get()
shot = cache.shot_active_get()
task_type = cache.task_type_active_get()
config.settings_load()
if seq.id == "" or shot.id == "" or task_type.id == "":
self.report(
{'ERROR'}, "Please select a sequence, shot and task type to build a shot file"
)
return {'CANCELLED'}
if self._frame_range_invalid(context, shot):
self.report(
{'WARNING'}, F"Shot {shot.name} is missing frame range data on Kitsu Server"
)
task_type_short_name = task_type.get_short_name()
shot_file_path_str = shot.get_filepath(context, task_type_short_name)
# Open Template File
replace_workspace_with_template(context, task_type.name)
# Set Up Scene + Naming
shot_task_name = shot.get_task_name(task_type.get_short_name())
scene = core.set_shot_scene(context, shot_task_name)
core.remove_all_data()
core.set_resolution_and_fps(active_project, scene)
core.set_frame_range(shot, scene)
# Set Render Settings
if task_type_short_name == 'anim': # TODO get anim from a constant instead
core.set_render_engine(context.scene, 'BLENDER_WORKBENCH')
else:
core.set_render_engine(context.scene)
# Create Output Collection & Link Camera
if config.OUTPUT_COL_CREATE.get(task_type_short_name):
output_col = core.create_task_type_output_collection(context.scene, shot, task_type)
if task_type_short_name == 'anim' or task_type_short_name == 'layout':
core.link_camera_rig(context.scene, output_col)
# Load Assets
get_shot_assets(scene=scene, output_collection=output_col, shot=shot)
# Link External Output Collections
core.link_task_type_output_collections(shot, task_type)
if config.LOAD_EDITORIAL_REF.get(task_type_short_name):
edit_export_import_latest(context, shot)
# Run Hooks
hooks_instance = Hooks()
hooks_instance.load_hooks(context)
hooks_instance.execute_hooks(
match_task_type=task_type_short_name,
scene=context.scene,
shot=shot,
prod_path=prefs.project_root_dir_get(context),
shot_path=shot_file_path_str,
)
# Set Kitsu Context
scene.kitsu.category = "SHOT"
scene.kitsu.sequence_active_name = seq.name
scene.kitsu.shot_active_name = shot.name
scene.kitsu.task_type_active_name = task_type.name
# Save File
if self.save_file:
if not save_shot_builder_file(file_path=shot_file_path_str):
self.report(
{"WARNING"},
f"Failed to save file at path `{shot_file_path_str}`",
)
return {"FINISHED"}
self.report({"INFO"}, f"Successfully Built Shot:`{shot.name}` Task: `{task_type.name}`")
return {"FINISHED"}
class KITSU_OT_open_shot_file(KITSU_OT_build_new_file_baseclass):
bl_idname = "kitsu.open_shot_file"
bl_label = "Open Shot File"
bl_description = "Open a Shot File from the current project"
_kitsu_context_type = "SHOT"
save_current: bpy.props.BoolProperty( # type: ignore
name="Save Current File",
description="Automatically save the current file before opening a new one.",
default=True,
)
def draw(self, context: bpy.types.Context) -> None:
global ACTIVE_PROJECT
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
flow = layout.grid_flow(
row_major=True, columns=0, even_columns=True, even_rows=False, align=False
)
col = flow.column()
row = col.row()
row.enabled = False
row.prop(self, "production_name")
if ACTIVE_PROJECT.production_type == bkglobals.KITSU_TV_PROJECT:
context_core.draw_episode_selector(context, col)
context_core.draw_sequence_selector(context, col)
context_core.draw_shot_selector(context, col)
context_core.draw_task_type_selector(context, col)
if bpy.data.is_dirty:
col.prop(self, "save_current")
def execute(self, context: bpy.types.Context):
shot = cache.shot_active_get()
task_type = cache.task_type_active_get()
task_type_short_name = task_type.get_short_name()
shot_file_path_str = shot.get_filepath(context, task_type_short_name)
if not Path(shot_file_path_str).exists():
self.report({'ERROR'}, f"Shot file does not exist: {shot_file_path_str}")
return {'CANCELLED'}
if bpy.data.is_dirty and self.save_current:
bpy.ops.wm.save_mainfile()
bpy.ops.wm.open_mainfile(filepath=shot_file_path_str)
return {'FINISHED'}
class KITSU_OT_create_edit_file(KITSU_OT_build_new_file_baseclass):
bl_idname = "kitsu.create_edit_file"
bl_label = "Create Edit File"
bl_description = "Create a new .blend file for editing using Blender's Video Editing template"
_edit_entity = None
_production_name = None
_kitsu_context_type = "EDIT"
create_kitsu_edit: bpy.props.BoolProperty( # type: ignore
name="Create Kitsu Edit if none exists.",
description="Automatically create a Kitsu edit for the edit.",
default=True,
)
def draw(self, context: bpy.types.Context) -> None:
global ACTIVE_PROJECT
layout = self.layout
if ACTIVE_PROJECT.production_type == bkglobals.KITSU_TV_PROJECT:
context_core.draw_episode_selector(context, layout)
layout.prop(self, "create_kitsu_edit")
layout.prop(self, "save_file")
def execute(self, context):
scene = context.scene
active_project = cache.project_active_get()
self._edit_entity = cache.edit_default_get(
create=self.create_kitsu_edit, episode_id=context.scene.kitsu.episode_active_id
)
self._edit_entity.set_edit_task()
task_type = self._edit_entity.get_task_type()
if not self._edit_entity:
self.report({'ERROR'}, "Failed to create Kitsu edit entity.")
return {'CANCELLED'}
edit_file_path_str = self._edit_entity.get_filepath(context)
# Create a new file using the Video Editing template
replace_workspace_with_template(context, task_type.name)
core.set_resolution_and_fps(active_project, scene)
cache.reset_all_edits_enum_for_active_project()
scene.kitsu.category = "EDIT"
scene.kitsu.edit_active_name = context_core.get_versioned_file_basename(
Path(edit_file_path_str).stem
)
scene.kitsu.task_type_active_name = bkglobals.EDIT_TASK_TYPE
# Save File
if self.save_file:
if not save_shot_builder_file(file_path=edit_file_path_str):
self.report(
{"WARNING"},
f"Failed to save file at path `{edit_file_path_str}`",
)
return {"FINISHED"}
self.report({'INFO'}, f"Created edit file at {edit_file_path_str}")
return {'FINISHED'}
class KITSU_OT_open_edit_file(KITSU_OT_build_new_file_baseclass):
bl_idname = "kitsu.open_edit_file"
bl_label = "Open Edit File"
bl_description = "Open Latest Edit File from the current project"
_kitsu_context_type = "EDIT"
save_current: bpy.props.BoolProperty( # type: ignore
name="Save Current File",
description="Automatically save the current file before opening a new one.",
default=True,
)
def draw(self, context: bpy.types.Context) -> None:
global ACTIVE_PROJECT
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
flow = layout.grid_flow(
row_major=True, columns=0, even_columns=True, even_rows=False, align=False
)
col = flow.column()
row = col.row()
row.enabled = False
row.prop(self, "production_name")
if ACTIVE_PROJECT.production_type == bkglobals.KITSU_TV_PROJECT:
context_core.draw_episode_selector(context, col)
context_core.draw_edit_selector(context, col)
if bpy.data.is_dirty:
col.prop(self, "save_current")
def execute(self, context: bpy.types.Context):
edit_entity = cache.edit_default_get(episode_id=context.scene.kitsu.episode_active_id)
if not edit_entity:
self.report({'ERROR'}, "No edit task found on Kitsu Server.")
return {'CANCELLED'}
edit_file_path_str = edit_entity.get_filepath(context)
if not Path(edit_file_path_str).exists():
self.report({'ERROR'}, f"Edit file does not exist: {edit_file_path_str}")
return {'CANCELLED'}
if bpy.data.is_dirty and self.save_current:
bpy.ops.wm.save_mainfile()
bpy.ops.wm.open_mainfile(filepath=edit_file_path_str)
return {'FINISHED'}
classes = [
KITSU_OT_build_new_shot,
KITSU_OT_build_new_asset,
KITSU_OT_build_config_save_hooks,
KITSU_OT_build_config_save_settings,
KITSU_OT_build_config_save_templates,
KITSU_OT_create_edit_file,
KITSU_OT_open_edit_file,
KITSU_OT_open_shot_file,
KITSU_OT_open_asset_file,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,69 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from pathlib import Path
from . import config
def list_dir_blend_files(p: Path) -> list[Path]:
return list(p.glob('*.blend'))
def get_template_for_task_type(task_type_name: str) -> Path:
# Find Custom Template in Config Dir if available
for file in list_dir_blend_files(config.template_dir_get()):
if file.stem.lower() == task_type_name.lower():
return file
# Fall back to example templates if no custom templates found
for file in list_dir_blend_files(config.template_example_dir_get()):
if file.stem.lower() == task_type_name.lower():
return file
return
def replace_workspace_with_template(context: bpy.types.Context, task_type_name: str):
if task_type_name is None:
return
file_path = get_template_for_task_type(task_type_name)
remove_prefix = "REMOVE-"
if not file_path:
print(f"No template found for task type '{task_type_name}'")
return
if not file_path.exists():
return
# Mark Existing Workspaces for Removal
for workspace in bpy.data.workspaces:
if workspace.name.startswith(remove_prefix):
continue
workspace.name = remove_prefix + workspace.name
# Add EXEC_DEFAULT to all bpy,ops calls to ensure they are "blocking" calls
file_path_str = file_path.absolute().as_posix()
with bpy.data.libraries.load(file_path_str) as (data_from, data_to):
for workspace in data_from.workspaces:
bpy.ops.wm.append(
'EXEC_DEFAULT',
filepath=file_path_str,
directory=file_path_str + "/" + 'WorkSpace',
filename=str(workspace),
)
for lib in bpy.data.libraries:
if lib.filepath == file_path_str:
bpy.data.libraries.remove(bpy.data.libraries.get(lib.name))
break
workspaces_to_remove = []
for workspace in bpy.data.workspaces:
if workspace.name.startswith(remove_prefix):
workspaces_to_remove.append(workspace)
# context.window.workspace = workspace
for workspace in workspaces_to_remove:
with context.temp_override(workspace=workspace):
bpy.ops.workspace.delete('EXEC_DEFAULT')
return True
@@ -1,12 +0,0 @@
# Shot Builder Template Files
# Description
Template files are used to implement custom UI when building new shots. Templates are custom .blend files named after the corresponding task type on the Kitsu Server for a given project. Workspaces from the template files are loaded into newly built shot files.
## Creating a Template Files
1. Save a new .blend file in the directory `your_project_name/svn/pro/config/shot_builder/templates`
2. Name the .blend file after a task type in Kitsu (e.g. Animation, Layout, Compositing), name must match exactly the [task type](https://kitsu.cg-wire.com/customization-pipeline/#task-type) name on Kitsu Server.
3. Customize Workspaces to create custom UI for the task type, and save the file.
## Limitations
Template files are only used for custom UI, other data like scene settings, collections and objects will not be loaded.
@@ -1,63 +0,0 @@
# SPDX-FileCopyrightText: 2023 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from bpy.types import Header, Menu, Panel
from bpy.app.translations import (
pgettext_iface as iface_,
contexts as i18n_contexts,
)
from typing import Any
def topbar_file_new_draw_handler(self: Any, context: bpy.types.Context) -> None:
layout = self.layout
layout.separator()
layout.operator("kitsu.build_new_shot", text="Shot File")
layout.operator("kitsu.build_new_asset", text="Asset File")
layout.operator("kitsu.create_edit_file", text="Edit File")
def topbar_kitsu_menu_draw_handler(self: Any, context: bpy.types.Context) -> None:
layout = self.layout
layout.menu("KITSU_MT_project_topbar_menu")
class KITSU_MT_project_topbar_menu(Menu):
bl_label = "Project"
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
layout.operator("kitsu.build_new_shot", text="New Shot")
layout.operator("kitsu.build_new_asset", text="New Asset")
layout.operator("kitsu.create_edit_file", text="New Edit")
layout.separator()
layout.operator("kitsu.open_shot_file", text="Open Shot")
layout.operator("kitsu.open_asset_file", text="Open Asset")
layout.operator("kitsu.open_edit_file", text="Open Edit")
layout.separator()
layout.operator("kitsu.con_detect_context")
classes = [
KITSU_MT_project_topbar_menu,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.TOPBAR_MT_file_new.append(topbar_file_new_draw_handler)
bpy.types.TOPBAR_MT_editor_menus.append(topbar_kitsu_menu_draw_handler)
def unregister():
bpy.types.TOPBAR_MT_file_new.remove(topbar_file_new_draw_handler)
bpy.types.TOPBAR_MT_editor_menus.append(topbar_kitsu_menu_draw_handler)
for cls in classes:
bpy.utils.unregister_class(cls)
@@ -1,39 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from ..sqe import opsdata, checkstrip, pull, push, ops, ui, draw
# ---------REGISTER ----------.
def reload():
global opsdata
global checkstrip
global pull
global push
global ops
global ui
global draw
opsdata = importlib.reload(opsdata)
checkstrip = importlib.reload(checkstrip)
pull = importlib.reload(pull)
push = importlib.reload(push)
ops = importlib.reload(ops)
ui = importlib.reload(ui)
draw = importlib.reload(draw)
def register():
ops.register()
ui.register()
draw.register()
def unregister():
ui.unregister()
ops.unregister()
draw.unregister()
@@ -1,72 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Dict, List
import bpy
from ..logger import LoggerFactory
from ..sqe import checkstrip
logger = LoggerFactory.getLogger()
def _is_range_in(range1: range, range2: range) -> bool:
"""Whether range1 is a subset of range2"""
# usual strip setup strip1(101, 120)|strip2(120, 130)|strip3(130, 140)
# first and last frame can be the same for each strip
range2 = range(range2.start + 1, range2.stop - 1)
if not range1:
return True # empty range is subset of anything
if not range2:
return False # non-empty range can't be subset of empty range
if len(range1) > 1 and range1.step % range2.step:
return False # must have a single value or integer multiple step
return range1.start in range2 or range1[-1] in range2
def get_occupied_ranges(context: bpy.types.Context) -> Dict[str, List[range]]:
"""
Scans sequence editor and returns a dictionary. It contains a key for each channel
and a list of ranges with the occupied frame ranges as values.
"""
# {'1': [(101, 213), (300, 320)]}.
ranges: Dict[str, List[range]] = {}
# Populate ranges.
for strip in context.scene.sequence_editor.sequences_all:
ranges.setdefault(str(strip.channel), [])
ranges[str(strip.channel)].append(
range(strip.frame_final_start, strip.frame_final_end + 1)
)
# Sort ranges tuple list.
for channel in ranges:
liste = ranges[channel]
ranges[channel] = sorted(liste, key=lambda item: item.start)
return ranges
def is_range_occupied(range_to_check: range, occupied_ranges: List[range]) -> bool:
for r in occupied_ranges:
# Range(101, 150).
if _is_range_in(range_to_check, r):
return True
continue
return False
def get_shot_strips(context: bpy.types.Context) -> List[bpy.types.Strip]:
shot_strips = []
shot_strips.extend(
[
strip
for strip in context.scene.sequence_editor.sequences_all
if checkstrip.is_valid_type(strip, log=False)
and checkstrip.is_linked(strip, log=False)
]
)
return shot_strips
@@ -1,131 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Optional
import bpy
import gazu
from ..types import Sequence, Project, Shot, Cache
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
VALID_STRIP_TYPES = {"MOVIE", "COLOR"}
def is_valid_type(strip: bpy.types.Strip, log: bool = True) -> bool:
if not strip.type in VALID_STRIP_TYPES:
if log:
logger.info("Strip: %s. Invalid type", strip.type)
return False
return True
def is_initialized(strip: bpy.types.Strip) -> bool:
"""Returns True if strip.kitsu.initialized is True else False"""
if not strip.kitsu.initialized:
logger.info("Strip: %s. Not initialized", strip.name)
return False
logger.info("Strip: %s. Is initialized", strip.name)
return True
def is_linked(strip: bpy.types.Strip, log: bool = True) -> bool:
"""Returns True if strip.kitsu.linked is True else False"""
if not strip.kitsu.linked:
if log:
logger.info("Strip: %s. Not linked yet", strip.name)
return False
if log:
logger.info("Strip: %s. Is linked to ID: %s", strip.name, strip.kitsu.shot_id)
return True
def has_meta(strip: bpy.types.Strip) -> bool:
"""Returns True if strip.kitsu.shot_name and strip.kitsu.sequence_name is Truethy else False"""
seq = strip.kitsu.sequence_name
shot = strip.kitsu.shot_name or strip.kitsu.manual_shot_name
if not bool(seq and shot):
logger.info("Strip: %s. Missing metadata", strip.name)
return False
logger.info("Strip: %s. Has metadata (Sequence: %s, Shot: %s)", strip.name, seq, shot)
return True
def shot_exists_by_id(strip: bpy.types.Strip, clear_cache: bool = True) -> Optional[Shot]:
"""Returns Shot instance if shot with strip.kitsu.shot_id exists else None"""
if clear_cache:
Cache.clear_all()
try:
shot = Shot.by_id(strip.kitsu.shot_id)
except (gazu.exception.RouteNotFoundException, gazu.exception.ServerErrorException):
logger.info(
"Strip: %s No shot found on server with ID: %s",
strip.name,
strip.kitsu.shot_id,
)
return None
logger.info("Strip: %s Shot %s exists on server (ID: %s)", strip.name, shot.name, shot.id)
return shot
def seq_exists_by_id(
strip: bpy.types.Strip, project: Project, clear_cache: bool = True
) -> Optional[Sequence]:
if clear_cache:
Cache.clear_all()
zseq = project.get_sequence(strip.kitsu.sequence_id)
if not zseq:
logger.info(
"Strip: %s Sequence %s does not exist on server",
strip.name,
strip.kitsu.sequence_name,
)
return None
logger.info(
"Strip: %s Sequence %s exists in on server (ID: %s)",
strip.name,
zseq.name,
zseq.id,
)
return zseq
def shot_exists_by_name(
strip: bpy.types.Strip,
project: Project,
sequence: Sequence,
clear_cache: bool = True,
) -> Optional[Shot]:
"""Returns Shot instance if strip.kitsu.shot_name exists on server, else None"""
if clear_cache:
Cache.clear_all()
shot = project.get_shot_by_name(sequence, strip.kitsu.manual_shot_name)
if not shot:
logger.info(
"Strip: %s Shot %s does not exist on server",
strip.name,
strip.kitsu.manual_shot_name,
)
return None
logger.info("Strip: %s Shot already existent on server (ID: %s)", strip.name, shot.id)
return shot
def contains(strip: bpy.types.Strip, framenr: int) -> bool:
"""Returns True if the strip covers the given frame number"""
return int(strip.frame_final_start) <= framenr <= int(strip.frame_final_end)
-126
View File
@@ -1,126 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import typing
import bpy
import gpu
from gpu_extras.batch import batch_for_shader
# Shaders and batches
rect_coords = ((0, 0), (1, 0), (1, 1), (0, 1))
indices = ((0, 1, 2), (2, 3, 0))
# Setup shaders only if Blender runs in the foreground.
# If running in the background, no handles are registered, as drawing extra UI
# elements does not make sense.
# See register() and unregister().
if not bpy.app.background:
ucolor_2d_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
ucolor_2d_rect_batch = batch_for_shader(
ucolor_2d_shader, "TRIS", {"pos": rect_coords}, indices=indices
)
Float2 = typing.Tuple[float, float]
Float3 = typing.Tuple[float, float, float]
Float4 = typing.Tuple[float, float, float, float]
def draw_line(position: Float2, size: Float2, color: Float4):
with gpu.matrix.push_pop():
gpu.state.blend_set("ALPHA")
gpu.matrix.translate(position)
gpu.matrix.scale(size)
ucolor_2d_shader.uniform_float("color", color)
ucolor_2d_rect_batch.draw(ucolor_2d_shader)
gpu.state.blend_set("NONE")
def get_strip_rectf(strip) -> Float4:
# Get x and y in terms of the grid's frames and channels.
x1 = strip.frame_final_start
x2 = strip.frame_final_end
# Seems to be a 5 % offset from channel top start of strip.
y1 = strip.channel + 0.05
y2 = strip.channel - 0.05 + 1
return x1, y1, x2, y2
def draw_line_in_strip(strip_coords: Float4, height_factor: float, color: Float4):
# Unpack strip coordinates.
s_x1, channel, s_x2, _ = strip_coords
# Get the line's measures, as a percentage of channel height.
# Note that the strip's height is 10% smaller than the channel (centered 5% top and bottom).
# Note 2: offset the height slightly (0.005) to make room for the selection outline (channel coords).
line_height_in_channel = (0.9 - 0.005 * 2) * height_factor + 0.005
line_thickness = 0.04
# Offset the width slightly to make room for the selection outline (virtual grid horizontal coords).
width_offset = 0.2
width = (s_x2 - s_x1) - width_offset * 2
pos = (s_x1 + width_offset, channel + line_height_in_channel)
scale = (width, line_thickness)
draw_line(pos, scale, color)
def draw_callback_px():
context = bpy.context
sqe = context.scene.sequence_editor
if not sqe:
return
strips = sqe.sequences_all
for strip in strips:
# Get corners of the strip rectangle in terms of the grid's frames and channels (virtual, not px).
strip_coords = get_strip_rectf(strip)
if strip.kitsu.initialized or strip.kitsu.linked:
try:
color = tuple(
context.scene.kitsu.sequence_colors[strip.kitsu.sequence_id].color
)
except KeyError:
color = (1, 1, 1)
alpha = 0.75 if strip.kitsu.linked else 0.25
line_color = color + (alpha,)
draw_line_in_strip(strip_coords, 0.0, line_color)
if strip.kitsu.media_outdated:
line_color = (1.0, 0.05, 0.145, 0.75)
draw_line_in_strip(strip_coords, 0.9, line_color)
draw_handles = []
def register():
if bpy.app.background:
# Do not register anything if Blender runs in the background (no UI needed).
return
draw_handles.append(
bpy.types.SpaceSequenceEditor.draw_handler_add(
draw_callback_px, (), "WINDOW", "POST_VIEW"
)
)
def unregister():
if bpy.app.background:
return
for handle in reversed(draw_handles):
try:
bpy.types.SpaceSequenceEditor.draw_handler_remove(handle, "WINDOW")
except ValueError:
# Not sure why, but sometimes the handler seems to already be removed...??
pass
File diff suppressed because it is too large Load Diff
-265
View File
@@ -1,265 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from pathlib import Path
from typing import Any, Dict, List, Tuple, Union, Optional
from . import pull
import bpy
from .. import bkglobals, prefs
from ..logger import LoggerFactory
from ..types import Sequence, Task, TaskStatus, Shot, TaskType
logger = LoggerFactory.getLogger()
_sqe_shot_enum_list: List[Tuple[str, str, str]] = []
_sqe_not_linked: List[Tuple[str, str, str]] = []
_sqe_duplicates: List[Tuple[str, str, str]] = []
_sqe_multi_project: List[Tuple[str, str, str]] = []
def sqe_get_not_linked(self, context):
return _sqe_not_linked
def sqe_get_duplicates(self, context):
return _sqe_duplicates
def sqe_get_multi_project(self, context):
return _sqe_multi_project
def sqe_update_not_linked(context: bpy.types.Context) -> List[Tuple[str, str, str]]:
"""get all strips that are initialized but not linked yet"""
enum_list = []
if context.selected_sequences:
strips = context.selected_sequences
else:
strips = context.scene.sequence_editor.sequences_all
for strip in strips:
if strip.kitsu.initialized and not strip.kitsu.linked:
enum_list.append((strip.name, strip.name, ""))
return enum_list
def sqe_update_duplicates(context: bpy.types.Context) -> List[Tuple[str, str, str]]:
"""get all strips that are initialized but not linked yet"""
enum_list = []
data_dict = {}
if context.selected_sequences:
strips = context.selected_sequences
else:
strips = context.scene.sequence_editor.sequences_all
# Create data dict that holds all shots ids and the corresponding strips that are linked to it.
for i in range(len(strips)):
if strips[i].kitsu.linked:
# Get shot_id, shot_name, create entry in data_dict if id not existent.
shot_id = strips[i].kitsu.shot_id
shot_name = strips[i].kitsu.shot_name
if shot_id not in data_dict:
data_dict[shot_id] = {"name": shot_name, "strips": []}
# Append i to strips list.
if strips[i] not in set(data_dict[shot_id]["strips"]):
data_dict[shot_id]["strips"].append(strips[i])
# Comparet to all other strip.
for j in range(i + 1, len(strips)):
if shot_id == strips[j].kitsu.shot_id:
data_dict[shot_id]["strips"].append(strips[j])
# Convert in data strucutre for enum property.
for shot_id, data in data_dict.items():
if len(data["strips"]) > 1:
enum_list.append(("", data["name"], shot_id))
for strip in data["strips"]:
enum_list.append((strip.name, strip.name, ""))
return enum_list
def sqe_update_multi_project(context: bpy.types.Context) -> List[Tuple[str, str, str]]:
"""get all strips that are initialized but not linked yet"""
enum_list: List[Tuple[str, str, str]] = []
data_dict: Dict[str, Any] = {}
if context.selected_sequences:
strips = context.selected_sequences
else:
strips = context.scene.sequence_editor.sequences_all
# Create data dict that holds project names as key and values the corresponding sequence strips.
for strip in strips:
if strip.kitsu.linked:
project = strip.kitsu.project_name
if project not in data_dict:
data_dict[project] = []
# Append i to strips list.
if strip not in set(data_dict[project]):
data_dict[project].append(strip)
# Convert in data strucutre for enum property.
for project, strips in data_dict.items():
enum_list.append(("", project, ""))
for strip in strips:
enum_list.append((strip.name, strip.name, ""))
return enum_list
def resolve_pattern(pattern: str, var_lookup_table: Dict[str, str]) -> str:
matches = re.findall(r"\<(\w+)\>", pattern)
matches = list(set(matches))
# If no variable detected just return value.
if len(matches) == 0:
return pattern
else:
result = pattern
for to_replace in matches:
if to_replace in var_lookup_table:
to_insert = var_lookup_table[to_replace]
result = result.replace("<{}>".format(to_replace), to_insert)
else:
logger.warning(
"Failed to resolve variable: %s not defined!", to_replace
)
return ""
return result
def get_shots_enum_for_link_shot_op(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _sqe_shot_enum_list
if not self.sequence_enum:
return []
zseq_active = Sequence.by_id(self.sequence_enum)
_sqe_shot_enum_list.clear()
_sqe_shot_enum_list.extend(
[(s.id, s.name, s.description or "") for s in zseq_active.get_all_shots()]
)
return _sqe_shot_enum_list
def upload_preview(
context: bpy.types.Context, filepath: Path, task_type: TaskType, comment: str = ""
) -> None:
# Get shot by id which is in filename of thumbnail.
shot_id = filepath.name.split(bkglobals.SPACE_REPLACER)[0]
shot = Shot.by_id(shot_id)
# Find task from task type for that shot, ca be None of no task was added for that task type.
task = Task.by_name(shot, task_type)
if not task:
# Turns out a entity on the server can have 0 tasks even tough task types exist
# you have to create a task first before being able to upload a thumbnail.
task_status = TaskStatus.by_short_name("wip")
task = Task.new_task(shot, task_type, task_status=task_status)
else:
task_status = TaskStatus.by_id(task.task_status_id)
# Create a comment, e.G 'Update thumbnail'.
comment_obj = task.add_comment(task_status, comment=comment)
# Add_preview_to_comment.
task.add_preview_to_comment(comment_obj, filepath.as_posix())
logger.info(f"Uploaded preview for shot: {shot.name} under: {task_type.name}")
def init_start_frame_offset(strip: bpy.types.Strip) -> None:
# Frame start offset.
offset_start = strip.frame_final_start - strip.frame_start
# Cast offset start to int, since after Blender 3.3 strip values are floats
strip.kitsu.frame_start_offset = int(offset_start)
def append_sequence_color(
context: bpy.types.Context, seq: Sequence
) -> Optional[Tuple[str, str, str]]:
"""
Extend scene.kitsu.sequence_colors property with seq.data['color'] value if it exists.
"""
# Pull sequencee color property.
if not seq.data:
logger.info("%s failed to load sequence color. Missing 'data' key")
return None
if not "color" in seq.data:
logger.info("%s failed to load sequence color. Missing data['color'] key")
return None
try:
item = context.scene.kitsu.sequence_colors[seq.id]
except:
item = context.scene.kitsu.sequence_colors.add()
item.name = seq.id
logger.info(
"Added %s to scene.kitsu.seqeuence_colors",
seq.name,
)
finally:
item.color = tuple(seq.data["color"])
return tuple(seq.data["color"])
def push_sequence_color(context: bpy.types.Context, sequence: Sequence) -> None:
# Updates sequence color and logs.
try:
item = context.scene.kitsu.sequence_colors[sequence.id]
except KeyError:
logger.info(
"%s failed to push sequence color. Does not exists in 'context.scene.kitsu.sequence_colors'",
sequence.name,
)
else:
sequence.update_data({"color": list(item.color)})
logger.info("%s pushed sequence color", sequence.name)
def create_metadata_strip(
scene: bpy.types.Scene, name: str, channel, frame_start: int, frame_end: int
) -> bpy.types.MovieStrip:
addon_prefs = prefs.addon_prefs_get(bpy.context)
strip = scene.sequence_editor.sequences.new_movie(
name,
addon_prefs.metadatastrip_file,
channel,
frame_start,
)
strip.frame_final_end = frame_end
# Set blend alpha.
strip.blend_alpha = 0
init_start_frame_offset(strip)
return strip
def link_metadata_strip(
context, shot: Shot, seq: Sequence, strip: bpy.types.MovieStrip
) -> bpy.types.MovieStrip:
# Pull shot meta.
pull.shot_meta(strip, shot)
# Rename strip.
strip.name = shot.name
# Pull sequence color.
append_sequence_color(context, seq)
return strip
-42
View File
@@ -1,42 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .. import bkglobals
from ..types import Cache, Sequence, Project, Shot
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
def shot_meta(strip: bpy.types.Strip, shot: Shot, clear_cache: bool = True) -> None:
if clear_cache:
# Clear cache before pulling.
Cache.clear_all()
# Update sequence props.
seq = Sequence.by_id(shot.parent_id)
strip.kitsu.sequence_id = seq.id
strip.kitsu.sequence_name = seq.name
# Update shot props.
strip.kitsu.shot_id = shot.id
strip.kitsu.shot_name = shot.name
strip.kitsu.shot_description = shot.description if shot.description else ""
# Update project props.
project = Project.by_id(shot.project_id)
strip.kitsu.project_id = project.id
strip.kitsu.project_name = project.name
# Update meta props.
strip.kitsu.initialized = True
strip.kitsu.linked = True
# Update strip name.
strip.name = shot.name
# Log.
logger.info("Pulled meta from shot: %s to strip: %s", shot.name, strip.name)
-94
View File
@@ -1,94 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Tuple
import bpy
from .. import bkglobals
from ..types import Sequence, Project, Shot
from ..logger import LoggerFactory
import gazu
logger = LoggerFactory.getLogger()
def shot_meta(strip: bpy.types.Strip, shot: Shot) -> None:
# Update shot info.
shot.name = strip.kitsu.shot_name
shot.description = strip.kitsu.shot_description
shot.data["frame_in"] = strip.frame_final_start
shot.data["frame_out"] = strip.frame_final_end
shot.data["3d_start"] = strip.kitsu_3d_start
shot.nb_frames = strip.frame_final_duration
shot.data["fps"] = bkglobals.FPS
# If user changed the sequence the shot belongs to
# (can only be done by operator not by hand).
if strip.kitsu.sequence_id != shot.sequence_id:
sequence = Sequence.by_id(strip.kitsu.sequence_id)
shot.sequence_id = sequence.id
shot.parent_id = sequence.id
shot.sequence_name = sequence.name
# Update on server.
shot.update()
logger.info("Pushed meta to shot: %s from strip: %s", shot.name, strip.name)
def new_shot(
strip: bpy.types.Strip, sequence: Sequence, project: Project, add_tasks=False
) -> Shot:
frame_range = (strip.frame_final_start, strip.frame_final_end)
shot = project.create_shot(
sequence,
strip.kitsu.shot_name,
nb_frames=strip.frame_final_duration,
frame_in=frame_range[0],
frame_out=frame_range[1],
data={"fps": bkglobals.FPS, "3d_start": bkglobals.FRAME_START},
)
if add_tasks:
create_intial_tasks(shot, project)
# Update description, no option to pass that on create.
if strip.kitsu.shot_description:
shot.description = strip.kitsu.shot_description
shot.update()
# Set project name locally, will be available on next pull.
shot.project_name = project.name
logger.info("Pushed create shot: %s for project: %s", shot.name, project.name)
return shot
def new_sequence(strip: bpy.types.Strip, project: Project) -> Sequence:
sequence = project.create_sequence(
strip.kitsu.sequence_name,
strip.kitsu.episode_id
)
logger.info(
"Pushed create sequence: %s for project: %s", sequence.name, project.name
)
return sequence
def delete_shot(strip: bpy.types.Strip, shot: Shot) -> str:
result = shot.remove()
logger.info(
"Pushed delete shot: %s for project: %s",
shot.name,
shot.project_name or "Unknown",
)
strip.kitsu.clear()
return result
def create_intial_tasks(shot: Shot, project: Project):
shot_entity = gazu.shot.get_shot(shot.id)
for task_type in gazu.task.all_task_types_for_project(project.id):
if task_type["for_entity"] == "Shot":
gazu.task.new_task(shot_entity, task_type)
-770
View File
@@ -1,770 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from .. import cache, prefs, ui, bkglobals
from ..sqe import checkstrip
from ..context import core as context_core
from ..logger import LoggerFactory
from ..sqe.ops import (
KITSU_OT_sqe_push_new_sequence,
KITSU_OT_sqe_push_new_shot,
KITSU_OT_sqe_push_shot_meta,
KITSU_OT_sqe_uninit_strip,
KITSU_OT_sqe_unlink_shot,
KITSU_OT_sqe_init_strip,
KITSU_OT_sqe_link_shot,
KITSU_OT_sqe_link_sequence,
KITSU_OT_sqe_set_thumbnail_task_type,
KITSU_OT_sqe_set_sqe_render_task_type,
KITSU_OT_sqe_push_render_still,
KITSU_OT_sqe_push_render,
KITSU_OT_sqe_push_del_shot,
KITSU_OT_sqe_pull_shot_meta,
KITSU_OT_sqe_multi_edit_strip,
KITSU_OT_sqe_debug_duplicates,
KITSU_OT_sqe_debug_not_linked,
KITSU_OT_sqe_debug_multi_project,
KITSU_OT_sqe_pull_edit,
KITSU_OT_sqe_init_strip_start_frame,
KITSU_OT_sqe_create_metadata_strip,
KITSU_OT_sqe_fix_metadata_strips,
KITSU_OT_sqe_add_sequence_color,
KITSU_OT_sqe_scan_for_media_updates,
KITSU_OT_sqe_change_strip_source,
KITSU_OT_sqe_clear_update_indicators,
KITSU_OT_sqe_import_image_sequence,
KITSU_OT_sqe_import_playblast,
)
from pathlib import Path
logger = LoggerFactory.getLogger()
def get_selshots_noun(nr_of_shots: int, prefix: str = "Active") -> str:
if not nr_of_shots:
noun = "All"
elif nr_of_shots == 1:
noun = f"{prefix} Shot"
else:
noun = "%i Shots" % nr_of_shots
return noun
class KITSU_MT_sqe_advanced_delete(bpy.types.Menu):
bl_label = "Advanced Delete"
def draw(self, context: bpy.types.Context) -> None:
selshots = context.selected_sequences
strips_to_unlink = [s for s in selshots if s.kitsu.linked]
layout = self.layout
layout.operator(
KITSU_OT_sqe_push_del_shot.bl_idname,
text=f"Unlink and Delete {len(strips_to_unlink)} Shots",
icon="CANCEL",
)
class KITSU_PT_sqe_shot_tools(bpy.types.Panel):
"""
Panel in sequence editor that shows all kinds of tools related to Kitsu and sequence strips
"""
# TODO: Because each draw function was previously a seperate Panel there might be a lot of
# code duplication now, needs to be refactored at some point
bl_category = "Kitsu"
bl_label = "Shot Tools"
bl_space_type = "SEQUENCE_EDITOR"
bl_region_type = "UI"
bl_order = 20
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not context_core.is_edit_context():
return False
sqe = context.scene.sequence_editor
return bool(prefs.session_auth(context) or (sqe and sqe.sequences_all))
def draw(self, context: bpy.types.Context) -> None:
active_project = cache.project_active_get()
if active_project.production_type == bkglobals.KITSU_TV_PROJECT:
if not cache.episode_active_get():
self.layout.label(text="Please Set Active Episode", icon="ERROR")
return
if self.poll_error(context):
self.draw_error(context)
if self.poll_setup(context):
self.draw_setup(context)
if self.poll_metadata(context):
self.draw_metadata(context)
if self.poll_offline_metadata(context):
self.draw_offline_metadata(context)
if self.poll_multi_edit(context):
self.draw_multi_edit(context)
if self.poll_push(context):
self.draw_push(context)
if self.poll_pull(context):
self.draw_pull(context)
self.draw_media(context)
if self.poll_debug(context):
self.draw_debug(context)
@classmethod
def poll_error(cls, context: bpy.types.Context) -> bool:
project_active = cache.project_active_get()
addon_prefs = prefs.addon_prefs_get(context)
if not prefs.session_auth(context):
return False
return bool(not project_active or not addon_prefs.is_project_root_valid)
def draw_error(self, context: bpy.types.Context) -> None:
layout = self.layout
project_active = cache.project_active_get()
box = ui.draw_error_box(layout)
addon_prefs = prefs.addon_prefs_get(context)
if not project_active:
ui.draw_error_active_project_unset(box)
if not addon_prefs.is_project_root_valid:
ui.draw_error_invalid_project_root_dir(box)
@classmethod
def poll_setup(cls, context: bpy.types.Context) -> bool:
return bool(context.selected_sequences)
def draw_setup(self, context: bpy.types.Context) -> None:
"""
Panel in SQE that shows operators to setup shots. That includes initialization,
uninitizialization, linking and unlinking.
"""
strip = context.scene.sequence_editor.active_strip
selshots = context.selected_sequences
nr_of_shots = len(selshots)
noun = get_selshots_noun(nr_of_shots)
project_active = cache.project_active_get()
strips_to_init = []
strips_to_uninit = []
strips_to_unlink = []
for s in selshots:
if s.type not in checkstrip.VALID_STRIP_TYPES:
continue
if not s.kitsu.initialized:
strips_to_init.append(s)
elif s.kitsu.linked:
strips_to_unlink.append(s)
elif s.kitsu.initialized:
strips_to_uninit.append(s)
# Create box.
layout = self.layout
box = layout.box()
box.label(text="Setup Shots", icon="TOOL_SETTINGS")
# Production.
if prefs.session_auth(context):
box.row().label(text=f"Production: {project_active.name}")
# Single Selection.
if nr_of_shots == 1:
row = box.row(align=True)
# Initialize.
if strip.type not in checkstrip.VALID_STRIP_TYPES:
row.label(text=f"Only sequence strips of types: {checkstrip.VALID_STRIP_TYPES }")
return
if not strip.kitsu.initialized:
# Init active.
row.operator(KITSU_OT_sqe_init_strip.bl_idname, text=f"Init {noun}", icon="ADD")
# Link active.
row.operator(
KITSU_OT_sqe_link_shot.bl_idname,
text=f"Link {noun}",
icon="LINKED",
)
# Create metadata strip from uninitialized strip.
row = box.row(align=True)
row.operator(
KITSU_OT_sqe_create_metadata_strip.bl_idname,
text=f"Create Metadata Strip from {noun}",
)
# Unlink.
elif strip.kitsu.linked:
row = box.row(align=True)
row.operator(
KITSU_OT_sqe_unlink_shot.bl_idname,
text=f"Unlink {noun}",
icon="UNLINKED",
)
row.menu("KITSU_MT_sqe_advanced_delete", icon="DOWNARROW_HLT", text="")
# Uninitialize.
else:
row = box.row(align=True)
# Unlink active.
row.operator(
KITSU_OT_sqe_uninit_strip.bl_idname,
text=f"Uninitialize {noun}",
icon="REMOVE",
)
# Multiple Selection.
elif nr_of_shots > 1:
row = box.row(align=True)
# Init.
if strips_to_init:
row.operator(
KITSU_OT_sqe_init_strip.bl_idname,
text=f"Init {len(strips_to_init)} Shots",
icon="ADD",
)
row = box.row(align=True)
row.operator(
KITSU_OT_sqe_create_metadata_strip.bl_idname,
text=f"Create {len(strips_to_init)} Metadata Strips",
)
# Make row.
if strips_to_uninit or strips_to_unlink:
row = box.row(align=True)
# Uninitialize.
if strips_to_uninit:
row.operator(
KITSU_OT_sqe_uninit_strip.bl_idname,
text=f"Uninitialize {len(strips_to_uninit)} Shots",
icon="REMOVE",
)
# Unlink all.
if strips_to_unlink:
row.operator(
KITSU_OT_sqe_unlink_shot.bl_idname,
text=f"Unlink {len(strips_to_unlink)} Shots",
icon="UNLINKED",
)
row.menu("KITSU_MT_sqe_advanced_delete", icon="DOWNARROW_HLT", text="")
@classmethod
def poll_metadata(cls, context: bpy.types.Context) -> bool:
nr_of_shots = len(context.selected_sequences)
strip = context.scene.sequence_editor.active_strip
if nr_of_shots == 1:
return strip.kitsu.initialized
return False
def draw_metadata(self, context: bpy.types.Context) -> None:
"""
Panel in sequence editor that shows .kitsu properties of active strip. (shot, sequence)
"""
split_factor = 0.2
strip = context.scene.sequence_editor.active_strip
# Create box.
layout = self.layout
box = layout.box()
box.label(text="Metadata", icon="ALIGN_LEFT")
col = box.column(align=True)
# Sequence.
split = col.split(factor=split_factor, align=True)
split.label(text="Sequence")
if not strip.kitsu.sequence_id:
sub_row = split.row(align=True)
sub_row.prop(strip.kitsu, "sequence_name", text="")
sub_row.operator(KITSU_OT_sqe_push_new_sequence.bl_idname, text="", icon="ADD")
else:
# Lots of splitting because color prop is too big by default
sub_split = split.split(factor=0.8, align=True)
sub_split.prop(strip.kitsu, "sequence_name", text="") # TODO Use new dropdown here too
sub_sub_split = sub_split.split(factor=0.4, align=True)
sub_sub_split.operator(KITSU_OT_sqe_push_new_sequence.bl_idname, text="", icon="ADD")
try:
sequence_color_item = context.scene.kitsu.sequence_colors[strip.kitsu.sequence_id]
except KeyError:
sub_sub_split.operator(
KITSU_OT_sqe_add_sequence_color.bl_idname, text="", icon="COLOR"
)
else:
sub_sub_split.prop(sequence_color_item, "color", text="")
# Shot.
split = col.split(factor=split_factor, align=True)
split.label(text="Shot")
if not strip.kitsu.shot_id:
placeholder = strip.kitsu.shot_name or ''
split.prop(strip.kitsu, "manual_shot_name", text="", placeholder=placeholder)
else:
split.prop(strip.kitsu, "shot_name", text="")
# Description.
split = col.split(factor=split_factor, align=True)
split.label(text="Description")
split.prop(strip.kitsu, "shot_description_display", text="")
split.enabled = False if not strip.kitsu.initialized else True
# Frame range.
split = col.split(factor=split_factor)
split.label(text="Frame Range")
row = split.row(align=False)
row.prop(strip, "kitsu_3d_start", text="In")
row.prop(strip, "kitsu_frame_end", text="Out")
row.prop(strip, "kitsu_frame_duration", text="Duration")
row.operator(KITSU_OT_sqe_init_strip_start_frame.bl_idname, text="", icon="FILE_REFRESH")
"""
split = col.split(factor=split_factor)
split.label(text="Offsets")
row = split.row(align=False)
row.prop(strip.kitsu, "frame_start_offset", text="In")
"""
def poll_offline_metadata(cls, context: bpy.types.Context) -> bool:
offline_metadata_strips = [
strip
for strip in context.scene.sequence_editor.sequences
if strip.kitsu.shot_id != '' and not Path(strip.filepath).is_file()
]
return len(offline_metadata_strips) > 0
def draw_offline_metadata(self, context: bpy.types.Context) -> None:
# Create box.
layout = self.layout
box = layout.box()
box.label(text="Fix Metadata Strips", icon="ERROR")
offline_metadata_strips = [
strip
for strip in context.selected_sequences
if strip.kitsu.shot_id != '' and not Path(strip.filepath).is_file()
]
if len(offline_metadata_strips) == 0:
text = "Fix All Missing Media"
else:
text = f"Fix {len(offline_metadata_strips)} Missing Media"
box.operator(KITSU_OT_sqe_fix_metadata_strips.bl_idname, text=text)
@classmethod
def poll_multi_edit(cls, context: bpy.types.Context) -> bool:
if not prefs.session_auth(context):
return False
sel_shots = context.selected_sequences
nr_of_shots = len(sel_shots)
unvalid = [s for s in sel_shots if s.kitsu.linked or not s.kitsu.initialized]
return bool(not unvalid and nr_of_shots > 1)
def draw_multi_edit(self, context: bpy.types.Context) -> None:
"""
Panel in sequence editor that can edit properties of multiple strips at one.
Mostly used to quickly initialize lots of shots with an increasing counter.
"""
addon_prefs = prefs.addon_prefs_get(context)
nr_of_shots = len(context.selected_sequences)
noun = get_selshots_noun(nr_of_shots)
# Create box.
layout = self.layout
box = layout.box()
box.label(text="Multi Edit Metadata", icon="PROPERTIES")
# Sequence
col = box.column()
sub_row = col.row(align=True)
sub_row.prop(context.window_manager, "selected_sequence_name", text="Sequence")
sub_row.operator(KITSU_OT_sqe_push_new_sequence.bl_idname, text="", icon="ADD")
# Counter.
row = box.row()
row.prop(context.window_manager, "shot_counter_start", text="Shot Counter Start")
row.prop(context.window_manager, "show_advanced", text="", icon="TOOL_SETTINGS")
if context.window_manager.show_advanced:
# Counter.
box.row().prop(addon_prefs, "shot_counter_digits", text="Shot Counter Digits")
box.row().prop(addon_prefs, "shot_counter_increment", text="Shot Counter Increment")
# Variables.
row = box.row(align=True)
row.prop(
context.window_manager,
"var_use_custom_seq",
text="Custom Sequence Variable",
)
if context.window_manager.var_use_custom_seq:
row.prop(context.window_manager, "var_sequence_custom", text="")
# Project.
row = box.row(align=True)
row.prop(
context.window_manager,
"var_use_custom_project",
text="Custom Project Variable",
)
if context.window_manager.var_use_custom_project:
row.prop(context.window_manager, "var_project_custom", text="")
# Shot pattern.
box.row().prop(addon_prefs, "shot_pattern", text="Shot Pattern")
# Preview.
row = box.row()
row.prop(context.window_manager, "shot_preview", text="Preview")
row = box.row(align=True)
row.operator(
KITSU_OT_sqe_multi_edit_strip.bl_idname,
text=f"Set Metadata for {noun}",
icon="ALIGN_LEFT",
)
@classmethod
def poll_push(cls, context: bpy.types.Context) -> bool:
# If only one strip is selected and it is not init then hide panel.
if not prefs.session_auth(context):
return False
selshots = context.selected_sequences
if not selshots:
selshots = context.scene.sequence_editor.sequences_all
strips_to_meta = []
strips_to_tb = []
strips_to_submit = []
for s in selshots:
if s.kitsu.linked:
strips_to_tb.append(s)
strips_to_meta.append(s)
elif s.kitsu.initialized and (s.kitsu.manual_shot_name != "" or s.kitsu.shot_name):
strips_to_submit.append(s)
return bool(strips_to_meta or strips_to_tb or strips_to_submit)
def draw_push(self, context: bpy.types.Context) -> None:
"""
Panel that shows operator to sync sequence editor metadata with backend.
"""
nr_of_shots = len(context.selected_sequences)
layout = self.layout
strip = context.scene.sequence_editor.active_strip
selshots = context.selected_sequences
if not selshots:
selshots = context.scene.sequence_editor.sequences_all
strips_to_meta = []
strips_to_tb = []
strips_to_submit = []
strips_to_delete = []
for s in selshots:
if s.kitsu.linked:
strips_to_tb.append(s)
strips_to_meta.append(s)
strips_to_delete.append(s)
elif s.kitsu.initialized:
if s.kitsu.shot_name and s.kitsu.sequence_name:
strips_to_submit.append(s)
# Create box.
layout = self.layout
box = layout.box()
box.label(text="Push", icon="EXPORT")
# Special case if one shot is selected and it is init but not linked
# shows the operator but it is not enabled until user types in required metadata.
if nr_of_shots == 1 and not strip.kitsu.linked:
# New operator.
row = box.row()
col = row.column(align=True)
col.operator(
KITSU_OT_sqe_push_new_shot.bl_idname,
text="Submit New Shot",
icon="ADD",
)
return
# Either way no selection one selection but linked or multiple.
# Metadata operator.
row = box.row()
if strips_to_meta:
col = row.column(align=True)
noun = get_selshots_noun(len(strips_to_meta), prefix=f"{len(strips_to_meta)}")
col.operator(
KITSU_OT_sqe_push_shot_meta.bl_idname,
text=f"Metadata {noun}",
icon="ALIGN_LEFT",
)
# Thumbnail and seqeunce renderoperator.
if strips_to_tb:
# Upload thumbnail op.
noun = get_selshots_noun(len(strips_to_tb), prefix=f"{len(strips_to_meta)}")
split = col.split(factor=0.7, align=True)
split.operator(
KITSU_OT_sqe_push_render_still.bl_idname,
text=f"Render Still {noun}",
icon="IMAGE_DATA",
)
# Select task types op.
noun = context.scene.kitsu.task_type_thumbnail_name or "Select Task Type"
split.operator(
KITSU_OT_sqe_set_thumbnail_task_type.bl_idname,
text=noun,
icon="DOWNARROW_HLT",
)
# Sqe render op.
noun = get_selshots_noun(len(strips_to_tb), prefix=f"{len(strips_to_meta)}")
split = col.split(factor=0.7, align=True)
split.operator(
KITSU_OT_sqe_push_render.bl_idname,
text=f"Render Movie {noun}",
icon="IMAGE_DATA",
)
# Select task types op.
noun = context.scene.kitsu.task_type_sqe_render_name or "Select Task Type"
split.operator(
KITSU_OT_sqe_set_sqe_render_task_type.bl_idname,
text=noun,
icon="DOWNARROW_HLT",
)
# Submit operator.
if nr_of_shots > 0:
if strips_to_submit:
noun = get_selshots_noun(len(strips_to_submit), prefix=f"{len(strips_to_submit)}")
row = box.row()
col = row.column(align=True)
col.operator(
KITSU_OT_sqe_push_new_shot.bl_idname,
text=f"Submit {noun}",
icon="ADD",
)
@classmethod
def poll_pull(cls, context: bpy.types.Context) -> bool:
if not prefs.session_auth(context):
return False
selshots = context.selected_sequences
all_shots = context.scene.sequence_editor.sequences_all
if not selshots: # Pull entire edit.
return True
strips_to_meta_sel = [s for s in selshots if s.kitsu.linked]
strips_to_meta_all = [s for s in all_shots if s.kitsu.linked]
if not selshots:
return bool(strips_to_meta_all)
return bool(strips_to_meta_sel)
def draw_pull(self, context: bpy.types.Context) -> None:
"""
Panel that shows operator to sync sequence editor metadata with backend.
"""
selshots = context.selected_sequences
if not selshots:
selshots = context.scene.sequence_editor.sequences_all
strips_to_meta = []
for s in selshots:
if s.kitsu.linked:
strips_to_meta.append(s)
# Create box.
layout = self.layout
box = layout.box()
box.label(text="Pull", icon="IMPORT")
layout = self.layout
if strips_to_meta:
noun = get_selshots_noun(len(strips_to_meta), prefix=f"{len(strips_to_meta)}")
row = box.row()
row.operator(
KITSU_OT_sqe_pull_shot_meta.bl_idname,
text=f"Metadata {noun}",
icon="ALIGN_LEFT",
)
if not context.selected_sequences:
row = box.row()
row.operator(
KITSU_OT_sqe_pull_edit.bl_idname,
text=f"Pull entire Edit",
icon="FILE_MOVIE",
)
@classmethod
def poll_debug(cls, context: bpy.types.Context) -> bool:
return prefs.addon_prefs_get(context).enable_debug
def draw_debug(self, context: bpy.types.Context) -> None:
nr_of_shots = len(context.selected_sequences)
noun = get_selshots_noun(nr_of_shots)
# Create box.
layout = self.layout
box = layout.box()
box.label(text="Debug", icon="MODIFIER_ON")
row = box.row()
row.operator(
KITSU_OT_sqe_debug_duplicates.bl_idname,
text=f"Duplicates {noun}",
icon="MODIFIER_ON",
)
row = box.row()
row.operator(
KITSU_OT_sqe_debug_not_linked.bl_idname,
text=f"Not Linked {noun}",
icon="MODIFIER_ON",
)
row = box.row()
row.operator(
KITSU_OT_sqe_debug_multi_project.bl_idname,
text=f"Multi Projects {noun}",
icon="MODIFIER_ON",
)
def draw_media(self, context: bpy.types.Context) -> None:
sel_metadata_strips = [strip for strip in context.selected_sequences if strip.kitsu.linked]
noun = get_selshots_noun(len(sel_metadata_strips), prefix=f"{len(sel_metadata_strips)}")
playblast = "Playblast" if len(sel_metadata_strips) <= 1 else "Playblasts"
sequence = "Sequence" if len(sel_metadata_strips) <= 1 else "Sequences"
# Create box.
layout = self.layout
box = layout.box()
box.label(text="Media", icon="RENDER_ANIMATION")
box.operator(
KITSU_OT_sqe_import_playblast.bl_idname,
text=f"Import {noun} {playblast}",
icon="FILE_MOVIE",
)
box.operator(
KITSU_OT_sqe_import_image_sequence.bl_idname,
text=f"Import {noun} Image {sequence}",
icon="RENDER_RESULT",
)
class KITSU_PT_sqe_general_tools(bpy.types.Panel):
"""
Panel in sequence editor that shows tools that don't relate directly to Kitsu
"""
bl_category = "Kitsu"
bl_label = "General Tools"
bl_space_type = "SEQUENCE_EDITOR"
bl_region_type = "UI"
bl_order = 30
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not context_core.is_edit_context():
return False
selshots = context.selected_sequences
sqe = context.scene.sequence_editor
if not sqe:
return False
if not selshots:
selshots = context.scene.sequence_editor.sequences_all
movie_strips = [s for s in selshots if s.type == "MOVIE"]
return bool(movie_strips)
def draw(self, context: bpy.types.Context) -> None:
active_strip = context.scene.sequence_editor.active_strip
selshots = context.selected_sequences
if not selshots:
selshots = context.scene.sequence_editor.sequences_all
strips_to_update_media = []
for s in selshots:
if s.type == "MOVIE":
strips_to_update_media.append(s)
# Create box.
layout = self.layout
box = layout.box()
box.label(text="General", icon="MODIFIER")
# Scan for outdated media and reset operator.
row = box.row(align=True)
row.operator(
KITSU_OT_sqe_scan_for_media_updates.bl_idname,
text=f"Check media update for {len(strips_to_update_media)} {'strip' if len(strips_to_update_media) == 1 else 'strips'}",
)
row.operator(KITSU_OT_sqe_clear_update_indicators.bl_idname, text="", icon="X")
# Up down source operator. Check for to strips to accommodate linked strips
if len(selshots) <= 2 and active_strip and active_strip.type == "MOVIE":
row = box.row(align=True)
row.prop(active_strip, "filepath_display", text="")
row.operator(
KITSU_OT_sqe_change_strip_source.bl_idname, text="", icon="TRIA_UP"
).direction = "UP"
row.operator(
KITSU_OT_sqe_change_strip_source.bl_idname, text="", icon="TRIA_DOWN"
).direction = "DOWN"
row.operator(
KITSU_OT_sqe_change_strip_source.bl_idname, text="", icon="FILE_PARENT"
).go_latest = True
# ---------REGISTER ----------.
classes = [
KITSU_MT_sqe_advanced_delete,
KITSU_PT_sqe_shot_tools,
KITSU_PT_sqe_general_tools,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,27 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
from ..tasks 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()
-60
View File
@@ -1,60 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Dict, List, Set, Optional, Tuple, Any
from .. import tasks
import bpy
import gazu
from .. import cache, prefs, util
from ..tasks import opsdata
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
class KITSU_OT_tasks_user_laod(bpy.types.Operator):
"""
Gets all tasks that the current logged in user is assgined to
"""
bl_idname = "kitsu.tasks_user_laod"
bl_label = "Tasks Load"
bl_property = "enum_prop"
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return prefs.session_auth(context)
def execute(self, context: bpy.types.Context) -> Set[str]:
addon_prefs = prefs.addon_prefs_get(context)
tasks_coll_prop = addon_prefs.tasks
active_user = cache.user_active_get()
# load tasks this also updates the collection property
cache.load_user_all_tasks(context)
util.ui_redraw()
self.report(
{"INFO"},
f"Fetched {len(tasks_coll_prop.items())} tasks for {active_user.full_name}",
)
return {"FINISHED"}
# ---------REGISTER ----------
classes = [KITSU_OT_tasks_user_laod]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
@@ -1,12 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, Dict, List, Tuple, Union
from pathlib import Path
import bpy
from ..logger import LoggerFactory
logger = LoggerFactory.getLogger()
-90
View File
@@ -1,90 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
import bpy
from .. import prefs, cache
from .ops import KITSU_OT_tasks_user_laod
# from ..tasks.ops import KITSU_OT_session_end, KITSU_OT_session_start
class KITSU_PT_tasks_user(bpy.types.Panel):
bl_category = "Kitsu"
bl_label = "Tasks"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_order = 45
bl_options = {"DEFAULT_CLOSED"}
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
return prefs.session_auth(context)
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
addon_prefs = prefs.addon_prefs_get(context)
tasks_coll_prop = addon_prefs.tasks
active_user = cache.user_active_get()
split_factor = 0.225
split_factor_small = 0.95
box = layout.box()
row = box.row(align=True)
row.label(text=active_user.full_name, icon="CHECKBOX_HLT")
# Detect Context
row.operator(
KITSU_OT_tasks_user_laod.bl_idname,
icon="FILE_REFRESH",
text="",
emboss=False,
)
# uilist
row = box.row(align=True)
row.template_list(
"KITSU_UL_tasks_user",
"",
addon_prefs,
"tasks",
context.window_manager.kitsu,
"tasks_index",
rows=5,
type="DEFAULT",
)
class KITSU_UL_tasks_user(bpy.types.UIList):
def draw_item(
self, context, layout, data, item, icon, active_data, active_propname, index
):
task_id = item.id
entity_id = item.entity_id
entity_name = item.entity_name
task_type_id = item.task_type_id
task_type_name = item.task_type_name
if self.layout_type in {"DEFAULT", "COMPACT"}:
layout.label(text=f"{entity_name} {task_type_name}")
elif self.layout_type in {"GRID"}:
layout.alignment = "CENTER"
layout.label(text=f"{entity_name} {task_type_name}")
# ---------REGISTER ----------
classes = [KITSU_UL_tasks_user, KITSU_PT_tasks_user]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
File diff suppressed because it is too large Load Diff
-84
View File
@@ -1,84 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import bpy
from . import prefs
def draw_error_box(layout: bpy.types.UILayout) -> bpy.types.UILayout:
box = layout.box()
box.label(text="Error", icon="ERROR")
return box
def draw_error_active_project_unset(box: bpy.types.UILayout) -> bpy.types.UILayout:
row = box.row(align=True)
row.label(text="No Active Project")
row.operator(
"preferences.addon_show", text="Open Addon Preferences"
).module = __package__
def draw_error_invalid_playblast_root_dir(
box: bpy.types.UILayout,
) -> bpy.types.UILayout:
row = box.row(align=True)
row.label(text="Invalid Playblast Root Directory")
row.operator(
"preferences.addon_show", text="Open Addon Preferences"
).module = __package__
def draw_error_invalid_edit_export_root_dir(
box: bpy.types.UILayout,
) -> bpy.types.UILayout:
row = box.row(align=True)
row.label(text="Invalid Edit Export Directory")
row.operator("preferences.addon_show", text="Open Addon Preferences").module = __package__
def draw_error_frame_range_outdated(
box: bpy.types.UILayout,
) -> bpy.types.UILayout:
row = box.row(align=True)
row.alert = True
row.label(text="Frame Range Outdated")
row.operator("kitsu.pull_frame_range", icon="TRIA_DOWN")
def draw_error_invalid_render_preset_dir(
box: bpy.types.UILayout,
) -> bpy.types.UILayout:
row = box.row(align=True)
row.label(text="Invalid Render Preset Directory")
row.operator(
"preferences.addon_show", text="Open Addon Preferences"
).module = __package__
def draw_error_invalid_project_root_dir(
box: bpy.types.UILayout,
) -> bpy.types.UILayout:
row = box.row(align=True)
row.label(text="Invalid Project Root Directory")
row.operator(
"preferences.addon_show", text="Open Addon Preferences"
).module = __package__
def draw_error_config_dir_not_exists(
box: bpy.types.UILayout,
) -> bpy.types.UILayout:
addon_prefs = prefs.addon_prefs_get(bpy.context)
row = box.row(align=True)
row.label(text=f"Config Directory does not exist: {addon_prefs.config_dir}")
def draw_error_no_active_camera(
box: bpy.types.UILayout,
) -> bpy.types.UILayout:
row = box.row(align=True)
row.label(text=f"No active camera")
row.prop(bpy.context.scene, "camera", text="")
-42
View File
@@ -1,42 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from typing import Union
import bpy
from . import bkglobals
def ui_redraw() -> None:
"""
Forces blender to redraw the UI.
"""
for screen in bpy.data.screens:
for area in screen.areas:
area.tag_redraw()
def get_version(str_value: str, format: type = str) -> Union[str, int, None]:
match = re.search(bkglobals.VERSION_PATTERN, str_value)
if match:
version = match.group()
if format == str:
return version
if format == int:
return int(version.replace("v", ""))
return None
def addon_prefs_get(context: bpy.types.Context) -> bpy.types.AddonPreferences:
# NOTE: This was moved out of prefs.py to resolve a circular dependency with cache.py.
if not context:
context = bpy.context
base_package = __package__
if base_package.startswith('bl_ext'):
# 4.2
return context.preferences.addons[base_package].preferences
else:
return context.preferences.addons[base_package.split(".")[0]].preferences
@@ -1,135 +0,0 @@
"""External dependencies loader."""
import contextlib
import importlib
from pathlib import Path
import sys
import logging
from types import ModuleType
from typing import Iterator, Iterable
_my_dir = Path(__file__).parent
_log = logging.getLogger(__name__)
def load_wheel(module_name: str, submodules: Iterable[str]) -> list[ModuleType]:
"""Loads modules from a wheel file 'module_name*.whl'.
Loads `module_name`, and if submodules are given, loads
`module_name.submodule` for each of the submodules. This allows loading all
required modules from the same wheel in one session, ensuring that
inter-submodule references are correct.
Returns the loaded modules, so [module, submodule, submodule, ...].
"""
fname_prefix = _fname_prefix_from_module_name(module_name)
wheel = _wheel_filename(fname_prefix)
loaded_modules: list[ModuleType] = []
to_load = [module_name] + [f"{module_name}.{submodule}" for submodule in submodules]
# Load the module from the wheel file. Keep a backup of sys.path so that it
# can be restored later. This should ensure that future import statements
# cannot find this wheel file, increasing the separation of dependencies of
# this add-on from other add-ons.
with _sys_path_mod_backup(wheel):
for modname in to_load:
try:
module = importlib.import_module(modname)
except ImportError as ex:
raise ImportError(
"Unable to load %r from %s: %s" % (modname, wheel, ex)
) from None
assert isinstance(module, ModuleType)
loaded_modules.append(module)
_log.info("Loaded %s from %s", modname, module.__file__)
assert len(loaded_modules) == len(
to_load
), f"expecting to load {len(to_load)} modules, but only have {len(loaded_modules)}: {loaded_modules}"
return loaded_modules
def load_wheel_global(module_name: str, fname_prefix: str = "") -> ModuleType:
"""Loads a wheel from 'fname_prefix*.whl', unless the named module can be imported.
This allows us to use system-installed packages before falling back to the shipped wheels.
This is useful for development, less so for deployment.
If `fname_prefix` is the empty string, it will use the first package from `module_name`.
In other words, `module_name="pkg.subpkg"` will result in `fname_prefix="pkg"`.
"""
if not fname_prefix:
fname_prefix = _fname_prefix_from_module_name(module_name)
try:
module = importlib.import_module(module_name)
except ImportError as ex:
_log.debug("Unable to import %s directly, will try wheel: %s", module_name, ex)
else:
_log.debug(
"Was able to load %s from %s, no need to load wheel %s",
module_name,
module.__file__,
fname_prefix,
)
return module
wheel = _wheel_filename(fname_prefix)
wheel_filepath = str(wheel)
if wheel_filepath not in sys.path:
sys.path.insert(0, wheel_filepath)
try:
module = importlib.import_module(module_name)
except ImportError as ex:
raise ImportError(
"Unable to load %r from %s: %s" % (module_name, wheel, ex)
) from None
_log.debug("Globally loaded %s from %s", module_name, module.__file__)
return module
@contextlib.contextmanager
def _sys_path_mod_backup(wheel_file: Path) -> Iterator[None]:
"""Temporarily inserts a wheel onto sys.path.
When the context exits, it restores sys.path and sys.modules, so that
anything that was imported within the context remains unimportable by other
modules.
"""
old_syspath = sys.path[:]
old_sysmod = sys.modules.copy()
try:
sys.path.insert(0, str(wheel_file))
yield
finally:
# Restore without assigning a new list instance. That way references
# held by other code will stay valid.
sys.path[:] = old_syspath
sys.modules.clear()
sys.modules.update(old_sysmod)
def _wheel_filename(fname_prefix: str) -> Path:
path_pattern = "%s*.whl" % fname_prefix
wheels: list[Path] = list(_my_dir.glob(path_pattern))
if not wheels:
raise RuntimeError("Unable to find wheel at %r" % path_pattern)
# If there are multiple wheels that match, load the last-modified one.
# Alphabetical sorting isn't going to cut it since BAT 1.10 was released.
def modtime(filepath: Path) -> float:
return filepath.stat().st_mtime
wheels.sort(key=modtime)
return wheels[-1]
def _fname_prefix_from_module_name(module_name: str) -> str:
return module_name.split(".", 1)[0]
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-4
View File
@@ -1,4 +0,0 @@
# Blender SVN
blender-svn is a Blender add-on to interact with the Subversion version control system from within Blender.
You can find the documentation [here](https://studio.blender.org/tools/addons/blender_svn).
-76
View File
@@ -1,76 +0,0 @@
# SPDX-FileCopyrightText: 2021 Blender Studio Tools Authors
#
# SPDX-License-Identifier: GPL-3.0-or-later
import importlib
import bpy
from . import (
props,
repository,
operators,
threaded,
ui,
prefs,
svn_info,
)
bl_info = {
"name": "Blender SVN",
"author": "Demeter Dzadik, Paul Golter",
"description": "An SVN (Subversion) interface built directly into Blender",
"blender": (3, 1, 0),
"version": (1, 0, 3),
"location": "View3D",
"warning": "",
"doc_url": "",
"tracker_url": "https://projects.blender.org/studio/blender-studio-tools/src/branch/main/scripts-blender/addons/blender_svn",
"category": "Generic",
}
modules = [
props,
operators,
repository,
threaded,
ui,
prefs,
svn_info,
]
def register_unregister_modules(modules, register: bool):
"""Recursively register or unregister modules by looking for either
un/register() functions or lists named `registry` which should be a list of
registerable classes.
"""
register_func = bpy.utils.register_class if register else bpy.utils.unregister_class
for m in modules:
if register:
importlib.reload(m)
if hasattr(m, 'registry'):
for c in m.registry:
try:
register_func(c)
except Exception as e:
un = 'un' if not register else ''
print(f"Warning: Failed to {un}register class: {c.__name__}")
print(e)
if hasattr(m, 'modules'):
register_unregister_modules(m.modules, register)
if register and hasattr(m, 'register'):
m.register()
elif hasattr(m, 'unregister'):
m.unregister()
def register():
register_unregister_modules(modules, True)
def unregister():
register_unregister_modules(modules, False)

Some files were not shown because too many files have changed in this diff Show More