2025-12-01
This commit is contained in:
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
Reference in New Issue
Block a user