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
56 changed files with 1163 additions and 2719 deletions

View File

@ -0,0 +1 @@
*.blend filter=lfs diff=lfs merge=lfs -text

View File

@ -0,0 +1 @@
*.blend1

View File

@ -7,28 +7,45 @@ blender-kitsu is a Blender Add-on to interact with Kitsu from within Blender. It
- [Table of Contents](#table-of-contents)
- [Installation](#installation)
- [How to get started](#how-to-get-started)
- [**Setup Login Data**](#setup-login-data)
- [**Setup Project Settings**](#setup-project-settings)
- [**Setup Animation Tools**](#setup-animation-tools)
- [**Setup Lookdev Tools**](#setup-lookdev-tools)
- [**Setup Media Search Paths**](#setup-media-search-paths)
- [**Setup Miscellaneous**](#setup-miscellaneous)
- [Features](#features)
- [Sequence Editor](#sequence-editor)
- [Metadata](#metadata)
- [Push](#push)
- [Pull](#pull)
- [Multi Edit](#multi-edit)
- [Shot as Image Sequence](#shot-as-image-sequence)
- [General Sequence Editor Tools](#general-sequence-editor-tools)
- [Context](#context)
- [Animation Tools](#animation-tools)
- [Lookdev Tools](#lookdev-tools)
- [Error System](#error-system)
- [Sequence Editor](#sequence-editor)
- [Metastrips](#metastrips)
- [Create a Metastrip](#create-a-metastrip)
- [Initialize a Shot](#initialize-a-shot)
- [Metadata](#metadata)
- [Push](#push)
- [Pull](#pull)
- [Multi Edit](#multi-edit)
- [Shot as Image Sequence](#shot-as-image-sequence)
- [Advanced Settings](#advanced-settings)
- [General Sequence Editor Tools](#general-sequence-editor-tools)
- [Context](#context)
- [Animation Tools](#animation-tools)
- [Lookdev Tools](#lookdev-tools)
- [Error System](#error-system)
- [Shot Builder](#shot-builder)
- [Features](#features)
- [Getting Started](#getting-started)
- [Shot Setup](#shot-setup)
- [Asset Setup](#asset-setup)
- [Kitsu Server](#kitsu-server)
- [Asset Index](#asset-index)
- [Example asset_index.json](#example-asset_indexjson)
- [Hooks Setup](#hooks-setup)
- [Editorial Exports](#editorial-exports)
- [Run Shot Builder](#run-shot-builder)
- [Development](#development)
- [Update Dependencies](#update-dependencies)
- [Troubleshoot](#troubleshoot)
- [Credits](#credits)
<!-- /TOC -->
blender-kitsu is a Blender Add-on to interact with Kitsu from within Blender. It also has features that are not directly related to Kitsu but support certain aspects of the Blender Studio Pipeline.
[Blender-Kitsu blogpost](https://studio.blender.org/blog/kitsu-addon-for-blender/)
## Table of Contents
@ -274,250 +291,83 @@ blender-kitsu has different checks that are performed during file load or during
![image info](/media/addons/blender_kitsu/error_animation.jpg)
## Shot Builder
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
Shot Builder is a Feature of the Blender Kitsu Add-on To automatically build shot files, using the data from Kitsu server and the file structures defined on the [Blender Studio](https://studio.blender.org/pipeline/naming-conventions/svn-folder-structure) website.
* Build blend files for a specific task and shot.
* Sync data back from work files to places like kitsu, or `edit.blend`.
### Features
- Saves a 'Shot' File for each of the Shot's Task Types on Kitsu Server.
- Automatically Names Scenes based on Shot and Task Type names
- Creates output collections for certain Task Types (anim, fx, layout, lighting, previz, storyboard)
- Links output collections between Task Types based on [Shot Assembly](https://studio.blender.org/pipeline/pipeline-overview/shot-production/shot-assembly) specifications
- Loads Editorial Export (defined in preferences) into Shot file's VSE area (if available)
- Loads Assets via `asset_index.json` file stored at `your_production/svn/pro/assets/asset_index.json`
- Executes a `hook.py` file stored at `your_production/svn/pro/assets/shot-builder/hooks.py`
### Design Principles
### Getting Started
#### Shot Setup
The Shot Builder requires shot data including Name, Frame Rate, and Duration to be stored on a Kitsu Server. Please follow the [Sequence Editor](#sequence-editor) guide to create metastrips and Push that data to the Kitsu server or follow the [Kitsu First Production](https://kitsu.cg-wire.com/first_production/) to manually enter this data into the Kitsu Server.
The main design principles are:
#### Asset Setup
##### Kitsu Server
The Shot Builder requires all Asset to be stored on the Kitsu Server with a [Metadata Column](https://kitsu.cg-wire.com/production_advanced/#create-custom-metadata-columns) with the exact name `slug` exactly matching the name of the asset's collection.
* 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.
Assets needs to be associated with each shot in your production. Please follow the [Kitsu Breakdown](https://kitsu.cg-wire.com/getting-started-production/) guide to Cast your assets to shots.
### Connectors
##### Asset Index
To match Assets File to the casting breakdown on the Kitsu server, we need to create an Asset Index. This is a json file that contains the mapping of the asset's name to the asset's filepath. Any collection Marked as an Asset in Blender in the directory `your_project/svn/pro/assets` will be added to this index.
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,
##### Example `asset_index.json`
```json
{
"CH-rain": {
"type": "Collection",
"filepath": "your_project/svn/pro/assets/chars/rain/rain.blend"
},
"CH-snow": {
"type": "Collection",
"filepath": "your_project/svn/pro/assets/chars/snow/snow.blend"
}
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.
To create/update the Asset Index:
1. Enter Asset Index directory `cd blender-studio-pipeline/scripts/index_assets`
2. Run using `./run_index_assets.py your_poduction` replace `your_production` with the path to your project's root directory
3. This will create an index file at `your_production/svn/pro/assets/asset_index.py`
### Setting up the tool
#### Hooks Setup
Shot Builder uses hooks to extend the functionality of the shot builder. To create a hook file
1. Open `Edit>Preferences>Add-Ons`
2. Search for the `Blender Kitsu` Add-On
3. In the `Blender Kitsu` Add-On preferences find the `Shot Builder` section
4. Run the Operator `Save Shot Builder Hook File`
5. Edit the file `your_project/svn/pro/assets/scripts/shot-builder/hooks.py` to customize your hooks.
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/
```
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)
### Usage
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")
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.
```
#### Editorial Exports
Shot Builder can load Exports from Editorial to the .blend's VSE for reference.
In the future other use cases will also be accessible, such as:
1. Open `Edit>Preferences>Add-Ons`
2. Search for the `Blender Kitsu` Add-On
3. In the `Blender Kitsu` Add-On preferences find the `Shot Builder` section
4. Set your `Editorial Export Directory` to `your_project/shared/editorial/export/`
5. Set your `Editorial File Pattern` to `your_project_v\d\d\d.mp4` where `\d` represents a digit. This pattern matches a file named `your_movie_v001.mp4`.
* 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.
#### Run Shot Builder
1. Open Blender
2. Select File>New
3. From dialogue box, select the desired Sequence/Shot/Task Type
4. Hit `ok` to run the tool. The tool will create a new file in the directory `your_project/svn/pro/shots/{sequence_name}/{shot_name}/{shot_name}+{task_type_name}.blend`
## Development
### Update Dependencies

View File

@ -116,7 +116,6 @@ def unregister():
lookdev.unregister()
playblast.unregister()
shot_builder.unregister()
LoggerLevelManager.restore_levels()

View File

@ -29,7 +29,7 @@ SHOT_DIR_NAME = "shots"
SEQ_DIR_NAME = "sequences"
ASSET_DIR_NAME = "assets"
FILE_DELIMITER = '-'
FILE_DELIMITER = "-"
ASSET_TASK_MAPPING = {
"geometry": "Geometry",
@ -90,3 +90,54 @@ RES_DIR_PATH = Path(os.path.abspath(__file__)).parent.joinpath("res")
SCENE_NAME_PLAYBLAST = "playblast_playback"
PLAYBLAST_DEFAULT_STATUS = "Todo"
###########################
# Shot Builder Properties
###########################
# TODO add documentation and move other shot builder props here
OUTPUT_COL_CREATE = {
"anim": True,
"comp": False,
"fx": True,
"layout": True,
"lighting": True,
"previz": True,
"rendering": False,
"smear_to_mesh": False,
"storyboard": True,
}
OUTPUT_COL_LINK_MAPPING = {
"anim": None,
"comp": ['anim', 'fx', 'lighting'],
"fx": ['anim', 'lighting'],
"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
}

View File

@ -269,6 +269,18 @@ def get_shots_enum_for_active_seq(
return _shot_enum_list
def get_shots_enum_for_seq(
self: bpy.types.Operator, context: bpy.types.Context, sequence: Sequence
) -> List[Tuple[str, str, str]]:
global _shot_enum_list
_shot_enum_list.clear()
_shot_enum_list.extend(
[(s.id, s.name, s.description or "") for s in sequence.get_all_shots()]
)
return _shot_enum_list
def get_assetypes_enum_list(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:
@ -343,6 +355,20 @@ def get_shot_task_types_enum(
return _task_types_shots_enum_list
def get_shot_task_types_enum_for_shot( # TODO Rename
self: bpy.types.Operator, context: bpy.types.Context, shot: Shot
) -> List[Tuple[str, str, str]]:
# TODO why do we have global variables here can't I just return the items directly? We clear the list every time how is this a cahe?
global _task_types_shots_enum_list
items = [(t.id, t.name, "") for t in shot.get_all_task_types()]
_task_types_shots_enum_list.clear()
_task_types_shots_enum_list.extend(items)
return _task_types_shots_enum_list
def get_all_task_statuses_enum(
self: bpy.types.Operator, context: bpy.types.Context
) -> List[Tuple[str, str, str]]:

View File

@ -40,7 +40,7 @@ from .auth.ops import (
)
from .context.ops import KITSU_OT_con_productions_load
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()
@ -339,12 +339,6 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences):
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()
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="")
box.row().prop(self, "shot_builder_armature_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.
box = layout.box()
@ -527,6 +521,11 @@ def addon_prefs_get(context: bpy.types.Context) -> bpy.types.AddonPreferences:
return context.preferences.addons["blender_kitsu"].preferences
def project_root_dir_get(context: bpy.types.Context):
addon_prefs = addon_prefs_get(context)
return Path(addon_prefs.project_root_dir).resolve()
def session_auth(context: bpy.types.Context) -> bool:
"""
Shortcut to check if zession is authorized

View File

@ -1,62 +1,13 @@
# ##### 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
from .anim_setup import ops as anim_setup_ops # TODO Fix Registraion
from .editorial import ops as editorial_ops # TODO Fix Registraion
# 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,
)
from . import ops
from .ui import topbar_file_new_draw_handler
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)
ops.register()
def unregister():
anim_setup_ops.unregister()
editorial_ops.unregister()
bpy.types.TOPBAR_MT_file_new.remove(topbar_file_new_draw_handler)
for cls in classes:
bpy.utils.unregister_class(cls)
ops.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

@ -0,0 +1,60 @@
import bpy
from .. import prefs
from pathlib import Path
import json
from ..types import Shot
from .core import link_and_override_collection, link_data_block
from .. import bkglobals
def get_asset_index_file() -> str:
svn_project_root_dir = prefs.project_root_dir_get(bpy.context)
asset_index_file = (
Path(svn_project_root_dir)
.joinpath("pro")
.joinpath("assets")
.joinpath("asset_index.json")
)
if asset_index_file.exists():
return asset_index_file.__str__()
def get_assset_index() -> dict:
asset_index_file = get_asset_index_file()
if asset_index_file is None:
return
return json.load(open(asset_index_file))
def get_shot_assets(
scene: bpy.types.Scene,
output_collection: bpy.types.Collection,
shot: Shot,
):
asset_index = get_assset_index()
if asset_index is None:
return
assets = shot.get_all_assets()
asset_slugs = [
asset.data.get("slug") for asset in assets if asset.data.get("slug") is not None
]
if asset_slugs == []:
print("No asset slugs found on Kitsu Server. Assets will not be loaded")
for key, value in asset_index.items():
if key in asset_slugs:
filepath = value.get('filepath')
data_type = value.get('type')
if bkglobals.ASSET_TYPE_TO_OVERRIDE.get(key.split('-')[0]):
if data_type != "Collection":
print(f"Cannot load {key} because it is not a collection")
continue
linked_collection = link_and_override_collection(
collection_name=key, file_path=filepath, scene=scene
)
print(f"'{key}': Succesfully Linked & Overriden")
else:
linked_collection = link_data_block(
file_path=filepath, data_block_name=key, data_block_type=data_type
)
print(f"'{key}': Succesfully Linked")
output_collection.children.link(linked_collection)

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

@ -0,0 +1,202 @@
import bpy
from pathlib import Path
from .. import bkglobals
from ..types import (
Sequence,
Shot,
TaskType,
)
from ..cache import Project
from .. import prefs
#################
# Constants
#################
CAMERA_NAME = 'CAM-camera'
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'):
"""
By default we set Cycles as the renderer.
"""
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)
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 get_3d_start(shot: Shot):
if shot.data and shot.data.get("3d_start"): # shot.data and
return int(shot.data.get("3d_start"))
else:
return bkglobals.FRAME_START
def set_frame_range(shot: Shot, scene: bpy.types.Scene):
start_3d = get_3d_start(shot)
scene.frame_start = start_3d
if not shot.nb_frames:
raise Exception(f"{shot.name} has missing frame duration information")
scene.frame_end = start_3d + shot.nb_frames - 1
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
"""
camera_col = link_data_block(file_path, collection_name, "Collection")
override_camera_col = camera_col.override_hierarchy_create(
scene, bpy.context.view_layer, do_fully_editable=True
)
scene.collection.children.unlink(camera_col)
# Make library override.
return override_camera_col
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 bkglobals.OUTPUT_COL_LINK_MAPPING.get(task_type_short_name) == None:
return
for short_name in bkglobals.OUTPUT_COL_LINK_MAPPING.get(task_type_short_name):
external_filepath = shot.get_shot_filepath(bpy.context, short_name)
if not Path(external_filepath).exists():
print(
f"Unable to link output collection for {Path(external_filepath).name}"
)
file_path = external_filepath.__str__()
colection_name = shot.get_output_collection_name(short_name)
link_data_block(file_path, colection_name, 'Collection')

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,9 +1,8 @@
import bpy
import re
from .. import prefs
from pathlib import Path
from typing import Set
from ... import prefs
from ... import cache
import re
from .core import get_3d_start
def editorial_export_get_latest(
@ -16,7 +15,7 @@ def editorial_export_get_latest(
if not latest_file:
return None
# Check if Kitsu server returned empty shot
if shot.get("id") == '':
if shot.id == '':
return None
strip_filepath = latest_file.as_posix()
strip_frame_start = addon_prefs.shot_builder_frame_offset
@ -41,8 +40,8 @@ def editorial_export_get_latest(
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_in = shot.data.get("frame_in")
frame_3d_start = get_3d_start(shot)
frame_3d_offset = frame_3d_start - addon_prefs.shot_builder_frame_offset
edit_export_offset = addon_prefs.edit_export_frame_offset

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,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

@ -0,0 +1,12 @@
from pathlib import Path
import bpy
def save_shot_builder_file(file_path: str):
"""Save Shot File within Folder of matching name.
Set Shot File to relative Paths."""
if Path(file_path).exists():
raise Exception(f"Cannot Overwrite Existing Shot File {file_path}")
dir_path = Path(file_path).parent
dir_path.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_mainfile(filepath=file_path, relative_remap=True)

View File

@ -0,0 +1,51 @@
import bpy
from blender_kitsu.shot_builder.hooks import hook
from blender_kitsu.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}")

View File

@ -1,8 +1,14 @@
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__)
@ -44,6 +50,8 @@ 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:
@ -60,10 +68,6 @@ class Hooks:
def __init__(self):
self._hooks: typing.List[HookFunction] = []
def register(self, func: HookFunction) -> None:
logger.info(f"registering hook '{func.__name__}'")
self._hooks.append(func)
def matches(
self,
hook: HookFunction,
@ -73,7 +77,6 @@ class Hooks:
) -> 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),
@ -86,32 +89,54 @@ class 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 _register_hook(func: types.FunctionType) -> None:
from .project import get_active_production
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__()]
with SystemPathInclude(paths) as _include:
try:
import hooks as production_hooks
production = get_active_production()
production.hooks.register(func)
importlib.reload(production_hooks)
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:
"""
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
_register_hook(module_item)
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 = DoNotMatch,
match_asset_type: MatchCriteriaType = DoNotMatch,
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.
@ -128,3 +153,41 @@ def hook(
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,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

@ -0,0 +1,271 @@
import bpy
from .. import bkglobals
from pathlib import Path
from typing import List, Any, Tuple, Set, cast
from .. import prefs, cache
from .core import (
set_render_engine,
link_camera_rig,
create_task_type_output_collection,
set_shot_scene,
set_resolution_and_fps,
set_frame_range,
link_task_type_output_collections,
remove_all_data,
)
from .editorial import editorial_export_get_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_save_shot_builder_hooks(bpy.types.Operator):
bl_idname = "kitsu.save_shot_builder_hooks"
bl_label = "Save Shot Builder Hook File"
bl_description = "Save hook.py file to `your_project/svn/pro/assets/scripts/shot-builder` directory. Hooks are used to customize shot builder behaviour."
def execute(self, context: bpy.types.Context):
addon_prefs = prefs.addon_prefs_get(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'}
root_dir = prefs.project_root_dir_get(context)
config_dir = root_dir.joinpath("pro/assets/scripts/shot-builder")
if not config_dir.exists():
config_dir.mkdir(parents=True)
hook_file_path = config_dir.joinpath("hooks.py")
if hook_file_path.exists():
self.report(
{'WARNING'},
"File already exists, cannot overwrite",
)
return {'CANCELLED'}
config_dir = Path(__file__).parent
example_hooks_path = config_dir.joinpath("hook_examples/hooks.py")
if not example_hooks_path.exists():
self.report(
{'ERROR'},
"Cannot find example hook file",
)
return {'CANCELLED'}
with example_hooks_path.open() as source:
# Read contents
contents = source.read()
# Write contents to target file
with hook_file_path.open('w') as target:
target.write(contents)
self.report({'INFO'}, f"Hook File saved to {hook_file_path}")
return {'FINISHED'}
class KITSU_OT_build_new_shot(bpy.types.Operator):
bl_idname = "kitsu.build_new_shot"
bl_label = "Build New Shot"
bl_description = "Build a New Shot file, based on infromation from KITSU Server"
bl_options = {"REGISTER"}
_timer = None
_built_shot = False
_add_vse_area = False
_file_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(
name="Sequence ID",
description="Sequence ID of the shot to build",
items=cache.get_sequences_enum_list,
)
shot_id: bpy.props.EnumProperty(
name="Shot ID",
description="Shot ID of the shot to build",
items=get_shots_for_seq,
)
task_type: bpy.props.EnumProperty(
name="Task",
description="Task to create the shot file for",
items=get_tasks_for_shot,
)
save_file: bpy.props.BoolProperty(
name="Save after building.",
description="Automatically save build file after 'Shot Builder' is complete.",
default=True,
)
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, "save_file")
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> Set[str]:
global active_project
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 directiory 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 _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 execute(self, context: bpy.types.Context):
# Get Properties
global active_project
seq = active_project.get_sequence(self.seq_id)
shot = active_project.get_shot(self.shot_id)
task_type = self._get_task_type_for_shot(context, shot)
task_type_short_name = task_type.get_short_name()
shot_file_path_str = shot.get_shot_filepath(context, task_type_short_name)
# Open Template File
replace_workspace_with_template(context, task_type_short_name)
# Set Up Scene + Naming
shot_task_name = shot.get_shot_task_name(task_type.get_short_name())
scene = set_shot_scene(context, shot_task_name)
remove_all_data()
set_resolution_and_fps(active_project, scene)
set_frame_range(shot, scene)
# Set Render Settings
if task_type_short_name == 'anim': # TODO get anim from a constant instead
set_render_engine(context.scene, 'BLENDER_WORKBENCH')
else:
set_render_engine(context.scene)
# Create Output Collection & Link Camera
if bkglobals.OUTPUT_COL_CREATE.get(task_type_short_name):
output_col = create_task_type_output_collection(
context.scene, shot, task_type
)
if task_type_short_name == 'anim':
link_camera_rig(context.scene, output_col)
# Load Assets
get_shot_assets(scene=scene, output_collection=output_col, shot=shot)
# Link External Output Collections
link_task_type_output_collections(shot, task_type)
if bkglobals.LOAD_EDITORIAL_REF.get(task_type_short_name):
editorial_export_get_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,
)
# Save File
if self.save_file:
save_shot_builder_file(file_path=shot_file_path_str)
self.report(
{"INFO"}, f"Successfully Built Shot:`{shot.name}` Task: `{task_type.name}`"
)
return {"FINISHED"}
classes = (KITSU_OT_build_new_shot, KITSU_OT_save_shot_builder_hooks)
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,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

@ -0,0 +1,59 @@
import bpy
from pathlib import Path
from .core import link_data_block
# TODO add ability for custom templates
def get_template_dir() -> Path:
return Path(__file__).absolute().parent.joinpath("templates")
def get_template_files() -> list[Path]:
dir = get_template_dir()
return list(dir.glob('*.blend'))
def get_template_for_task_type(task_type_short_name: str) -> Path:
for file in get_template_files():
if file.stem == task_type_short_name:
return file
def replace_workspace_with_template(
context: bpy.types.Context, task_type_short_name: str
):
file_path = get_template_for_task_type(task_type_short_name).resolve().absolute()
remove_prefix = "REMOVE-"
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
file_path_str = file_path.__str__()
with bpy.data.libraries.load(file_path_str) as (data_from, data_to):
for workspace in data_from.workspaces:
bpy.ops.wm.append(
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()
return True

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,27 +1,7 @@
# ##### 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 typing import *
from .operators import *
from typing import Any
def topbar_file_new_draw_handler(self: Any, context: bpy.types.Context) -> None:
layout = self.layout
op = layout.operator(SHOTBUILDER_OT_NewShotFile.bl_idname, text="Shot File")
op = layout.operator("kitsu.build_new_shot", text="Shot File")

View File

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

View File

@ -22,9 +22,11 @@ from __future__ import annotations
import inspect
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional, Union, Tuple, TypeVar
from pathlib import Path
import gazu
from .logger import LoggerFactory
from . import bkglobals
from . import prefs
logger = LoggerFactory.getLogger()
@ -558,6 +560,11 @@ class Shot(Entity):
def get_all_tasks(self) -> List[Task]:
return [Task.from_dict(t) for t in gazu.task.all_tasks_for_shot(asdict(self))]
def get_all_assets(self) -> List[Asset]:
return [
Asset.from_dict(t) for t in gazu.asset.all_assets_for_shot(asdict(self))
]
def get_sequence(self) -> Sequence:
return Sequence.from_dict(gazu.shot.get_sequence_from_shot(asdict(self)))
@ -565,6 +572,23 @@ class Shot(Entity):
gazu.shot.update_shot(asdict(self))
return self
def get_shot_task_name(self, task_type_short_name: str) -> str: #
return f"{self.name}{bkglobals.FILE_DELIMITER}{task_type_short_name}"
def get_output_collection_name(self, task_type_short_name: str) -> str:
return f"{self.get_shot_task_name(task_type_short_name)}{bkglobals.FILE_DELIMITER}output"
def get_shot_dir(self, context) -> str:
project_root_dir = prefs.project_root_dir_get(context)
all_shots_dir = project_root_dir.joinpath('pro').joinpath('shots')
seq = self.get_sequence()
shot_dir = all_shots_dir.joinpath(seq.name).joinpath(self.name)
return shot_dir.__str__()
def get_shot_filepath(self, context, task_type_short_name: str) -> str:
file_name = self.get_shot_task_name(task_type_short_name) + '.blend'
return Path(self.get_shot_dir(context)).joinpath(file_name).__str__()
def update_data(self, data: Dict[str, Any]) -> Shot:
gazu.shot.update_shot_data(asdict(self), data=data)
if not self.data:
@ -714,6 +738,11 @@ class TaskType(Entity):
if t["for_entity"] == "Sequence"
]
def get_short_name(self) -> str:
for key, value in bkglobals.SHOT_TASK_MAPPING.items():
if value == self.name:
return key
def __bool__(self) -> bool:
return bool(self.id)

View File

@ -0,0 +1,10 @@
# Index Assets
This tool will create a dictionary of all data-blocks (collections only) [marked as an Asset](https://docs.blender.org/manual/en/latest/files/asset_libraries/introduction.html#asset-create) and stores this information in a JSON file at `you_project/svn/pro/assets/asset_index.json`. This JSON File is used by other tools like the Blender Kitsu Add-On to quickly discover assets to use in the shot builder.
To run this tool use the following command, followed by the path to your project's root folder
```bash
./run_index_assets.py your_project/
```

View File

@ -0,0 +1,66 @@
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
import bpy
import json
import sys
def load_json_file(json_file_path: str) -> dict:
"""Finds JSON file with asset index if any exist
Args:
json_file_path (str): Path to existing JSON File
Returns:
dict: Dictionary with the existing JSON File, else blank
"""
asset_index_json = Path(json_file_path)
if asset_index_json.exists():
return json.load(open(asset_index_json))
return {}
def dump_json_file(asset_dict: dict, json_file_path: str) -> None:
"""Save Asset Index to JSON File at provided path
Args:
asset_dict (dict): Dictionary of Asset Items
json_file_path (str): Path to Save JSON File
"""
with open(json_file_path, 'w') as json_file:
json.dump(asset_dict, json_file, indent=4)
def find_save_assets():
"""Find all collections marked as asset in the current
.blend file, and add them to a dictionary, saved as a JSON"""
argv = sys.argv
json_file_path = argv[argv.index("--") + 1 :][0]
asset_dict = load_json_file(json_file_path)
for col in bpy.data.collections:
if col.asset_data:
print(f"Found Asset {col.name}")
asset_dict[col.name] = {
'type': type(col).bl_rna.name,
'filepath': bpy.data.filepath,
}
print('Asset Index Completed')
dump_json_file(asset_dict, json_file_path)
find_save_assets()

View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
import argparse
import os
import platform
import subprocess
import sys
def cancel_program(message: str) -> None:
"""Cancel Execution of this file"""
print(message)
sys.exit(0)
parser = argparse.ArgumentParser()
parser.add_argument(
"path",
help="Path to a file(s) or folder(s) on which to perform crawl. In format of '{my_project}/'",
)
def get_bbatch_script_path() -> str:
"""Returns path to script that runs with bbatch"""
dir = Path(__file__).parent.absolute()
return dir.joinpath("blender_index_assets.py").__str__()
def get_blender_path(project_path: Path) -> str:
"""Get the path to a project's blender executable
Args:
project_path (Path): Path Object, containing project's root path
Returns:
str: Path to blender executable as a string
"""
# TODO get this from the run_blender.py script instead (new logic needs tobe added to run_blender.py first)
local_blender_path = project_path.joinpath('local').joinpath('blender')
system_name = platform.system().lower()
blender_path_base = local_blender_path / system_name
if system_name == 'linux':
blender_path = blender_path_base / 'blender'
elif system_name == 'darwin':
blender_path = (
blender_path_base / 'Blender.app' / 'Contents' / 'MacOS' / 'Blender'
)
elif system_name == 'windows':
blender_path = blender_path_base / 'blender.exe'
return blender_path.absolute().__str__()
def index_assets():
"""Crawl the Asset Library of a provided Blender Studio Pipeline Project and
index all assets into a dictionary using a script executed by bbatch"""
args = parser.parse_args()
project_path = Path(args.path)
if not project_path.exists():
cancel_program("Provided Path does not exist")
asset_dir = (
project_path.joinpath("svn").joinpath("pro").joinpath("assets").absolute()
)
if not asset_dir.exists():
cancel_program("Asset Library does not exist at provided path")
asset_dir_path = asset_dir.__str__()
json_file_path = asset_dir.joinpath("asset_index.json").__str__()
script_path = get_bbatch_script_path()
project_blender = get_blender_path(project_path)
print(project_blender)
os.chdir("../bbatch")
cmd_list = (
'python',
'-m',
'bbatch',
asset_dir_path,
'--ask',
'--exec',
project_blender,
"--nosave",
"--recursive",
'--script',
script_path,
"--args",
f'{json_file_path}',
)
process = subprocess.Popen(cmd_list, shell=False)
if process.wait() != 0:
cancel_program(f"Asset Index Failed!")
print("Asset Index Completed Successfully")
print(f"Index File: '{json_file_path}'")
return 0
if __name__ == "__main__":
index_assets()