Blender Kitsu: Refactor Shot Builder #183

Merged
Nick Alberelli merged 55 commits from TinyNick/blender-studio-pipeline:feature/shot-builder-2 into main 2023-12-21 23:58:21 +01:00
51 changed files with 124 additions and 2781 deletions
Showing only changes of commit 01492ccda8 - Show all commits

View File

@ -24,7 +24,6 @@ dependencies.preload_modules()
from . import ( from . import (
shot_builder, shot_builder,
shot_builder_2,
lookdev, lookdev,
bkglobals, bkglobals,
types, types,
@ -99,7 +98,6 @@ def register():
playblast.register() playblast.register()
anim.register() anim.register()
shot_builder.register() shot_builder.register()
shot_builder_2.register()
LoggerLevelManager.configure_levels() LoggerLevelManager.configure_levels()
logger.info("Registered blender-kitsu") logger.info("Registered blender-kitsu")
@ -118,7 +116,6 @@ def unregister():
lookdev.unregister() lookdev.unregister()
playblast.unregister() playblast.unregister()
shot_builder.unregister() shot_builder.unregister()
shot_builder_2.unregister()
LoggerLevelManager.restore_levels() LoggerLevelManager.restore_levels()

View File

@ -40,7 +40,7 @@ from .auth.ops import (
) )
from .context.ops import KITSU_OT_con_productions_load from .context.ops import KITSU_OT_con_productions_load
from .lookdev.prefs import LOOKDEV_preferences from .lookdev.prefs import LOOKDEV_preferences
from .shot_builder.editorial.core import editorial_export_check_latest from .shot_builder.editorial import editorial_export_check_latest
logger = LoggerFactory.getLogger() logger = LoggerFactory.getLogger()
@ -339,12 +339,6 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences):
default="ANI-", default="ANI-",
) )
user_exec_code: bpy.props.StringProperty( # type: ignore
name="Post Execution Command",
description="Run this command after shot_builder is complete, but before the file is saved.",
default="",
)
session: Session = Session() session: Session = Session()
tasks: bpy.props.CollectionProperty(type=KITSU_task) tasks: bpy.props.CollectionProperty(type=KITSU_task)
@ -439,7 +433,7 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences):
start_frame_row.prop(self, "shot_builder_frame_offset", text="") start_frame_row.prop(self, "shot_builder_frame_offset", text="")
box.row().prop(self, "shot_builder_armature_prefix") box.row().prop(self, "shot_builder_armature_prefix")
box.row().prop(self, "shot_builder_action_prefix") box.row().prop(self, "shot_builder_action_prefix")
box.row().prop(self, "user_exec_code") box.operator("kitsu.save_shot_builder_hooks", icon='FILE_SCRIPT')
# Misc settings. # Misc settings.
box = layout.box() box = layout.box()

View File

@ -1,62 +1,15 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
from .ui import *
from .connectors.kitsu import *
from .operators import *
import bpy import bpy
from .anim_setup import ops as anim_setup_ops # TODO Fix Registraion from . import ops, ui
from .editorial import ops as editorial_ops # TODO Fix Registraion from .ui import topbar_file_new_draw_handler
# import logging
# logging.basicConfig(level=logging.DEBUG)
# bl_info = {
# 'name': 'Shot Builder',
# "author": "Jeroen Bakker",
# 'version': (0, 1),
# 'blender': (2, 90, 0),
# 'location': 'Addon Preferences panel and file new menu',
# 'description': 'Shot builder production tool.',
# 'category': 'Studio',
# }
classes = (
KitsuPreferences,
SHOTBUILDER_OT_NewShotFile,
)
def register(): def register():
anim_setup_ops.register()
editorial_ops.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_file_new.append(topbar_file_new_draw_handler)
ops.register()
ui.register()
def unregister(): def unregister():
anim_setup_ops.unregister()
editorial_ops.unregister()
bpy.types.TOPBAR_MT_file_new.remove(topbar_file_new_draw_handler) bpy.types.TOPBAR_MT_file_new.remove(topbar_file_new_draw_handler)
for cls in classes: ops.unregister()
bpy.utils.unregister_class(cls) ui.unregister()

View File

@ -1,34 +0,0 @@
import bpy
import re
from pathlib import Path
from typing import Set
from ... import prefs
from ... import cache
def animation_workspace_vse_area_add(context: bpy.types.Context):
"""Split smallest 3D View in current workspace"""
for workspace in [
workspace for workspace in bpy.data.workspaces if workspace.name == "Animation"
]:
context.window.workspace = workspace
context.view_layer.update()
areas = workspace.screens[0].areas
view_3d_areas = sorted(
[area for area in areas if area.ui_type == "VIEW_3D"],
key=lambda x: x.width,
reverse=False,
)
small_view_3d = view_3d_areas[0]
with context.temp_override(window=context.window, area=small_view_3d):
bpy.ops.screen.area_split(direction='HORIZONTAL', factor=0.5)
small_view_3d.ui_type = "SEQUENCE_EDITOR"
small_view_3d.spaces[0].view_type = "PREVIEW"
def animation_workspace_delete_others():
"""Delete any workspace that is not an animation workspace"""
for ws in bpy.data.workspaces:
if ws.name != "Animation":
with bpy.context.temp_override(workspace=ws):
bpy.ops.workspace.delete()

View File

@ -1,35 +0,0 @@
import bpy
from typing import Set
from .core import animation_workspace_delete_others, animation_workspace_vse_area_add
class ANIM_SETUP_OT_setup_workspaces(bpy.types.Operator):
bl_idname = "anim_setup.setup_workspaces"
bl_label = "Setup Workspace"
bl_description = "Sets up the workspaces for the animation task"
def execute(self, context: bpy.types.Context) -> Set[str]:
animation_workspace_delete_others(self, context)
self.report({"INFO"}, "Deleted non Animation workspaces")
return {"FINISHED"}
class ANIM_SETUP_OT_animation_workspace_vse_area_add(bpy.types.Operator):
bl_idname = "anim_setup.animation_workspace_vse_area_add"
bl_label = "Split Viewport"
bl_description = "Split smallest 3D View in current workspace"
def execute(self, context: bpy.types.Context) -> Set[str]:
animation_workspace_vse_area_add(self, context)
return {"FINISHED"}
classes = [
ANIM_SETUP_OT_setup_workspaces,
ANIM_SETUP_OT_animation_workspace_vse_area_add
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)

View File

@ -1,50 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
class Asset:
"""
Container to hold data where the asset can be located in the production repository.
path: absolute path to the blend file containing this asset.
"""
asset_type = ""
code = ""
name = ""
path = "{production.path}/assets/{asset.asset_type}/{asset.code}/{asset.code}.blend"
collection = "{asset.code}"
def __str__(self) -> str:
return self.name
class AssetRef:
"""
Reference to an asset from an external system.
"""
def __init__(self, name: str = "", code: str = ""):
self.name = name
self.code = code
def __str__(self) -> str:
return self.name

View File

@ -1,100 +0,0 @@
from ..project import Production
from ..task_type import TaskType
from ..asset import Asset, AssetRef
from .build_step import BuildStep, BuildContext
from .init_asset import InitAssetStep
from .init_shot import InitShotStep
from .set_render_settings import SetRenderSettingsStep
from .new_scene import NewSceneStep
from .invoke_hook import InvokeHookStep
import bpy
import typing
import logging
logger = logging.getLogger(__name__)
class ShotBuilder:
def __init__(
self,
context: bpy.types.Context,
production: Production,
task_type: TaskType,
shot_name: str,
):
self._steps: typing.List[BuildStep] = []
shot = production.get_shot(context, shot_name)
assert shot
render_settings = production.get_render_settings(context, shot)
self.build_context = BuildContext(
context=context,
production=production,
shot=shot,
render_settings=render_settings,
task_type=task_type,
)
def __find_asset(self, asset_ref: AssetRef) -> typing.Optional[Asset]:
for asset_class in self.build_context.production.assets:
asset = typing.cast(Asset, asset_class())
logger.debug(f"{asset_ref.name}, {asset.name}")
if asset_ref.name == asset.name:
return asset
return None
def create_build_steps(self) -> None:
self._steps.append(InitShotStep())
self._steps.append(NewSceneStep())
self._steps.append(SetRenderSettingsStep())
production = self.build_context.production
task_type = self.build_context.task_type
# Add global hooks.
for hook in production.hooks.filter():
self._steps.append(InvokeHookStep(hook))
# Add task specific hooks.
for hook in production.hooks.filter(match_task_type=task_type.name):
self._steps.append(InvokeHookStep(hook))
context = self.build_context.context
shot = self.build_context.shot
# Collect assets that should be loaded.
asset_refs = production.get_assets_for_shot(context, shot)
assets = []
for asset_ref in asset_refs:
asset = self.__find_asset(asset_ref)
if asset is None:
logger.warning(f"cannot determine repository data for {asset_ref}")
continue
assets.append(asset)
# Sort the assets on asset_type and asset.code).
assets.sort(key=lambda asset: (asset.asset_type, asset.code))
# Build asset specific build steps.
for asset in assets:
self._steps.append(InitAssetStep(asset))
# Add asset specific hooks.
for hook in production.hooks.filter(
match_task_type=task_type.name, match_asset_type=asset.asset_type
):
self._steps.append(InvokeHookStep(hook))
def build(self) -> None:
num_steps = len(self._steps)
step_number = 1
build_context = self.build_context
window_manager = build_context.context.window_manager
window_manager.progress_begin(min=0, max=num_steps)
for step in self._steps:
logger.info(f"Building step [{step_number}/{num_steps}]: {step} ")
step.execute(build_context=build_context)
window_manager.progress_update(value=step_number)
step_number += 1
window_manager.progress_end()

View File

@ -1,38 +0,0 @@
import bpy
import typing
from ..project import Production
from ..shot import Shot
from ..task_type import TaskType
from ..render_settings import RenderSettings
from ..asset import Asset
class BuildContext:
def __init__(self, context: bpy.types.Context, production: Production, shot: Shot, render_settings: RenderSettings, task_type: TaskType):
self.context = context
self.production = production
self.shot = shot
self.task_type = task_type
self.render_settings = render_settings
self.asset: typing.Optional[Asset] = None
self.scene: typing.Optional[bpy.types.Scene] = None
def as_dict(self) -> typing.Dict[str, typing.Any]:
return {
'context': self.context,
'scene': self.scene,
'production': self.production,
'shot': self.shot,
'task_type': self.task_type,
'render_settings': self.render_settings,
'asset': self.asset,
}
class BuildStep:
def __str__(self) -> str:
return "unnamed build step"
def execute(self, build_context: BuildContext) -> None:
raise NotImplementedError()

View File

@ -1,25 +0,0 @@
from ..builder.build_step import BuildStep, BuildContext
from ..asset import *
from ..project import *
from ..shot import *
import bpy
import logging
logger = logging.getLogger(__name__)
class InitAssetStep(BuildStep):
def __init__(self, asset: Asset):
self.__asset = asset
def __str__(self) -> str:
return f"init asset \"{self.__asset.name}\""
def execute(self, build_context: BuildContext) -> None:
build_context.asset = self.__asset
self.__asset.path = self.__asset.path.format_map(build_context.as_dict())
self.__asset.collection = self.__asset.collection.format_map(
build_context.as_dict()
)

View File

@ -1,19 +0,0 @@
from ..builder.build_step import BuildStep, BuildContext
from ..asset import *
from ..project import *
from ..shot import *
import bpy
import logging
logger = logging.getLogger(__name__)
class InitShotStep(BuildStep):
def __str__(self) -> str:
return "init shot"
def execute(self, build_context: BuildContext) -> None:
shot = build_context.shot
shot.file_path = shot.file_path_format.format_map(build_context.as_dict())

View File

@ -1,21 +0,0 @@
from ..builder.build_step import BuildStep, BuildContext
from ..hooks import HookFunction
import bpy
import typing
import types
import logging
logger = logging.getLogger(__name__)
class InvokeHookStep(BuildStep):
def __init__(self, hook: HookFunction):
self._hook = hook
def __str__(self) -> str:
return f"invoke hook [{self._hook.__name__}]"
def execute(self, build_context: BuildContext) -> None:
params = build_context.as_dict()
self._hook(**params) # type: ignore

View File

@ -1,30 +0,0 @@
from ..builder.build_step import BuildStep, BuildContext
from ..render_settings import RenderSettings
import bpy
import logging
logger = logging.getLogger(__name__)
class NewSceneStep(BuildStep):
def __str__(self) -> str:
return f"new scene"
def execute(self, build_context: BuildContext) -> None:
production = build_context.production
scene_name = production.scene_name_format.format_map(
build_context.as_dict())
logger.debug(f"create scene with name {scene_name}")
scene = bpy.data.scenes.new(name=scene_name)
bpy.context.window.scene = scene
build_context.scene = scene
self.__remove_other_scenes(build_context)
def __remove_other_scenes(self, build_context: BuildContext) -> None:
for scene in bpy.data.scenes:
if scene != build_context.scene:
logger.debug(f"remove scene {scene.name}")
bpy.data.scenes.remove(scene)

View File

@ -1,30 +0,0 @@
from ..builder.build_step import BuildStep, BuildContext
from ..asset import *
from ..project import *
from ..shot import *
import pathlib
import bpy
import logging
logger = logging.getLogger(__name__)
def save_shot_builder_file(file_path: str):
"""Save Shot File within Folder of matching name.
Set Shot File to relative Paths."""
dir_path = pathlib.Path(file_path)
dir_path.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_mainfile(filepath=file_path, relative_remap=True)
class SaveFileStep(BuildStep):
def __str__(self) -> str:
return "save file"
def execute(self, build_context: BuildContext) -> None:
shot = build_context.shot
file_path = pathlib.Path(shot.file_path)
save_shot_builder_file(file_path)
logger.info(f"save file {shot.file_path}")

View File

@ -1,27 +0,0 @@
from ..builder.build_step import BuildStep, BuildContext
import bpy
import typing
import logging
logger = logging.getLogger(__name__)
class SetRenderSettingsStep(BuildStep):
def __str__(self) -> str:
return f"set render settings"
def execute(self, build_context: BuildContext) -> None:
scene = typing.cast(bpy.types.Scene, build_context.scene)
render_settings = build_context.render_settings
logger.debug(
f"set render resolution to {render_settings.width}x{render_settings.height}")
scene.render.resolution_x = render_settings.width
scene.render.resolution_y = render_settings.height
scene.render.resolution_percentage = 100
shot = build_context.shot
scene.frame_start = shot.frame_start
scene.frame_current = shot.frame_start
scene.frame_end = shot.frame_start + shot.frames -1
logger.debug(f"set frame range to ({scene.frame_start}-{scene.frame_end})")

View File

@ -1,19 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>

View File

@ -1,108 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
"""
This module contains the Connector class. It is an abstract base class for concrete connectors.
"""
from ..shot import Shot, ShotRef
from..asset import Asset, AssetRef
from..task_type import TaskType
from..render_settings import RenderSettings
from typing import *
if TYPE_CHECKING:
from..project import Production
from..properties import ShotBuilderPreferences
class Connector:
"""
A Connector is used to retrieve data from a source. This source can be an external system.
Connectors can be configured for productions in its `shot-builder/config.py` file.
# Members
_production: reference to the production that we want to read data for.
_preference: reference to the add-on preference to read settings for.
Connectors can add settings to the add-on preferences.
# Class Members
PRODUCTION_KEYS: Connectors can register production configuration keys that will be loaded from the production config file.
When keys are added the content will be read and stored in the production.
# Usage
Concrete connectors only overrides methods that they support. All non-overridden methods will raise an
NotImplementerError.
Example of using predefined connectors in a production config file:
```shot-builder/config.py
from ..connectors.default import DefaultConnector
from ..connectors.kitsu import KitsuConnector
PRODUCTION_NAME = DefaultConnector
TASK_TYPES = KitsuConnector
KITSU_PROJECT_ID = "...."
```
"""
PRODUCTION_KEYS: Set[str] = set()
def __init__(self, production: 'Production', preferences: 'ShotBuilderPreferences'):
self._production = production
self._preferences = preferences
def get_name(self) -> str:
"""
Retrieve the production name using the connector.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support retrieval of production name")
def get_task_types(self) -> List[TaskType]:
"""
Retrieve the task types using the connector.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support retrieval of task types")
def get_shots(self) -> List[ShotRef]:
"""
Retrieve the shots using the connector.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support retrieval of shots")
def get_assets_for_shot(self, shot: Shot) -> List[AssetRef]:
"""
Retrieve the sequences using the connector.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support retrieval of assets for a shot")
def get_render_settings(self, shot: Shot) -> RenderSettings:
"""
Retrieve the render settings for the given shot.
"""
raise NotImplementedError(
f"{self.__class__.__name__} does not support retrieval of render settings for a shot")

View File

@ -1,49 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
from ..shot import Shot, ShotRef
from ..asset import Asset, AssetRef
from ..task_type import TaskType
from ..render_settings import RenderSettings
from ..connectors.connector import Connector
from typing import *
class DefaultConnector(Connector):
"""
Default connector is a connector that returns the defaults for the shot builder add-on.
"""
def get_name(self) -> str:
return "unnamed production"
def get_shots(self) -> List[ShotRef]:
return []
def get_assets_for_shot(self, shot: Shot) -> List[AssetRef]:
return []
def get_task_types(self) -> List[TaskType]:
return [TaskType("anim"), TaskType("lighting"), TaskType("comp"), TaskType("fx")]
def get_render_settings(self, shot: Shot) -> RenderSettings:
"""
Retrieve the render settings for the given shot.
"""
return RenderSettings(width=1920, height=1080, frames_per_second=24.0)

View File

@ -1,248 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import bpy
from .. import vars
from ..shot import Shot, ShotRef
from ..asset import Asset, AssetRef
from ..task_type import TaskType
from ..render_settings import RenderSettings
from ..connectors.connector import Connector
import requests
from ... import cache
import gazu
import typing
import logging
logger = logging.getLogger(__name__)
class KitsuException(Exception):
pass
class KitsuPreferences(bpy.types.PropertyGroup):
backend: bpy.props.StringProperty( # type: ignore
name="Server URL",
description="Kitsu server address",
default="https://kitsu.blender.cloud/api",
)
username: bpy.props.StringProperty( # type: ignore
name="Username",
description="Username to connect to Kitsu",
)
password: bpy.props.StringProperty( # type: ignore
name="Password",
description="Password to connect to Kitsu",
subtype='PASSWORD',
)
def draw(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
layout.label(text="Kitsu")
layout.prop(self, "backend")
layout.prop(self, "username")
layout.prop(self, "password")
def _validate(self):
if not (self.backend and self.username and self.password):
raise KitsuException(
"Kitsu connector has not been configured in the add-on preferences"
)
class KitsuDataContainer:
def __init__(self, data: typing.Dict[str, typing.Optional[str]]):
self._data = data
def get_parent_id(self) -> typing.Optional[str]:
return self._data['parent_id']
def get_id(self) -> str:
return str(self._data['id'])
def get_name(self) -> str:
return str(self._data['name'])
def get_code(self) -> typing.Optional[str]:
return self._data['code']
def get_description(self) -> str:
result = self._data['description']
if result is None:
return ""
return result
class KitsuProject(KitsuDataContainer):
def get_resolution(self) -> typing.Tuple[int, int]:
"""
Get the resolution and decode it to (width, height)
"""
res_str = str(self._data['resolution'])
splitted = res_str.split("x")
return (int(splitted[0]), int(splitted[1]))
class KitsuSequenceRef(ShotRef):
def __init__(self, kitsu_id: str, name: str, code: str):
super().__init__(name=name, code=code)
self.kitsu_id = kitsu_id
def sync_data(self, shot: Shot) -> None:
shot.sequence_code = self.name
class KitsuShotRef(ShotRef):
def __init__(
self,
kitsu_id: str,
name: str,
code: str,
frame_start: int,
frames: int,
frame_end: int,
frames_per_second: float,
sequence: KitsuSequenceRef,
):
super().__init__(name=name, code=code)
self.kitsu_id = kitsu_id
self.frame_start = frame_start
self.frames = frames
self.frame_end = frame_end
self.frames_per_second = frames_per_second
self.sequence = sequence
def sync_data(self, shot: Shot) -> None:
shot.name = self.name
shot.code = self.code
shot.kitsu_id = self.kitsu_id
shot.frame_start = self.frame_start
shot.frames = self.frames
shot.frame_end = self.frame_end
shot.frames_per_second = self.frames_per_second
self.sequence.sync_data(shot)
class KitsuConnector(Connector):
# PRODUCTION_KEYS = {'KITSU_PROJECT_ID'}
def __init__(self, **kwargs):
super().__init__(**kwargs)
def __get_production_data(self) -> KitsuProject:
production = cache.project_active_get()
project = KitsuProject(typing.cast(typing.Dict[str, typing.Any], production))
return project
def get_name(self) -> str:
production = self.__get_production_data()
return production.get_name()
def get_task_types(self) -> typing.List[TaskType]:
project = cache.project_active_get()
task_types = project.task_types
import pprint
pprint.pprint(task_types)
return []
def get_shots(self) -> typing.List[ShotRef]:
project = cache.project_active_get()
kitsu_sequences = gazu.shot.all_sequences_for_project(project.id)
sequence_lookup = {
sequence_data['id']: KitsuSequenceRef(
kitsu_id=sequence_data['id'],
name=sequence_data['name'],
code=sequence_data['code'],
)
for sequence_data in kitsu_sequences
}
kitsu_shots = gazu.shot.all_shots_for_project(project.id)
shots: typing.List[ShotRef] = []
for shot_data in kitsu_shots:
# Initialize default values
frame_start = vars.DEFAULT_FRAME_START
frame_end = 0
# shot_data['data'] can be None
if shot_data['data']:
# If 3d_start key not found use default start frame.
frame_start = int(
shot_data['data'].get('3d_start', vars.DEFAULT_FRAME_START)
)
frame_end = (
int(shot_data['data'].get('3d_start', vars.DEFAULT_FRAME_START))
+ shot_data['nb_frames']
- 1
)
# If 3d_start and 3d_out available use that to calculate frames.
# If not try shot_data['nb_frames'] or 0 -> invalid.
frames = int(
(frame_end - frame_start + 1)
if frame_end
else shot_data['nb_frames'] or 0
)
if frames < 0:
logger.error(
"%s duration is negative: %i. Check frame range information on Kitsu",
shot_data['name'],
frames,
)
frames = 0
shots.append(
KitsuShotRef(
kitsu_id=shot_data['id'],
name=shot_data['name'],
code=shot_data['code'],
frame_start=frame_start,
frames=frames,
frame_end=frame_end,
frames_per_second=24.0,
sequence=sequence_lookup[shot_data['parent_id']],
)
)
return shots
def get_assets_for_shot(self, shot: Shot) -> typing.List[AssetRef]:
kitsu_assets = gazu.asset.all_assets_for_shot(shot.kitsu_id)
return [
AssetRef(name=asset_data['name'], code=asset_data['code'])
for asset_data in kitsu_assets
]
def get_render_settings(self, shot: Shot) -> RenderSettings:
"""
Retrieve the render settings for the given shot.
"""
project = cache.project_active_get()
return RenderSettings(
width=int(project.resolution.split('x')[0]),
height=int(project.resolution.split('x')[1]),
frames_per_second=project.fps,
)

View File

@ -1,246 +0,0 @@
# Project Description (DRAFT)
Shot Builder is an Add-on that helps studios to work with task specific
Blend-files. The shot builder is part of the shot-tools repository. The main functionalities are
* Build blend files for a specific task and shot.
* Sync data back from work files to places like kitsu, or `edit.blend`.
## Design Principles
The main design principles are:
* The core-tool can be installed as an add-on, but the (production specific)
configuration should be part of the production repository.
* The configuration files are a collection of python files. The API between
the configuration files and the add-on should be easy to use as pipeline
TDs working on the production should be able to work with it.
* TDs/artists should be able to handle issues during building without looking
at how the add-on is structured.
* The tool contains connectors that can be configured to read/write data
from the system/file that is the main location of the data. For example
The start and end time of a shot could be stored in an external production tracking application.
## Connectors
Connectors are components that can be used to read or write to files or
systems. The connectors will add flexibility to the add-on so it could be used
in multiple productions or studios.
In the configuration files the TD can setup the connectors that are used for
the production. Possible connectors would be:
* Connector for text based config files (json/yaml).
* Connector for kitsu (https://www.cg-wire.com/en/kitsu.html).
* Connector for blend files.
## Layering & Hooks
The configuration of the tool is layered. When building a work file for a sequence
there are multiple ways to change the configuration.
* Configuration for the production.
* Configuration for the asset that is needed.
* Configuration for the asset type of the loaded asset.
* Configuration for the sequence.
* Configuration for the shot.
* Configuration for the task type.
For any combination of these configurations hooks can be defined.
```
@shot_tools.hook(match_asset_name='Spring', match_shot_code='02_020A')
def hook_Spring_02_020A(asset: shot_tools.Asset, shot: shot_tools.Shot, **kwargs) -> None:
"""
Specific overrides when Spring is loaded in 02_020A.
"""
@shot_tools.hook(match_task_type='anim')
def hook_task_anim(task: shot_tools.Task, shot: shot_tools.Shot, **kwargs) -> None:
"""
Specific overrides for any animation task.
"""
```
### Data
All hooks must have Pythons `**kwargs` parameter. The `kwargs` contains
the context at the moment the hook is invoked. The context can contain the
following items.
* `production`: `shot_tools.Production`: Include the name of the production
and the location on the filesystem.
* `task`: `shot_tools.Task`: The task (combination of task_type and shot)
* `task_type`: `shot_tools.TaskType`: Is part of the `task`.
* `sequence`: `shot_tools.Sequence`: Is part of `shot`.
* `shot`: `shot_tools.Shot` Is part of `task`.
* `asset`: `shot_tools.Asset`: Only available during asset loading phase.
* `asset_type`: `shot_tools.AssetType`: Only available during asset loading phase.
### Execution Order
The add-on will internally create a list containing the hooks that needs to be
executed for the command in a sensible order. It will then execute them in that
order.
By default the next order will be used:
* Production wide hooks
* Asset Type hooks
* Asset hooks
* Sequence hooks
* Shot hooks
* Task type hooks
A hook with a single match rule will be run in the corresponding phase. A hook with
multiple match rules will be run in the last matching phase. For example, a hook with
asset and task type match rules will be run in the task type phase.
#### Events
Order of execution can be customized by adding the optional `run_before`
or `run_after` parameters.
```
@shot_tools.hook(match_task_type='anim',
requires={shot_tools.events.AssetsLoaded, hook_task_other_anim},
is_required_by={shot_tools.events.ShotOverrides})
def hook_task_anim(task: shot_tools.Task, shot: shot_tools.Shot, **kwargs) -> None:
"""
Specific overrides for any animation task run after all assets have been loaded.
"""
```
Events could be:
* `shot_tools.events.BuildStart`
* `shot_tools.events.ProductionSettingsLoaded`
* `shot_tools.events.AssetsLoaded`
* `shot_tools.events.AssetTypeOverrides`
* `shot_tools.events.SequenceOverrides`
* `shot_tools.events.ShotOverrides`
* `shot_tools.events.TaskTypeOverrides`
* `shot_tools.events.BuildFinished`
* `shot_tools.events.HookStart`
* `shot_tools.events.HookEnd`
During usage we should see which one of these or other events are needed.
`shot_tools.events.BuildStart`, `shot_tools.events.ProductionSettingsLoaded`
and `shot_tools.events.HookStart` can only be used in the `run_after`
parameter. `shot_tools.events.BuildFinished`, `shot_tools.events.HookFinished`
can only be used in the `run_before` parameter.
## API
The shot builder has an API between the add-on and the configuration files. This
API contains convenience functions and classes to hide complexity and makes
sure that the configuration files are easy to maintain.
```
register_task_type(task_type="anim")
register_task_type(task_type="lighting")
```
```
# shot_tool/characters.py
class Asset(shot_tool.some_module.Asset):
asset_file = "/{asset_type}/{name}/{name}.blend"
collection = “{class_name}”
name = “{class_name}”
class Character(Asset):
asset_type = char
class Ellie(Character):
collection = “{class_name}-{variant_name}”
variants = {default, short_hair}
class Victoria(Character): pass
class Rex(Character): pass
# shot_tool/shots.py
class Shot_01_020_A(shot_tool.some_module.Shot):
shot_id = 01_020_A
assets = {
characters.Ellie(variant=”short_hair”),
characters.Rex,
sets.LogOverChasm,
}
class AllHumansShot(shot_tool.some_module.Shot):
assets = {
characters.Ellie(variant=”short_hair”),
characters.Rex,
characters.Victoria,
}
class Shot_01_035_A(AllHumansShot):
assets = {
sets.Camp,
}
```
This API is structured/implemented in a way that it keeps track of what
is being done. This will be used when an error occurs so a descriptive
error message can be generated that would help the TD to solve the issue more
quickly. The goal would be that the error messages are descriptive enough to
direct the TD into the direction where the actual cause is. And when possible
propose several solutions to fix it.
## Setting up the tool
The artist/TD can configure their current local project directory in the add-on preferences.
This can then be used for new blend files. The project associated with an opened (so existing)
blend file can be found automatically by iterating over parent directories until a Shot Builder
configuration file is found. Project-specific settings are not configured/stored in the add-on,
but in this configuration file.
The add-on will look in the root of the production repository to locate the
main configuration file `/project_root_directory/pro/shot-builder/config.py`. This file contains general
settings about the production, including:
* The name of the production for reporting back to the user when needed.
* Naming standards to test against when reporting deviations.
* Location of other configuration (`tasks.py`, `assets.py`) relative to the `shot-builder` directory of the production.
* Configuration of the needed connectors.
### Directory Layout
``` bash
└── project-name/ # Project Root Directory
└── pro/
├── assets/
├── shot-builder/
│ ├── assets.py
│ ├── config.py
│ ├── hooks.py
│ └── shots.py
└── shots/
```
## Usage
Any artist can open a shot file via the `File` menu. A modal panel appears
where the user can select the task type and sequence/shot. When the file
already exists, it will be opened. When the file doesn't exist, the file
will be built.
In the future other use cases will also be accessible, such as:
* Syncing data back from a work file to the source of the data.
* Report of errors/differences between the shot file and the configuration.
## Open Issues
### Security
* Security keys needed by connectors need to be stored somewhere. The easy
place is to place inside the production repository, but that isn't secure
Anyone with access to the repository could misuse the keys to access the
connector. Other solution might be to use the OS key store or retrieve the
keys from an online service authenticated by the blender cloud add-on.
We could use `keyring` to access OS key stores.

View File

@ -1,4 +0,0 @@
# Example configuration files
This folder contains an example shot builder configuration. It shows the part
that a TD would do to incorporate the shot builder in a production.

View File

@ -1,30 +0,0 @@
from blender_kitsu.shot_builder.asset import Asset
class ProductionAsset(Asset):
path = "{production.path}/assets/{asset.asset_type}/{asset.code}/{asset.code}.blend" # Path to most assets
color_tag = "NONE"
# Categories
class Character(ProductionAsset):
asset_type = "chars"
collection = "CH-{asset.code}" # Prefix for characters
class Prop(ProductionAsset):
asset_type = "props"
collection = "PR-{asset.code}" # Prefix for props
# Assets
class MyCharacter(Character):
name = "My Character" # Name on Kitsu Server
code = "mycharacter" # Name of Collection without prefix (e.g. CH-mycharacter)
path = "{production.path}/assets/{asset.asset_type}/mycharacter/publish/mycharacter.v001.blend" # This asset has a custom path
color_tag = "COLOR_01"
class MyProp(Prop):
name = "MyProp"
code = "myprop"

View File

@ -1,13 +0,0 @@
from blender_kitsu.shot_builder.connectors.kitsu import KitsuConnector
PRODUCTION_NAME = KitsuConnector
SHOTS = KitsuConnector
ASSETS = KitsuConnector
RENDER_SETTINGS = KitsuConnector
# Formatting rules
# ----------------
# The name of the scene in blender where the shot is build in.
SCENE_NAME_FORMAT = "{shot.name}.{task_type}"
SHOT_NAME_FORMAT = "{shot.name}"

View File

@ -1,170 +0,0 @@
import bpy
from blender_kitsu.shot_builder.hooks import hook, Wildcard
from blender_kitsu.shot_builder.asset import Asset
from blender_kitsu.shot_builder.shot import Shot
from blender_kitsu.shot_builder.project import Production
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
# ---------- Global Hook ----------
CAMERA_NAME = 'CAM-camera'
@hook()
def set_cycles_render_engine(scene: bpy.types.Scene, **kwargs):
"""
By default we set Cycles as the renderer.
"""
scene.render.engine = 'CYCLES'
# ---------- Overrides for animation files ----------
@hook(match_task_type='anim')
def task_type_anim_set_workbench(scene: bpy.types.Scene, **kwargs):
"""
Override of the render engine to Workbench when building animation files.
"""
scene.render.engine = 'BLENDER_WORKBENCH'
# ---------- Create output collection for animation files ----------
def _add_camera_rig(
scene: bpy.types.Scene,
production: Production,
shot: Shot,
):
"""
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.
path = f"{production.path}/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)
shot.output_collection.objects.link(camera_object)
return
collection_name = "CA-camera_rig"
bpy.ops.wm.link(
filepath=path,
directory=path + "/Collection",
filename=collection_name,
)
# Keep the active object name as this would also be the name of the collection after enabling library override.
active_object_name = bpy.context.active_object.name
# Make library override.
bpy.ops.object.make_override_library()
# Add camera collection to the output collection
asset_collection = bpy.data.collections[active_object_name]
shot.output_collection.children.link(asset_collection)
# Set the camera of the camera rig as active scene camera.
camera = bpy.data.objects[CAMERA_NAME]
scene.camera = camera
@hook(match_task_type='anim')
def task_type_anim_output_collection(
scene: bpy.types.Scene, production: Production, shot: Shot, task_type: str, **kwargs
):
"""
Animations are stored in an output collection. This collection will be linked
by the lighting file.
Also loads the camera rig.
"""
output_collection = bpy.data.collections.new(
name=shot.get_output_collection_name(shot=shot, task_type=task_type)
)
shot.output_collection = output_collection
output_collection.use_fake_user = True
scene.collection.children.link(output_collection)
_add_camera_rig(scene, production, shot)
@hook(match_task_type='lighting')
def link_anim_output_collection(
scene: bpy.types.Scene, production: Production, shot: Shot, **kwargs
):
"""
Link in the animation output collection from the animation file.
"""
anim_collection = bpy.data.collections.new(name="animation")
scene.collection.children.link(anim_collection)
anim_file_path = shot.get_anim_file_path(production, shot)
anim_output_collection_name = shot.get_output_collection_name(
shot=shot, task_type="anim"
)
result = bpy.ops.wm.link(
filepath=anim_file_path,
directory=anim_file_path + "/Collection",
filename=anim_output_collection_name,
)
assert result == {'FINISHED'}
# Move the anim output collection from scene collection to the animation collection.
anim_output_collection = bpy.data.objects[anim_output_collection_name]
anim_collection.objects.link(anim_output_collection)
scene.collection.objects.unlink(anim_output_collection)
# Use animation camera as active scene camera.
camera = bpy.data.objects['CAM-camera']
scene.camera = camera
# ---------- Asset loading and linking ----------
@hook(match_task_type='anim', match_asset_type=['chars', 'props'])
def link_char_prop_for_anim(scene: bpy.types.Scene, shot: Shot, asset: Asset, **kwargs):
"""
Loading a character or prop for an animation file.
"""
collection_names = []
if asset.code == 'notepad_pencil':
collection_names.append("PR-pencil")
collection_names.append("PR-notepad")
else:
collection_names.append(asset.collection)
for collection_name in collection_names:
logger.info("link asset")
bpy.ops.wm.link(
filepath=str(asset.path),
directory=str(asset.path) + "/Collection",
filename=collection_name,
)
# Keep the active object name as this would also be the name of the collection after enabling library override.
active_object_name = bpy.context.active_object.name
# Make library override.
bpy.ops.object.make_override_library()
# Add overridden collection to the output collection.
asset_collection = bpy.data.collections[active_object_name]
shot.output_collection.children.link(asset_collection)
@hook(match_task_type=Wildcard, match_asset_type='sets')
def link_set(asset: Asset, **kwargs):
"""
Load the set of the shot.
"""
bpy.ops.wm.link(
filepath=str(asset.path),
directory=str(asset.path) + "/Collection",
filename=asset.collection,
)

View File

@ -1,6 +0,0 @@
# Example configuration files
This folder contains an example shot builder configuration. It shows the part
that a TD would do to incorporate the shot builder in a production.

View File

@ -1,41 +0,0 @@
from blender_kitsu.shot_builder.shot import Shot
from blender_kitsu.shot_builder.project import Production
class ProductionShot(Shot):
def get_anim_file_path(self, production: Production, shot: Shot) -> str:
"""Get the animation file path for this given shot."""
return self.file_path_format.format_map(
{'production': production, 'shot': shot, 'task_type': "anim"}
)
def get_lighting_file_path(self, production: Production, shot: Shot) -> str:
"""Get the lighting file path for this given shot."""
return self.file_path_format.format_map(
{'production': production, 'shot': shot, 'task_type': "lighting"}
)
def get_output_collection_name(self, shot: Shot, task_type: str) -> str:
"""Get the collection name where the output is stored."""
return f"{shot.name}.{task_type}.output"
def is_valid(self) -> bool:
"""Check if this shot contains all data, so it could be selected
for shot building.
"""
if not super().is_valid():
return False
return True
# Assuming path to file is in `project_name/svn/pro/shot/sequence_name/shot_name`
# Render Ouput path should be `project_name/shared/shot_frames/sequence_name/shot_name/`
def get_render_output_dir(self) -> str:
return f"//../../../../../shared/shot_frames/{self.sequence_code}/{self.name}/{self.name}.lighting"
def get_comp_output_dir(self) -> str:
return f"//../../../../../shared/shot_frames/{self.sequence_code}/{self.name}/{self.name}.comp"
class GenericShot(ProductionShot):
is_generic = True

View File

@ -1,30 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import bpy
from . import ops
def register():
ops.register()
def unregister():
ops.unregister()

View File

@ -1,82 +0,0 @@
import bpy
import re
from pathlib import Path
from typing import Set
from ... import prefs
from ... import cache
def editorial_export_get_latest(
context: bpy.types.Context, shot
) -> list[bpy.types.Sequence]: # TODO add info to shot
"""Loads latest export from editorial department"""
addon_prefs = prefs.addon_prefs_get(context)
strip_channel = 1
latest_file = editorial_export_check_latest(context)
if not latest_file:
return None
# Check if Kitsu server returned empty shot
if shot.get("id") == '':
return None
strip_filepath = latest_file.as_posix()
strip_frame_start = addon_prefs.shot_builder_frame_offset
scene = context.scene
if not scene.sequence_editor:
scene.sequence_editor_create()
seq_editor = scene.sequence_editor
movie_strip = seq_editor.sequences.new_movie(
latest_file.name,
strip_filepath,
strip_channel + 1,
strip_frame_start,
fit_method="FIT",
)
sound_strip = seq_editor.sequences.new_sound(
latest_file.name,
strip_filepath,
strip_channel,
strip_frame_start,
)
new_strips = [movie_strip, sound_strip]
# Update shift frame range prop.
frame_in = shot["data"].get("frame_in")
frame_3d_start = shot["data"].get("3d_start")
frame_3d_offset = frame_3d_start - addon_prefs.shot_builder_frame_offset
edit_export_offset = addon_prefs.edit_export_frame_offset
# Set sequence strip start kitsu data.
for strip in new_strips:
strip.frame_start = (
-frame_in + (strip_frame_start * 2) + frame_3d_offset + edit_export_offset
)
return new_strips
def editorial_export_check_latest(context: bpy.types.Context):
"""Find latest export in editorial export directory"""
addon_prefs = prefs.addon_prefs_get(context)
edit_export_path = Path(addon_prefs.edit_export_dir)
files_list = [
f
for f in edit_export_path.iterdir()
if f.is_file()
and editorial_export_is_valid_edit_name(
addon_prefs.edit_export_file_pattern, f.name
)
]
if len(files_list) >= 1:
files_list = sorted(files_list, reverse=True)
return files_list[0]
return None
def editorial_export_is_valid_edit_name(file_pattern: str, filename: str) -> bool:
"""Verify file name matches file pattern set in preferences"""
match = re.search(file_pattern, filename)
if match:
return True
return False

View File

@ -1,42 +0,0 @@
import bpy
from typing import Set
from .core import editorial_export_get_latest
from ... import cache
import gazu
class ANIM_SETUP_OT_load_latest_editorial(bpy.types.Operator):
bl_idname = "asset_setup.load_latest_editorial"
bl_label = "Load Editorial Export"
bl_description = (
"Loads latest edit from shot_preview_folder "
"Shifts edit so current shot starts at 3d_start metadata shot key from Kitsu"
)
def execute(self, context: bpy.types.Context) -> Set[str]:
cache_shot = cache.shot_active_get()
shot = gazu.shot.get_shot(cache_shot.id) # TODO INEFFICENT TO LOAD SHOT TWICE
strips = editorial_export_get_latest(context, shot)
if strips is None:
self.report(
{"ERROR"}, f"No valid editorial export in editorial export path."
)
return {"CANCELLED"}
self.report({"INFO"}, f"Loaded latest edit: {strips[0].name}")
return {"FINISHED"}
classes = [
ANIM_SETUP_OT_load_latest_editorial,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)

View File

@ -1,8 +1,14 @@
import sys
import pathlib
from typing import *
import typing import typing
import types import types
from collections.abc import Iterable from collections.abc import Iterable
import importlib
from .. import prefs
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -44,6 +50,8 @@ HookFunction = typing.Callable[[typing.Any], None]
def _match_hook_parameter( def _match_hook_parameter(
hook_criteria: MatchCriteriaType, match_query: typing.Optional[str] hook_criteria: MatchCriteriaType, match_query: typing.Optional[str]
) -> bool: ) -> bool:
if hook_criteria == None:
return True
if hook_criteria == DoNotMatch: if hook_criteria == DoNotMatch:
return match_query is None return match_query is None
if hook_criteria == Wildcard: if hook_criteria == Wildcard:
@ -60,10 +68,6 @@ class Hooks:
def __init__(self): def __init__(self):
self._hooks: typing.List[HookFunction] = [] self._hooks: typing.List[HookFunction] = []
def register(self, func: HookFunction) -> None:
logger.info(f"registering hook '{func.__name__}'")
self._hooks.append(func)
def matches( def matches(
self, self,
hook: HookFunction, hook: HookFunction,
@ -73,7 +77,6 @@ class Hooks:
) -> bool: ) -> bool:
assert not kwargs assert not kwargs
rules = typing.cast(MatchingRulesType, getattr(hook, '_shot_builder_rules')) rules = typing.cast(MatchingRulesType, getattr(hook, '_shot_builder_rules'))
return all( return all(
( (
_match_hook_parameter(rules['match_task_type'], match_task_type), _match_hook_parameter(rules['match_task_type'], match_task_type),
@ -86,15 +89,39 @@ class Hooks:
if self.matches(hook=hook, **kwargs): if self.matches(hook=hook, **kwargs):
yield hook 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 _register_hook(func: types.FunctionType) -> None: def load_hooks(self, context):
from .project import get_active_production root_dir = prefs.project_root_dir_get(context)
shot_builder_config_dir = root_dir.joinpath("pro/assets/scripts/shot-builder")
if not shot_builder_config_dir.exists():
raise Exception("Shot Builder Hooks directory does not exist")
paths = [
shot_builder_config_dir.resolve().__str__() # TODO Make variable path
] # TODO Set path to where hooks are stored
with SystemPathInclude(paths) as _include:
try:
import hooks as production_hooks
production = get_active_production() importlib.reload(production_hooks)
production.hooks.register(func) self.register_hooks(production_hooks)
except ModuleNotFoundError:
raise Exception("Production has no `hooks.py` configuration file")
return False
def register_hooks(module: types.ModuleType) -> None: 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. Register all hooks inside the given module.
""" """
@ -106,12 +133,12 @@ def register_hooks(module: types.ModuleType) -> None:
continue continue
if not hasattr(module_item, "_shot_builder_rules"): if not hasattr(module_item, "_shot_builder_rules"):
continue continue
_register_hook(module_item) self.register(module_item)
def hook( def hook(
match_task_type: MatchCriteriaType = DoNotMatch, match_task_type: MatchCriteriaType = None,
match_asset_type: MatchCriteriaType = DoNotMatch, match_asset_type: MatchCriteriaType = None,
) -> typing.Callable[[types.FunctionType], types.FunctionType]: ) -> typing.Callable[[types.FunctionType], types.FunctionType]:
""" """
Decorator to add custom logic when building a shot. Decorator to add custom logic when building a shot.
@ -128,3 +155,41 @@ def hook(
return func return func
return wrapper return wrapper
class SystemPathInclude:
"""
Resource class to temporary include system paths to `sys.paths`.
Usage:
```
paths = [pathlib.Path("/home/guest/my_python_scripts")]
with SystemPathInclude(paths) as t:
import my_module
reload(my_module)
```
It is possible to nest multiple SystemPathIncludes.
"""
def __init__(self, paths_to_add: List[pathlib.Path]):
# TODO: Check if all paths exist and are absolute.
self.__paths = paths_to_add
self.__original_sys_path: List[str] = []
def __enter__(self):
self.__original_sys_path = sys.path
new_sys_path = []
for path_to_add in self.__paths:
# Do not add paths that are already in the sys path.
# Report this to the logger as this might indicate wrong usage.
path_to_add_str = str(path_to_add)
if path_to_add_str in self.__original_sys_path:
logger.warn(f"{path_to_add_str} already added to `sys.path`")
continue
new_sys_path.append(path_to_add_str)
new_sys_path.extend(self.__original_sys_path)
sys.path = new_sys_path
def __exit__(self, exc_type, exc_value, exc_traceback):
sys.path = self.__original_sys_path

View File

@ -1,298 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import pathlib
from typing import *
import bpy
import gazu
from .shot import ShotRef
from .project import (
ensure_loaded_production,
get_active_production,
)
from .builder import ShotBuilder
from .task_type import TaskType
from .. import prefs, cache
from .anim_setup.core import (
animation_workspace_delete_others,
animation_workspace_vse_area_add,
)
from .editorial.core import editorial_export_get_latest
from .builder.save_file import save_shot_builder_file
_production_task_type_items: List[Tuple[str, str, str]] = []
def production_task_type_items(
self: Any, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _production_task_type_items
return _production_task_type_items
_production_seq_id_items: List[Tuple[str, str, str]] = []
def production_seq_id_items(
self: Any, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _production_seq_id_items
return _production_seq_id_items
_production_shots: List[ShotRef] = []
def production_shots(
self: Any, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _production_shots
return _production_shots
_production_shot_id_items_for_seq: List[Tuple[str, str, str]] = []
def production_shot_id_items_for_seq(
self: Any, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
global _production_shot_id_items_for_seq
global _production_shot_id_items
if not self.seq_id or not _production_shots:
return []
shots_for_seq: List[Tuple(str, str, str)] = [
(s.name, s.name, "")
for s in _production_shots
if s.sequence.name == self.seq_id
]
_production_shot_id_items_for_seq.clear()
_production_shot_id_items_for_seq.extend(shots_for_seq)
return _production_shot_id_items_for_seq
def reset_shot_id_enum(self: Any, context: bpy.types.Context) -> None:
production_shot_id_items_for_seq(self, context)
global _production_shot_id_items_for_seq
if _production_shot_id_items_for_seq:
self.shot_id = _production_shot_id_items_for_seq[0][0]
class SHOTBUILDER_OT_NewShotFile(bpy.types.Operator):
"""Build a new shot file"""
bl_idname = "shotbuilder.new_shot_file"
bl_label = "New Production Shot File"
_timer = None
_built_shot = False
_add_vse_area = False
_file_path = ''
production_root: bpy.props.StringProperty( # type: ignore
name="Production Root", description="Root of the production", subtype='DIR_PATH'
)
production_name: bpy.props.StringProperty( # type: ignore
name="Production",
description="Name of the production to create a shot file for",
options=set(),
)
seq_id: bpy.props.EnumProperty( # type: ignore
name="Sequence ID",
description="Sequence ID of the shot to build",
items=production_seq_id_items,
update=reset_shot_id_enum,
)
shot_id: bpy.props.EnumProperty( # type: ignore
name="Shot ID",
description="Shot ID of the shot to build",
items=production_shot_id_items_for_seq,
)
task_type: bpy.props.EnumProperty( # type: ignore
name="Task",
description="Task to create the shot file for",
items=production_task_type_items,
)
auto_save: bpy.props.BoolProperty(
name="Save after building.",
description="Automatically save build file after 'Shot Builder' is complete.",
default=True,
)
def modal(self, context, event):
if event.type == 'TIMER' and not self._add_vse_area:
# Show Storyboard/Animatic from VSE
"""Running as Modal Event because functions within execute() function like
animation_workspace_delete_others() changed UI context that needs to be refreshed.
https://docs.blender.org/api/current/info_gotcha.html#no-updates-after-changing-ui-context
"""
# TODO this is a hack, should be inherient to above builder
# TODO fix during refactor
if self.task_type == 'anim':
animation_workspace_vse_area_add(context)
self._add_vse_area = True
if self._built_shot and self._add_vse_area:
if self.auto_save:
file_path = pathlib.Path()
try:
save_shot_builder_file(self._file_path)
self.report(
{"INFO"}, f"Saved Shot{self.shot_id} at {self._file_path}"
)
return {'FINISHED'}
except FileExistsError:
self.report(
{"ERROR"},
f"Cannot create a file/folder when that file/folder already exists {file_path}",
)
return {'CANCELLED'}
self.report({"INFO"}, f"Built Shot {self.shot_id}, file is not saved!")
return {'FINISHED'}
return {'PASS_THROUGH'}
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
addon_prefs = prefs.addon_prefs_get(bpy.context)
project = cache.project_active_get()
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 directiory is configured in 'Blender Kitsu' addon preferences.",
)
return {'CANCELLED'}
self.production_root = addon_prefs.project_root_dir
self.production_name = project.name
if not ensure_loaded_production(context):
self.report(
{'ERROR'},
"Shot builder configuration files not found in current project directory. \nCheck addon preferences to ensure project root contains shot_builder config.",
)
return {'CANCELLED'}
production = get_active_production()
global _production_task_type_items
_production_task_type_items = production.get_task_type_items(context=context)
global _production_seq_id_items
_production_seq_id_items = production.get_seq_items(context=context)
global _production_shots
_production_shots = production.get_shots(context=context)
return cast(
Set[str], context.window_manager.invoke_props_dialog(self, width=400)
)
def execute(self, context: bpy.types.Context) -> Set[str]:
addon_prefs = bpy.context.preferences.addons["blender_kitsu"].preferences
wm = context.window_manager
self._timer = wm.event_timer_add(0.1, window=context.window)
wm.modal_handler_add(self)
if not self.production_root:
self.report(
{'ERROR'},
"Shot builder can only be started from the File menu. Shortcuts like CTRL-N don't work",
)
return {'CANCELLED'}
if self._built_shot:
return {'RUNNING_MODAL'}
ensure_loaded_production(context)
production = get_active_production()
shot_builder = ShotBuilder(
context=context,
production=production,
shot_name=self.shot_id,
task_type=TaskType(self.task_type),
)
shot_builder.create_build_steps()
shot_builder.build()
active_project = cache.project_active_get()
# Build Kitsu Context
sequence = gazu.shot.get_sequence_by_name(active_project.id, self.seq_id)
shot = gazu.shot.get_shot_by_name(sequence, self.shot_id)
# TODO this is a hack, should be inherient to above builder
# TODO fix during refactor
if self.task_type == 'anim':
# Load EDIT
editorial_export_get_latest(context, shot)
# Load Anim Workspace
animation_workspace_delete_others()
# Initilize armatures
for obj in [obj for obj in bpy.data.objects if obj.type == "ARMATURE"]:
base_name = obj.name.split(addon_prefs.shot_builder_armature_prefix)[-1]
new_action = bpy.data.actions.new(
f"{addon_prefs.shot_builder_action_prefix}{base_name}.{self.shot_id}.v001"
)
new_action.use_fake_user = True
obj.animation_data.action = new_action
# Set Shot Frame Range
context.scene.frame_start = int(shot["data"].get("3d_start"))
context.scene.frame_end = (
int(shot["data"].get("3d_start")) + shot.get('nb_frames') - 1
)
# Run User Script
exec(addon_prefs.user_exec_code)
self._file_path = shot_builder.build_context.shot.file_path
self._built_shot = True
return {'RUNNING_MODAL'}
def draw(self, context: bpy.types.Context) -> None:
layout = self.layout
row = layout.row()
row.enabled = False
row.prop(self, "production_name")
layout.prop(self, "seq_id")
layout.prop(self, "shot_id")
layout.prop(self, "task_type")
layout.prop(self, "auto_save")

View File

@ -1,460 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import importlib
from collections import defaultdict
import bpy
from .task_type import *
from .shot import Shot, ShotRef
from .render_settings import RenderSettings
from .asset import Asset, AssetRef
from .sys_utils import *
from .hooks import Hooks, register_hooks
from .connectors.default import DefaultConnector
from .connectors.connector import Connector
import os
from .. import prefs
from pathlib import Path
from typing import *
import types
logger = logging.getLogger(__name__)
class Production:
"""
Class containing data and methods for a production.
# Data members #
path: contains the path to the root of the production.
task_types: contains a list of `TaskType`s or a Connector to retrieve that are defined for this
production. By default the task_types are prefilled with anim and light.
name: human readable name of the production.
"""
__ATTRNAMES_SUPPORTING_CONNECTOR = ['task_types', 'shots', 'name']
def __init__(self, production_path: pathlib.Path):
self.path = production_path
self.task_types: List[TaskType] = []
self.task_types_connector = DefaultConnector
self.shots_connector = DefaultConnector
self.assets: List[type] = []
self.shots: List[Shot] = []
self.name = ""
self.name_connector = DefaultConnector
self.render_settings_connector = DefaultConnector
self.config: Dict[str, Any] = {}
self.__shot_lookup: Dict[str, Shot] = {}
self.hooks: Hooks = Hooks()
self.shot_data_synced = False
self.scene_name_format = "{shot.sequence_code}_{shot.code}.{task_type}"
self.shot_name_format = "{shot.sequence_code}_{shot.code}"
self.file_name_format = (
"{production.path}shots/{shot.code}/{shot.code}.{task_type}.blend"
)
def __create_connector(
self, connector_cls: Type[Connector], context: bpy.types.Context
) -> Connector:
# TODO: Cache connector
preferences = context.preferences.addons["blender_kitsu"].preferences
return connector_cls(production=self, preferences=preferences)
def __format_shot_name(self, shot: Shot) -> str:
return self.shot_name_format.format(shot=shot)
def get_task_type_items(
self, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
"""
Get the list of task types items to be used in an item function of a
`bpy.props.EnumProperty`
"""
if not self.task_types:
connector = self.__create_connector(
self.task_types_connector, context=context
)
self.task_types = connector.get_task_types()
return [
(task_type.name, task_type.name, task_type.name)
for task_type in self.task_types
]
def get_assets_for_shot(
self, context: bpy.types.Context, shot: Shot
) -> List[AssetRef]:
connector = self.__create_connector(self.shots_connector, context=context)
return connector.get_assets_for_shot(shot)
def get_shots(self, context: bpy.types.Context) -> List[ShotRef]:
connector = self.__create_connector(self.shots_connector, context=context)
return connector.get_shots()
def get_shot(self, context: bpy.types.Context, shot_name: str) -> Optional[Shot]:
self._ensure_shot_data(context)
for shot in self.shots:
if shot.name == shot_name:
return shot
return None
def _ensure_shot_data(self, context: bpy.types.Context) -> None:
if self.shot_data_synced:
return
# Find a generic shot definition. This class will be used as template
# when no specific shot definition could be found.
generic_shot_class = None
for shot in self.shots:
if shot.is_generic:
generic_shot_class = shot.__class__
break
shot_refs = self.get_shots(context)
for shot_ref in shot_refs:
logger.debug(f"Finding shot definition for {shot_ref.name}")
for shot in self.shots:
if shot.name == shot_ref.name:
logger.debug(f"Shot definition found for {shot_ref.name}")
shot_ref.sync_data(shot)
break
else:
logger.info(f"No shot definition found for {shot_ref.name}")
if generic_shot_class:
logger.info(f"Using generic shot class")
shot = generic_shot_class()
shot_ref.sync_data(shot)
shot.is_generic = False
self.shots.append(shot)
self.shot_data_synced = True
def get_render_settings(
self, context: bpy.types.Context, shot: Shot
) -> RenderSettings:
connector = self.__create_connector(self.shots_connector, context=context)
return connector.get_render_settings(shot)
def get_shot_items(self, context: bpy.types.Context) -> List[Tuple[str, str, str]]:
"""
Get the list of shot items to be used in an item function of a
`bpy.props.EnumProperty` to select a shot.
"""
result = []
self._ensure_shot_data(context)
sequences: Dict[str, List[Shot]] = defaultdict(list)
for shot in self.shots:
if not shot.is_valid():
continue
sequences[shot.sequence_code].append(shot)
sorted_sequences = sorted(sequences.keys())
for sequence in sorted_sequences:
result.append(("", sequence, sequence))
for shot in sorted(sequences[sequence], key=lambda x: x.name):
result.append((shot.name, self.__format_shot_name(shot), shot.name))
return result
def get_seq_items(self, context: bpy.types.Context) -> List[Tuple[str, str, str]]:
"""
Get the list of seq items to be used in an item function of a
`bpy.props.EnumProperty` to select a shot.
"""
shots = self.get_shots(context)
sequences = list(set([s.sequence for s in shots]))
sequences.sort(key=lambda seq: seq.name)
return [(seq.name, seq.name, "") for seq in sequences]
def get_name(self, context: bpy.types.Context) -> str:
"""
Get the name of the production
"""
if not self.name:
connector = self.__create_connector(self.name_connector, context=context)
self.name = connector.get_name()
return self.name
# TODO: Use visitor pattern.
def __load_name(self, main_config_mod: types.ModuleType) -> None:
name = getattr(main_config_mod, "PRODUCTION_NAME", None)
if name is None:
return
# Extract task types from a list of strings
if isinstance(name, str):
self.name = name
return
if issubclass(name, Connector):
self.name = ""
self.name_connector = name
return
logger.warn(
"Skip loading of production name. Incorrect configuration detected."
)
def __load_task_types(self, main_config_mod: types.ModuleType) -> None:
task_types = getattr(main_config_mod, "TASK_TYPES", None)
if task_types is None:
return
# Extract task types from a list of strings
if isinstance(task_types, list):
self.task_types = [TaskType(task_type) for task_type in task_types]
return
if issubclass(task_types, Connector):
self.task_types = task_types
logger.warn("Skip loading of task_types. Incorrect configuration detected.")
def __load_shots_connector(self, main_config_mod: types.ModuleType) -> None:
shots = getattr(main_config_mod, "SHOTS", None)
if shots is None:
return
# Extract task types from a list of strings
if issubclass(shots, Connector):
self.shots_connector = shots
return
logger.warn("Skip loading of shots. Incorrect configuration detected.")
def __load_connector_keys(self, main_config_mod: types.ModuleType) -> None:
connectors = set()
for attrname in Production.__ATTRNAMES_SUPPORTING_CONNECTOR:
connector = getattr(self, f"{attrname}_connector")
connectors.add(connector)
connector_keys = set()
for connector in connectors:
for key in connector.PRODUCTION_KEYS:
connector_keys.add(key)
for connector_key in connector_keys:
if hasattr(main_config_mod, connector_key):
self.config[connector_key] = getattr(main_config_mod, connector_key)
def __load_render_settings(self, main_config_mod: types.ModuleType) -> None:
render_settings = getattr(main_config_mod, "RENDER_SETTINGS", None)
if render_settings is None:
return
if issubclass(render_settings, Connector):
self.render_settings_connector = render_settings
return
logger.warn("Skip loading of render settings. Incorrect configuration detected")
def __load_formatting_strings(self, main_config_mod: types.ModuleType) -> None:
self.shot_name_format = getattr(
main_config_mod, "SHOT_NAME_FORMAT", self.scene_name_format
)
self.scene_name_format = getattr(
main_config_mod, "SCENE_NAME_FORMAT", self.scene_name_format
)
self.file_name_format = getattr(
main_config_mod, "FILE_NAME_FORMAT", self.file_name_format
)
def _load_config(self, main_config_mod: types.ModuleType) -> None:
self.__load_name(main_config_mod)
self.__load_task_types(main_config_mod)
self.__load_shots_connector(main_config_mod)
self.__load_connector_keys(main_config_mod)
self.__load_render_settings(main_config_mod)
self.__load_formatting_strings(main_config_mod)
def _load_asset_definitions(self, asset_mod: types.ModuleType) -> None:
"""
Load all assets from the given module.
"""
self.assets = []
for module_item_str in dir(asset_mod):
module_item = getattr(asset_mod, module_item_str)
if module_item.__class__ != type:
continue
if not issubclass(module_item, Asset):
continue
if not hasattr(module_item, "name"):
continue
logger.info(f"loading asset config {module_item}")
self.assets.append(module_item)
# TODO: only add assets that are leaves
def _load_shot_definitions(self, shot_mod: types.ModuleType) -> None:
"""
Load all assets from the given module.
"""
self.shots = []
for module_item_str in dir(shot_mod):
module_item = getattr(shot_mod, module_item_str)
if module_item.__class__ != type:
continue
if not issubclass(module_item, Shot):
continue
if not hasattr(module_item, "name"):
continue
logger.info(f"loading shot config {module_item}")
self.shots.append(module_item())
_PRODUCTION: Optional[Production] = None
def is_valid_production_root(path: pathlib.Path) -> bool:
"""
Test if the given project path is configured correctly.
A valid project path contains a subfolder with the name `shot-builder`
holding configuration files.
"""
if not path.is_absolute():
return False
if not path.exists():
return False
if not path.is_dir():
return False
config_file_path = get_production_config_file_path(path)
return config_file_path.exists()
def get_production_config_dir_path(path: pathlib.Path) -> pathlib.Path:
"""
Get the production configuration dir path.
"""
return path / "shot-builder"
def get_production_config_file_path(path: pathlib.Path) -> pathlib.Path:
"""
Get the production configuration file path.
"""
return get_production_config_dir_path(path) / "config.py"
def _find_production_root(path: pathlib.Path) -> Optional[pathlib.Path]:
"""
Given a path try to find the production root
"""
if is_valid_production_root(path):
return path
try:
parent_path = path.parents[0]
return _find_production_root(parent_path)
except IndexError:
return None
# TODO: return type is optional
def get_production_root(context: bpy.types.Context) -> Optional[pathlib.Path]:
"""
Determine the project root based on the current file.
When current file isn't part of a project the project root
configured in the add-on will be used.
"""
current_file = pathlib.Path(bpy.data.filepath)
production_root = _find_production_root(current_file)
if production_root:
return production_root
addon_prefs = prefs.addon_prefs_get(bpy.context)
production_root = Path(addon_prefs.project_root_dir)
if is_valid_production_root(production_root):
return production_root
return None
def ensure_loaded_production(context: bpy.types.Context) -> bool:
"""
Ensure that the production of the current context is loaded.
Returns if the production of for the given context is loaded.
"""
global _PRODUCTION
addon_prefs = prefs.addon_prefs_get(bpy.context)
base_path = Path(addon_prefs.project_root_dir)
production_root = os.path.join(
base_path, "pro"
) # TODO Fix during refactor should use base_path
if is_valid_production_root(Path(production_root)):
logger.debug(f"loading new production configuration from '{production_root}'.")
__load_production_configuration(context, Path(production_root))
return True
return False
def __load_production_configuration(
context: bpy.types.Context, production_path: pathlib.Path
) -> bool:
global _PRODUCTION
_PRODUCTION = Production(production_path)
paths = [production_path / "shot-builder"]
with SystemPathInclude(paths) as _include:
try:
import config as production_config
importlib.reload(production_config)
_PRODUCTION._load_config(production_config)
except ModuleNotFoundError:
logger.warning("Production has no `config.py` configuration file")
try:
import shots as production_shots
importlib.reload(production_shots)
_PRODUCTION._load_shot_definitions(production_shots)
except ModuleNotFoundError:
logger.warning("Production has no `shots.py` configuration file")
try:
import assets as production_assets
importlib.reload(production_assets)
_PRODUCTION._load_asset_definitions(production_assets)
except ModuleNotFoundError:
logger.warning("Production has no `assets.py` configuration file")
try:
import hooks as production_hooks
importlib.reload(production_hooks)
register_hooks(production_hooks)
except ModuleNotFoundError:
logger.warning("Production has no `hooks.py` configuration file")
pass
return False
def get_active_production() -> Production:
global _PRODUCTION
assert _PRODUCTION
return _PRODUCTION

View File

@ -1,29 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
from ..shot_builder.asset import Asset
from typing import *
class RenderSettings:
def __init__(self, width: int, height: int, frames_per_second: float):
self.width = width
self.height = height
self.frames_per_second = frames_per_second

View File

@ -1,64 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import typing
class Shot:
is_generic = False
kitsu_id = ""
sequence_code = ""
name = ""
code = ""
frame_start = 0
frames = 0
# Frame_end will be stored for debugging only.
frame_end = 0
frames_per_second = 24.0
file_path_format = "{production.path}/shots/{shot.sequence_code}/{shot.name}/{shot.name}.{task_type}.blend"
file_path = ""
def is_valid(self) -> bool:
"""
Check if this shot contains all data so it could be selected
for shot building.
When not valid it won't be shown in the shot selection field.
"""
if not self.name:
return False
if self.frames <= 0:
return False
return True
class ShotRef:
"""
Reference to an asset from an external system.
"""
def __init__(self, name: str = "", code: str = ""):
self.name = name
self.code = code
def sync_data(self, shot: Shot) -> None:
pass

View File

@ -1,64 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import sys
import pathlib
import logging
from typing import *
logger = logging.getLogger(__name__)
class SystemPathInclude:
"""
Resource class to temporary include system paths to `sys.paths`.
Usage:
```
paths = [pathlib.Path("/home/guest/my_python_scripts")]
with SystemPathInclude(paths) as t:
import my_module
reload(my_module)
```
It is possible to nest multiple SystemPathIncludes.
"""
def __init__(self, paths_to_add: List[pathlib.Path]):
# TODO: Check if all paths exist and are absolute.
self.__paths = paths_to_add
self.__original_sys_path: List[str] = []
def __enter__(self):
self.__original_sys_path = sys.path
new_sys_path = []
for path_to_add in self.__paths:
# Do not add paths that are already in the sys path.
# Report this to the logger as this might indicate wrong usage.
path_to_add_str = str(path_to_add)
if path_to_add_str in self.__original_sys_path:
logger.warn(f"{path_to_add_str} already added to `sys.path`")
continue
new_sys_path.append(path_to_add_str)
new_sys_path.extend(self.__original_sys_path)
sys.path = new_sys_path
def __exit__(self, exc_type, exc_value, exc_traceback):
sys.path = self.__original_sys_path

View File

@ -1,27 +0,0 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
class TaskType:
def __init__(self, task_name: str):
self.name = task_name
def __str__(self) -> str:
return self.name

View File

@ -1,27 +1,31 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
import bpy import bpy
from typing import * from typing import Any
from .operators import *
def topbar_file_new_draw_handler(self: Any, context: bpy.types.Context) -> None: def topbar_file_new_draw_handler(self: Any, context: bpy.types.Context) -> None:
layout = self.layout layout = self.layout
op = layout.operator(SHOTBUILDER_OT_NewShotFile.bl_idname, text="Shot File") op = layout.operator("kitsu.build_new_shot", text="Shot File")
class KITSU_PT_new_shot_panel(bpy.types.Panel): # TODO Remove (for testing only)
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_label = "New Shot"
bl_category = "New Shot"
def draw(self, context):
self.layout.operator("kitsu.build_new_shot")
self.layout.operator("kitsu.save_shot_builder_hooks")
classes = (KITSU_PT_new_shot_panel,)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

View File

@ -1 +0,0 @@
DEFAULT_FRAME_START: int = 101

View File

@ -1,11 +0,0 @@
from . import ops, ui
def register():
ops.register()
ui.register()
def unregister():
ops.unregister()
ui.unregister()

View File

@ -1,195 +0,0 @@
import sys
import pathlib
from typing import *
import typing
import types
from collections.abc import Iterable
import importlib
from .. import prefs
import logging
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):
root_dir = prefs.project_root_dir_get(context)
shot_builder_config_dir = root_dir.joinpath("pro/assets/scripts/shot-builder")
if not shot_builder_config_dir.exists():
raise Exception("Shot Builder Hooks directory does not exist")
paths = [
shot_builder_config_dir.resolve().__str__() # TODO Make variable path
] # TODO Set path to where hooks are stored
with SystemPathInclude(paths) as _include:
try:
import hooks as production_hooks
importlib.reload(production_hooks)
self.register_hooks(production_hooks)
except ModuleNotFoundError:
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
class SystemPathInclude:
"""
Resource class to temporary include system paths to `sys.paths`.
Usage:
```
paths = [pathlib.Path("/home/guest/my_python_scripts")]
with SystemPathInclude(paths) as t:
import my_module
reload(my_module)
```
It is possible to nest multiple SystemPathIncludes.
"""
def __init__(self, paths_to_add: List[pathlib.Path]):
# TODO: Check if all paths exist and are absolute.
self.__paths = paths_to_add
self.__original_sys_path: List[str] = []
def __enter__(self):
self.__original_sys_path = sys.path
new_sys_path = []
for path_to_add in self.__paths:
# Do not add paths that are already in the sys path.
# Report this to the logger as this might indicate wrong usage.
path_to_add_str = str(path_to_add)
if path_to_add_str in self.__original_sys_path:
logger.warn(f"{path_to_add_str} already added to `sys.path`")
continue
new_sys_path.append(path_to_add_str)
new_sys_path.extend(self.__original_sys_path)
sys.path = new_sys_path
def __exit__(self, exc_type, exc_value, exc_traceback):
sys.path = self.__original_sys_path

View File

@ -1,24 +0,0 @@
import bpy
class KITSU_PT_new_shot_panel(bpy.types.Panel): # TODO Remove (for testing only)
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_label = "New Shot"
bl_category = "New Shot"
def draw(self, context):
self.layout.operator("kitsu.build_new_shot")
classes = (KITSU_PT_new_shot_panel,)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)