2025-12-01

This commit is contained in:
2026-03-17 14:58:51 -06:00
parent 183e865f8b
commit 4b82b57113
6846 changed files with 954887 additions and 162606 deletions
@@ -0,0 +1,16 @@
# 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()
@@ -0,0 +1,45 @@
# 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)
@@ -0,0 +1,121 @@
# 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")
@@ -0,0 +1,53 @@
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}")
@@ -0,0 +1,50 @@
{
"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
}
}
@@ -0,0 +1,219 @@
# 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)
@@ -0,0 +1,22 @@
# 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
@@ -0,0 +1,162 @@
# 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
@@ -0,0 +1,632 @@
# 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)
@@ -0,0 +1,69 @@
# 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
@@ -0,0 +1,12 @@
# 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.
@@ -0,0 +1,63 @@
# 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)