diff --git a/anim_setup/.gitignore b/anim_setup/.gitignore deleted file mode 100644 index d197a693..00000000 --- a/anim_setup/.gitignore +++ /dev/null @@ -1,112 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# dotenv -.env - -# virtualenv -.venv -.venv* -venv/ -ENV/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ - -# IDE settings -.vscode/ - -# utility bat files: -*jump_in_venv.bat - -#local tests -tests/local* \ No newline at end of file diff --git a/anim_setup/README.md b/anim_setup/README.md deleted file mode 100644 index dd849ec9..00000000 --- a/anim_setup/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# anim-setup -anim-setup is a Blender Add-on that automates the setup of animation scenes for the Sprite-Fright project. -## Installation -Download or clone this repository. -In the root project folder you will find the 'anim_setup' folder. Place this folder in your Blender addons directory or create a sym link to it. - -After install you need to configure the addon in the addon preferences. - -## Features -The addon relies on the correct naming of asset and camera actions in the corresponding previs file of the shot. -Check the Animation Setup Checklist. - -Operators of the addon: -- Setup Workspace for animation -- Load latest edit from edit export directory -- Import camera action from the previs file -- Import actions for found assets from previs file -- Shift animation of camera and asset actions to start at layout cut in -- Create missing actions for found assets in scene - -## Development -In the project root you will find a `pyproject.toml` and `peotry.lock` file. -With `poetry` you can easily generate a virtual env for the project which should get you setup quickly. -Basic Usage: https://python-poetry.org/docs/basic-usage/ diff --git a/anim_setup/__init__.py b/anim_setup/__init__.py deleted file mode 100644 index 6ee4240b..00000000 --- a/anim_setup/__init__.py +++ /dev/null @@ -1,57 +0,0 @@ -import bpy - -from . import asglobals -from . import prefs -from . import kitsu -from . import props -from . import opsdata -from . import ops -from . import ui -from .log import LoggerFactory - -logger = LoggerFactory.getLogger(__name__) - -bl_info = { - "name": "Anim Setup", - "author": "Paul Golter", - "description": "Blender addon to setup animation scenes for the spritefright project", - "blender": (3, 0, 0), - "version": (0, 1, 0), - "location": "View3D", - "warning": "", - "doc_url": "", - "tracker_url": "", - "category": "Generic", -} - -_need_reload = "ops" in locals() - -if _need_reload: - import importlib - - asglobals = importlib.reload(asglobals) - prefs = importlib.reload(prefs) - kitsu = importlib.reload(kitsu) - props = importlib.reload(props) - opsdata = importlib.reload(opsdata) - ops = importlib.reload(ops) - ui = importlib.reload(ui) - - -def register(): - prefs.register() - props.register() - ops.register() - ui.register() - logger.info("Registered anim-setup") - - -def unregister(): - ui.unregister() - ops.unregister() - props.unregister() - prefs.unregister() - - -if __name__ == "__main__": - register() diff --git a/anim_setup/asglobals.py b/anim_setup/asglobals.py deleted file mode 100644 index f69ee76b..00000000 --- a/anim_setup/asglobals.py +++ /dev/null @@ -1,17 +0,0 @@ -PROJECT_NAME = "SpriteFright" -PROJECT_ID = "fc77c0b9-bb76-41c3-b843-c9b156f9b3ec" -ACTION_ASSETS = [ - "CH-ellie", - "CH-jay", - "CH-phil", - "CH-rex", - "CH-elder_sprite", - "CH-victoria", - "CH-bird", - "PR-bbq_grill", - "PR-boombox", - "PR-tree_chasm", - "PR-log_bridge_trunk" -] -MULTI_ASSETS = ["CH-sprite"] -HIDE_COLLS = ["mushrooms_center", "treetop_leaves"] diff --git a/anim_setup/kitsu.py b/anim_setup/kitsu.py deleted file mode 100644 index 2463df8d..00000000 --- a/anim_setup/kitsu.py +++ /dev/null @@ -1,315 +0,0 @@ -from __future__ import annotations -import requests - -from dataclasses import asdict, dataclass, field -from typing import Any, Dict, List, Optional, Union - -from .log import LoggerFactory - -logger = LoggerFactory.getLogger() - - -class KitsuException(Exception): - pass - - -class KitsuConnector: - def __init__(self, preferences: "AS_AddonPreferences"): - self._preferences = preferences - self.__access_token = "" - self.__validate() - self.__authorize() - - def __validate(self) -> None: - self._preferences.kitsu._validate() - - def __authorize(self) -> None: - kitsu_pref = self._preferences.kitsu - backend = kitsu_pref.backend - email = kitsu_pref.email - password = kitsu_pref.password - - logger.info(f"authorize {email} against {backend}") - response = requests.post( - url=f"{backend}/auth/login", data={"email": email, "password": password} - ) - if response.status_code != 200: - self.__access_token = "" - raise KitsuException( - f"unable to authorize (status code={response.status_code})" - ) - json_response = response.json() - self.__access_token = json_response["access_token"] - - def api_get(self, api: str) -> Any: - kitsu_pref = self._preferences.kitsu - backend = kitsu_pref.backend - - response = requests.get( - url=f"{backend}{api}", - headers={"Authorization": f"Bearer {self.__access_token}"}, - ) - if response.status_code != 200: - raise KitsuException( - f"unable to call kitsu (api={api}, status code={response.status_code})" - ) - return response.json() - - @classmethod - def fetch_first( - cls, json_response: Dict[str, Any], filter: Dict[str, Any] - ) -> Optional[Dict[str, Any]]: - - if not isinstance(json_response, list): - raise ValueError( - f"Failed to fetch one, excpected list object: {json_response}" - ) - - for item in json_response: - matches = 0 - for f in filter: - if f in item and item[f] == filter[f]: - matches += 1 - - if matches == len(filter): - return item - - logger.error("Filter had no match %s on json response.", str(filter)) - return None - - @classmethod - def fetch_all( - cls, json_response: Dict[str, Any], filter: Dict[str, Any] - ) -> List[Dict[str, Any]]: - - if not isinstance(json_response, list): - raise ValueError( - f"Failed to fetch all, excpected list object: {json_response}" - ) - - valid_items: List[Dict[str, Any]] = [] - - for item in json_response: - matches = 0 - for f in filter: - if f in item and item[f] == filter[f]: - matches += 1 - - if matches == len(filter): - valid_items.append(item) - - return valid_items - - -class ProjectList(KitsuConnector): - """ - Class to get object oriented representation of backend productions data structure. - """ - - def __init__(self): - self._projects: List[Project] = [] - self._init_projects() - - @property - def names(self) -> List[str]: - return [p.name for p in self._projects] - - @property - def projects(self) -> List[Project]: - return self._projects - - def _init_projects(self) -> None: - api_url = "data/projects" - - for project in self.api_get(api_url): - self._projects.append(Project(**project)) - - -@dataclass -class Project(KitsuConnector): - """ - Class to get object oriented representation of backend project data structure. - Can shortcut some functions from gazu api because active project is given through class instance. - Has multiple constructor functions (by_name, by_id, init>by_dict) - """ - - id: str = "" - created_at: str = "" - updated_at: str = "" - name: str = "" - code: Optional[str] = None - description: Optional[str] = None - shotgun_id: Optional[str] = None - data: None = None - has_avatar: bool = False - fps: Optional[str] = None - ratio: Optional[str] = None - resolution: Optional[str] = None - production_type: str = "" - start_date: Optional[str] = None - end_date: Optional[str] = None - man_days: Optional[str] = None - nb_episodes: int = 0 - episode_span: int = 0 - project_status_id: str = "" - type: str = "" - project_status_name: str = "" - file_tree: Dict[str, Any] = field(default_factory=dict) - team: List[Any] = field(default_factory=list) - asset_types: List[Any] = field(default_factory=list) - task_types: List[Any] = field(default_factory=list) - task_statuses: List[Any] = field(default_factory=list) - - @classmethod - def by_id(cls, connector: KitsuConnector, project_id: str) -> Project: - api_url = f"data/projects/{project_id}" - project_dict = connector.api_get(api_url) - return cls(**project_dict) - - # SEQUENCES - # --------------- - - def get_sequence(self, connector: KitsuConnector, seq_id: str) -> Sequence: - return Sequence.by_id(connector, seq_id) - - def get_sequence_by_name( - self, connector: KitsuConnector, seq_name: str - ) -> Optional[Sequence]: - return Sequence.by_name(connector, self, seq_name) - - def get_sequences_all(self, connector: KitsuConnector) -> List[Sequence]: - api_url = f"data/projects/{self.id}/sequences" - seq_dicts = connector.api_get(api_url) - - sequences = [Sequence(**s) for s in seq_dicts] - return sorted(sequences, key=lambda x: x.name) - - # SHOT - # --------------- - - def get_shot(self, connector: KitsuConnector, shot_id: str) -> Shot: - return Shot.by_id(connector, shot_id) - - def get_shots_all(self, connector: KitsuConnector) -> List[Shot]: - api_url = f"data/projects/{self.id}/shots" - shot_dicts = connector.api_get(api_url) - - shots = [Shot(**s) for s in shot_dicts] - return sorted(shots, key=lambda x: x.name) - - def get_shot_by_name( - self, connector: KitsuConnector, sequence: Sequence, name: str - ) -> Optional[Shot]: - all_shots = self.get_shots_all(connector) - return Shot.by_name(connector, sequence, name) - - def __bool__(self): - return bool(self.id) - - -@dataclass -class Sequence(KitsuConnector): - """ - Class to get object oriented representation of backend sequence data structure. - Has multiple constructor functions (by_name, by_id, init>by_dict) - """ - - id: str = "" - created_at: str = "" - updated_at: str = "" - name: str = "" - code: Optional[str] = None - description: Optional[str] = None - shotgun_id: Optional[str] = None - canceled: bool = False - nb_frames: Optional[int] = None - project_id: str = "" - entity_type_id: str = "" - parent_id: str = "" - source_id: Optional[str] = None - preview_file_id: Optional[str] = None - data: Optional[Dict[str, Any]] = None - type: str = "" - project_name: str = "" - - @classmethod - def by_id(cls, connector: KitsuConnector, seq_id: str) -> Sequence: - api_url = f"data/sequences/{seq_id}" - seq_dict = connector.api_get(seq_id) - return cls(**seq_dict) - - @classmethod - def by_name( - cls, connector: KitsuConnector, project: Project, seq_name: str - ) -> Optional[Sequence]: - api_url = f"data/projects/{project.id}/sequences" - seq_dicts = connector.api_get(api_url) - seq_dict = connector.fetch_first(seq_dicts, {"name": seq_name}) - - # Can be None if name not found. - if not seq_dict: - return None - - return cls(**seq_dict) - - def __bool__(self): - return bool(self.id) - - -@dataclass -class Shot(KitsuConnector): - """ - Class to get object oriented representation of backend shot data structure. - Has multiple constructor functions (by_name, by_id, init>by_dict - """ - - id: str = "" - created_at: str = "" - updated_at: str = "" - name: str = "" - canceled: bool = False - code: Optional[str] = None - description: Optional[str] = None - entity_type_id: str = "" - episode_id: Optional[str] = None - episode_name: str = "" - fps: str = "" - frame_in: str = "" - frame_out: str = "" - nb_frames: int = 0 - parent_id: str = "" - preview_file_id: Optional[str] = None - project_id: str = "" - project_name: str = "" - sequence_id: str = "" - sequence_name: str = "" - source_id: Optional[str] = None - shotgun_id: Optional[str] = None - type: str = "" - data: Dict[str, Any] = field(default_factory=dict) - tasks: List[Dict[str, Any]] = field(default_factory=list) - - @classmethod - def by_id(cls, connector: KitsuConnector, shot_id: str) -> Shot: - api_url = f"data/shots/{shot_id}" - shot_dict = connector.api_get(shot_id) - return cls(**shot_dict) - - @classmethod - def by_name( - cls, connector: KitsuConnector, sequence: Sequence, shot_name: str - ) -> Optional[Shot]: - api_url = f"data/projects/{sequence.project_id}/shots" - shot_dicts = connector.api_get(api_url) - shot_dict = connector.fetch_first( - shot_dicts, {"parent_id": sequence.id, "name": shot_name} - ) - - # Can be None if name not found. - if not shot_dict: - return None - - return cls(**shot_dict) - - def __bool__(self): - return bool(self.id) diff --git a/anim_setup/log.py b/anim_setup/log.py deleted file mode 100644 index de426b41..00000000 --- a/anim_setup/log.py +++ /dev/null @@ -1,16 +0,0 @@ -import logging -import sys -from typing import List, Tuple - - -class LoggerFactory: - - """ - Utility class to streamline logger creation - """ - - @staticmethod - def getLogger(name=__name__): - name = name - logger = logging.getLogger(name) - return logger diff --git a/anim_setup/ops.py b/anim_setup/ops.py deleted file mode 100644 index 53143969..00000000 --- a/anim_setup/ops.py +++ /dev/null @@ -1,835 +0,0 @@ -import re -from pathlib import Path -import types -from typing import Container, Dict, List, Set, Optional - -import bpy - -from .log import LoggerFactory -from .kitsu import KitsuConnector, Shot, Project, Sequence -from . import opsdata, prefs, asglobals - -logger = LoggerFactory.getLogger() - - -def ui_redraw() -> None: - """Forces blender to redraw the UI.""" - for screen in bpy.data.screens: - for area in screen.areas: - area.tag_redraw() - - -class AS_OT_create_actions(bpy.types.Operator): - bl_idname = "as.create_action" - bl_label = "Create action" - bl_description = ( - "Creates action for all found assets that have no assigned yet. " - "Names them following the blender-studio convention" - ) - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - act_coll = context.view_layer.active_layer_collection.collection - return bool(bpy.data.filepath and act_coll) - - def execute(self, context: bpy.types.Context) -> Set[str]: - assigned: List[bpy.types.Action] = [] - created: List[bpy.types.Action] = [] - failed: List[bpy.types.Collection] = [] - collections = opsdata.get_valid_collections(context) - exists: List[bpy.types.Collection] = [] - - if not collections: - self.report({"WARNING"}, "No valid collections available") - return {"CANCELLED"} - - for coll in collections: - print("\n") - rig = opsdata.find_rig(coll) - - if not rig: - logger.warning(f"{coll.name} contains no rig.") - failed.append(coll) - continue - - # Create animation data if not existent. - if not rig.animation_data: - rig.animation_data_create() - logger.info("%s created animation data", rig.name) - - # If action already exists check for fake user and then continue. - if rig.animation_data.action: - logger.info("%s already has an action assigned", rig.name) - - if not rig.animation_data.action.use_fake_user: - rig.animation_data.action.use_fake_user = True - logger.info("%s assigned existing action fake user", rig.name) - exists.append(coll) - continue - - # Create new action. - action_name_new = opsdata.gen_action_name(coll) - try: - action = bpy.data.actions[action_name_new] - except KeyError: - action = bpy.data.actions.new(action_name_new) - logger.info("Created action: %s", action.name) - created.append(action) - else: - logger.info("Action %s already exists. Will take that.", action.name) - - # Assign action. - rig.animation_data.action = action - logger.info("%s assigned action %s", rig.name, action.name) - - # Add fake user. - action.use_fake_user = True - assigned.append(action) - - self.report( - {"INFO"}, - "Actions: Created %s | Assigned %s | Exists %s | Failed %s" - % (len(created), len(assigned), len(exists), len(failed)), - ) - return {"FINISHED"} - - -class AS_OT_setup_workspaces(bpy.types.Operator): - bl_idname = "as.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]: - - # Remove non anim workspaces. - for ws in bpy.data.workspaces: - if ws.name != "Animation": - bpy.ops.workspace.delete({"workspace": ws}) - - self.report({"INFO"}, "Deleted non Animation workspaces") - - return {"FINISHED"} - - -class AS_OT_load_latest_edit(bpy.types.Operator): - bl_idname = "as.load_latest_edit" - bl_label = "Load edit" - bl_description = ( - "Loads latest edit from shot_preview_folder " - "Shifts edit so current shot starts at 3d_in metadata shot key from Kitsu" - ) - - @classmethod - def can_load_edit(cls, context: bpy.types.Context) -> bool: - """Check if shared dir and VSE area are available""" - addon_prefs = prefs.addon_prefs_get(context) - edit_export_path = Path(addon_prefs.edit_export_path) - - # Needs to be run in sequence editor area - # TODO: temporarily create a VSE area if not available. - area_override = None - for area in bpy.context.screen.areas: - if area.type == "SEQUENCE_EDITOR": - area_override = area - - return bool(area_override and edit_export_path) - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return cls.can_load_edit(context) - - @classmethod - def description(cls, context, properties): - if cls.can_load_edit(context): - return "Load latest edit from shared folder" - else: - return "Shared folder not set, or VSE area not available in this workspace" - - def execute(self, context: bpy.types.Context) -> Set[str]: - - addon_prefs = prefs.addon_prefs_get(context) - edit_export_path = Path(addon_prefs.edit_export_path) - strip_channel = 1 - latest_file = self._get_latest_edit(context) - if not latest_file: - self.report( - {"ERROR"}, f"Found no edit file in: {edit_export_path.as_posix()}" - ) - strip_filepath = latest_file.as_posix() - strip_frame_start = 101 - - # Needs to be run in sequence editor area. - area_override = None - for area in bpy.context.screen.areas: - if area.type == "SEQUENCE_EDITOR": - area_override = area - - if not area_override: - self.report({"ERROR"}, "No sequence editor are found") - return {"CANCELLED"} - - override = bpy.context.copy() - override["area"] = area_override - - bpy.ops.sequencer.movie_strip_add( - override, - filepath=strip_filepath, - relative_path=False, - frame_start=strip_frame_start, - channel=strip_channel, - fit_method="FIT", - ) - - # Get sequence name. - seqname = opsdata.get_sequence_from_file() - if not seqname: - self.report({"ERROR"}, "Failed to retrieve seqname from current file.") - return {"CANCELLED"} - - # Get shotname. - shotname = opsdata.get_shot_name_from_file() - if not shotname: - self.report({"ERROR"}, "Failed to retrieve shotname from current file.") - return {"CANCELLED"} - - # Setup connector and get data from kitsu. - connector = KitsuConnector(addon_prefs) - project = Project.by_id(connector, addon_prefs.kitsu.project_id) - sequence = project.get_sequence_by_name(connector, seqname) - - if not sequence: - self.report({"ERROR"}, f"Failed to find {seqname} on kitsu.") - return {"CANCELLED"} - - shot = project.get_shot_by_name(connector, sequence, shotname) - - if not shot: - self.report({"ERROR"}, f"Failed to find shot {shotname} on kitsu.") - return {"CANCELLED"} - - # Update shift frame range prop. - frame_in = shot.data["frame_in"] - frame_out = shot.data["frame_out"] - frame_3d_in = shot.data["3d_in"] - frame_3d_offset = frame_3d_in - 101 - - if not frame_in: - self.report( - {"ERROR"}, f"On kitsu 'frame_in' is not defined for shot {shotname}." - ) - return {"CANCELLED"} - - # Set sequence strip start kitsu data. - for strip in context.scene.sequence_editor.sequences_all: - strip.frame_start = -frame_in + (strip_frame_start * 2) + frame_3d_offset - - self.report({"INFO"}, f"Loaded latest edit: {latest_file.name}") - - return {"FINISHED"} - - def _get_latest_edit(self, context: bpy.types.Context): - addon_prefs = prefs.addon_prefs_get(context) - - edit_export_path = Path(addon_prefs.edit_export_path) - - files_list = [ - f - for f in edit_export_path.iterdir() - if f.is_file() and self._is_valid_edit_name(f.name) - ] - files_list = sorted(files_list, reverse=True) - - return files_list[0] - - def _is_valid_edit_name(self, filename: str) -> bool: - pattern = r"sf-edit-v\d\d\d.mp4" - - match = re.search(pattern, filename) - if match: - return True - return False - - -class AS_OT_import_camera(bpy.types.Operator): - bl_idname = "as.import_camera" - bl_label = "Import Camera" - bl_description = "Imports camera rig and makes library override" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - addon_prefs = prefs.addon_prefs_get(context) - return bool(addon_prefs.is_project_root_valid and bpy.data.filepath) - - def execute(self, context: bpy.types.Context) -> Set[str]: - - addon_prefs = prefs.addon_prefs_get(context) - - # Import camera rig and make override. - camera_rig_path = addon_prefs.camera_rig_path - if not camera_rig_path: - self.report({"ERROR"}, "Failed to import camera rig") - return {"CANCELLED"} - - cam_lib_coll = opsdata.import_data_from_lib( - "collections", - "CA-camera_rig", - camera_rig_path, - ) - opsdata.instance_coll_to_scene_and_override(context, cam_lib_coll) - cam_coll = bpy.data.collections[cam_lib_coll.name, None] - - self.report({"INFO"}, f"Imported camera: {cam_coll.name}") - return {"FINISHED"} - - -class AS_OT_import_camera_action(bpy.types.Operator): - bl_idname = "as.import_camera_action" - bl_label = "Import Camera Action" - bl_description = ( - "Imports camera action of previs file that matches current shot and assigns it" - ) - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - addon_prefs = prefs.addon_prefs_get(context) - return bool(addon_prefs.is_project_root_valid and bpy.data.filepath) - - def execute(self, context: bpy.types.Context) -> Set[str]: - - try: - cam_coll = bpy.data.collections["CA-camera_rig", None] - except KeyError: - self.report({"ERROR"}, f"Camera collection CA-camera_rig is not imported") - return {"CANCELELD"} - - # Import camera action from previz file. - - # Get shotname and previs filepath. - shotname = opsdata.get_shot_name_from_file() - if not shotname: - self.report({"ERROR"}, "Failed to retrieve shotname from current file.") - return {"CANCELLED"} - - previs_path = opsdata.get_previs_file(context) - if not previs_path: - self.report({"ERROR"}, "Failed to find previz file") - return {"CANCELLED"} - - # Check if cam action name exists in previs library. - cam_action_name_new = opsdata.get_cam_action_name_from_lib( - shotname, previs_path - ) - if not cam_action_name_new: - self.report( - {"ERROR"}, - f"Camera action: {cam_action_name_new} not found in lib: {previs_path.name}", - ) - return {"CANCELLED"} - - # Import cam action data block. - cam_action = opsdata.import_data_from_lib( - "actions", cam_action_name_new, previs_path, link=False - ) - - # Find rig to assing action to. - rig = opsdata.find_rig(cam_coll) - if not rig: - self.report({"WARNING"}, f"{cam_coll.name} contains no rig.") - return {"CANCELLED"} - - # Assign action. - rig.animation_data.action = cam_action - logger.info("%s assigned action %s", rig.name, cam_action.name) - - # Add fake user. - cam_action.use_fake_user = True - - # Ensure version suffix to action data bloc. - opsdata.ensure_name_version_suffix(cam_action) - - self.report({"INFO"}, f"{rig.name} imported camera action: {cam_action.name}") - return {"FINISHED"} - - -class AS_OT_import_asset_actions(bpy.types.Operator): - """Imports asset action of previs file that matches current shot and assigns it""" - - bl_idname = "as.import_asset_actions" - bl_label = "Import Asset Actions" - bl_description = ( - "For each found asset tries to find action in previs file. " - "Imports it to current file, renames it, adds fake user and assigns it" - ) - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - addon_prefs = prefs.addon_prefs_get(context) - return bool(addon_prefs.is_project_root_valid and bpy.data.filepath) - - def execute(self, context: bpy.types.Context) -> Set[str]: - - succeeded = [] - failed = [] - actions_imported = [] - renamed_actions = [] - - # Get shotname and previs filepath. - shotname = opsdata.get_shot_name_from_file() - if not shotname: - self.report({"ERROR"}, "Failed to retrieve shotname from current file.") - return {"CANCELLED"} - - previs_path = opsdata.get_previs_file(context) - if not previs_path: - self.report({"ERROR"}, "Failed to find previz file") - return {"CANCELLED"} - - # Check if cam action name exists in previs library. - action_candidates: Dict[str, List[str]] = {} - asset_colls = [] - - with bpy.data.libraries.load( - previs_path.as_posix(), relative=True, link=False - ) as ( - data_from, - data_to, - ): - - for asset in asglobals.ACTION_ASSETS: - - # Check if asset is in current scene. - try: - coll = bpy.data.collections[asset] - except KeyError: - # can continue here if not in scene we - # cant load action anyway - continue - else: - logger.info("Found asset in scene: %s", coll.name) - asset_colls.append(coll) - - # Find if actions exists for that asset in previs file. - asset_name = opsdata.find_asset_name(asset) - for action in data_from.actions: - if action.startswith(f"ANI-{asset_name}."): - - # Create key if not existent yet. - if asset not in action_candidates: - action_candidates[asset] = [] - - # Append action to that asset. - action_candidates[asset].append(action) - - # Load and assign actions for asset colls. - for coll in asset_colls: - - # Find rig. - rig = opsdata.find_rig(coll) - if not rig: - logger.warning("%s contains no rig.", coll.name) - continue - - # Check if action was found in previs file for that asset. - if not coll.name in action_candidates: - logger.warning("%s no action found in previs file", coll.name) - continue - else: - logger.info( - "%s found actions in previs file: %s", - asset, - str(action_candidates[coll.name]), - ) - - # Check if multiple actions are in the prvis file for that asset. - if len(action_candidates[coll.name]) > 1: - logger.warning( - "%s Multiple actions found in previs file: %s", - coll.name, - str(action_candidates[coll.name]), - ) - continue - - # Import action from previs file. - actions = action_candidates[coll.name] - action = opsdata.import_data_from_lib( - "actions", actions[0], previs_path, link=False - ) - if not action: - continue - - actions_imported.append(action) - - # Create animation data if not existent. - if not rig.animation_data: - rig.animation_data_create() - logger.info("%s created animation data", rig.name) - - # Assign action. - rig.animation_data.action = action - logger.info("%s assigned action %s", rig.name, action.name) - - # Add fake user. - action.use_fake_user = True - - # Rename actions. - action_name_new = opsdata.gen_action_name(coll) - try: - action_existing = bpy.data.actions[action_name_new] - except KeyError: - # Action does not exists can rename. - old_name = action.name - action.name = action_name_new - logger.info("Renamed action: %s to %s", old_name, action.name) - renamed_actions.append(action) - else: - # Action name already exists in this scene. - logger.info( - "Failed to rename action action %s to %s. Already exists", - action.name, - action_name_new, - ) - continue - - self.report( - {"INFO"}, - f"Found Assets: {len(asset_colls)} | Imported Actions: {len(actions_imported)} | Renamed Actions: {len(renamed_actions)}", - ) - return {"FINISHED"} - - -class AS_OT_import_multi_assets(bpy.types.Operator): - bl_idname = "as.import_multi_assets" - bl_label = "Import Multi Assets" - bl_description = ( - "For each found multi asset tries to find action in previs file. " - "Imports it to current file, renames it, adds fake user and assigns it" - ) - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - addon_prefs = prefs.addon_prefs_get(context) - return bool(addon_prefs.is_project_root_valid and bpy.data.filepath) - - def execute(self, context: bpy.types.Context) -> Set[str]: - actions_imported = [] - new_colls = [] - - # Get shotname and previs filepath. - shotname = opsdata.get_shot_name_from_file() - if not shotname: - self.report({"ERROR"}, "Failed to retrieve shotname from current file.") - return {"CANCELLED"} - - previs_path = opsdata.get_previs_file(context) - if not previs_path: - self.report({"ERROR"}, "Failed to find previz file") - return {"CANCELLED"} - - # Check if cam action name exists in previs library. - action_candidates: Dict[str, List[str]] = {} - asset_colls: List[bpy.types.Collection] = [] - - with bpy.data.libraries.load( - previs_path.as_posix(), relative=True, link=False - ) as ( - data_from, - data_to, - ): - data_from_actions: List[str] = data_from.actions - data_from_actions.sort() - - # Find all sprites actions. - for asset in asglobals.MULTI_ASSETS: - # Check if asset is in current scene. - try: - coll = bpy.data.collections[asset] - except KeyError: - # Can continue here if not in scene we - # cant load action anyway. - continue - else: - logger.info("Found asset in scene: %s", coll.name) - asset_colls.append(coll) - - # Find if actions exists for that asset in previs file. - asset_name = opsdata.find_asset_name(asset) - for action in data_from_actions: - if action.startswith(f"ANI-{asset_name}"): - - # Create key if not existent yet. - if asset not in action_candidates: - action_candidates[asset] = [] - - # Append action to that asset. - action_candidates[asset].append(action) - - # Load and assign actions for asset colls. - color_tag: str = "" - for coll in asset_colls: - - # Check if action was found in previs file for that asset. - if not coll.name in action_candidates: - logger.warning("%s no action found in previs file", coll.name) - continue - else: - logger.info( - "%s found actions in previs file: %s", - asset, - str(action_candidates[coll.name]), - ) - - # Create duplicate for each action. - for idx, action_candidate in enumerate(action_candidates[coll.name]): - - # First index use existing collection that was already created by shot builder. - if idx == 0: - new_coll = bpy.data.collections[asset, None] - logger.info("First index will use existing coll: %s", new_coll.name) - color_tag = new_coll.color_tag # Take color from first collection. - else: - ref_coll = opsdata.get_ref_coll(coll) - new_coll = ref_coll.override_hierarchy_create( - context.scene, context.view_layer, reference=coll - ) - new_coll.color_tag = color_tag - logger.info("Created new override collection: %s", new_coll.name) - new_colls.append(new_coll) - - # Find rig of new coll. - rig = opsdata.find_rig(new_coll) - if not rig: - logger.warning("%s contains no rig.", coll.name) - continue - - # Import action. - action = opsdata.import_data_from_lib( - "actions", action_candidate, previs_path, link=False - ) - if not action: - continue - - actions_imported.append(action) - - # Create animation data if not existent. - if not rig.animation_data: - rig.animation_data_create() - logger.info("%s created animation data", rig.name) - - # Assign action. - rig.animation_data.action = action - logger.info("%s assigned action %s", rig.name, action.name) - - self.report( - {"INFO"}, - f"Found Assets: {len(asset_colls)} | Imported Actions: {len(actions_imported)} | New collections: {len(new_colls)}", - ) - return {"FINISHED"} - - -class AS_OT_shift_anim(bpy.types.Operator): - bl_idname = "as.shift_anim" - bl_label = "Shift Anim" - bl_description = ( - "Shifts the animation of found assets by number of frames. " - "It also shifts the camera animation as well as its modifier values" - ) - - multi_assets: bpy.props.BoolProperty(name="Do Multi Assets") - - def execute(self, context: bpy.types.Context) -> Set[str]: - # Define the frame offset by: - # Subtracting the layout cut in frame (to set the 0) - # Adding 101 (the animation start for a shot) - # For example, layout frame 520 becomes frames_offset -520 + 101 = -419. - - frames_offset = -context.scene.anim_setup.layout_cut_in + 101 - rigs: List[bpy.types.Armature] = [] - - if not self.multi_assets: - # Get cam coll. - try: - rig = bpy.data.objects["RIG-camera", None] - except KeyError: - logger.warning("Failed to find camera object 'RIG-camera'") - else: - rigs.append(rig) - - # Find assets. - for asset in asglobals.ACTION_ASSETS: - - # Check if asset is in current scene. - try: - coll = bpy.data.collections[asset] - except KeyError: - # Can continue here if not in scene we - # cant load action anyway. - continue - else: - logger.info("Found asset in scene: %s", coll.name) - # Find rig. - rig = opsdata.find_rig(coll) - if not rig: - logger.warning("%s contains no rig.", coll.name) - continue - rigs.append(rig) - else: - for asset in asglobals.MULTI_ASSETS: - for coll in bpy.data.collections: - - if not opsdata.is_item_lib_override(coll): - continue - - if not coll.name.startswith(asset): - continue - - logger.info("Found asset in scene: %s", coll.name) - # Find rig. - rig = opsdata.find_rig(coll) - if not rig: - logger.warning("%s contains no rig.", coll.name) - continue - rigs.append(rig) - - if not rigs: - self.report( - {"ERROR"}, "Failed to find any assets or cameras to shift animation." - ) - return {"CANCELLED"} - - for rig in rigs: - for fcurve in rig.animation_data.action.fcurves: - - # Shift all keyframes. - for point in fcurve.keyframe_points: - # Print(f"{fcurve.data_path}|{fcurve.array_index}: {point.co.x}|{point.co.y}"). - point.co.x += frames_offset - # Don't forget the keyframe's handles:. - point.handle_left.x += frames_offset - point.handle_right.x += frames_offset - - # Shift all noise modififers values. - for m in fcurve.modifiers: - if not m.type == "NOISE": - continue - - m.offset += frames_offset - - if m.use_restricted_range: - frame_start = m.frame_start - frame_end = m.frame_end - m.frame_start = frame_start + (frames_offset) - m.frame_end = frame_end + (frames_offset) - - logger.info( - "%s shifted %s modifier values by %i frames", - m.id_data.name, - m.type.lower(), - frames_offset, - ) - logger.info( - "%s: %s shifted all keyframes by %i frames", - rig.name, - rig.animation_data.action.name, - frames_offset, - ) - - self.report( - {"INFO"}, f"Shifted animation of {len(rigs)} actions by {frames_offset}" - ) - return {"FINISHED"} - - -class AS_OT_apply_additional_settings(bpy.types.Operator): - - bl_idname = "as.apply_additional_settings" - bl_label = "Apply Additional Settings" - bl_description = ( - "Apply some additional settings that are important " "for animation scenes" - ) - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - sqe_area = cls._get_sqe_area(context) - return bool(sqe_area) - - def execute(self, context: bpy.types.Context) -> Set[str]: - - sqe_area = self._get_sqe_area(context) - - sqe_area.spaces.active.use_proxies = False - sqe_area.spaces.active.proxy_render_size = "PROXY_100" - - self.report({"INFO"}, "Set: use_proxies | proxy_render_size") - return {"FINISHED"} - - @classmethod - def _get_sqe_area(cls, context: bpy.types.Context): - for window in context.window_manager.windows: - screen = window.screen - - for area in screen.areas: - if area.type == "SEQUENCE_EDITOR": - return area - - return None - - -class AS_OT_exclude_colls(bpy.types.Operator): - """Excludes Collections that are not needed for animation""" - - bl_idname = "as.exclude_colls" - bl_label = "Exclude Collections" - bl_description = ( - "Exclude some collections by name that are not needed in animation scenes" - ) - - def execute(self, context: bpy.types.Context) -> Set[str]: - view_layer_colls = opsdata.get_all_view_layer_colls(context) - - excluded = [] - for coll_name in asglobals.HIDE_COLLS: - # Find view layer collection, if same collection is linked in in 2 different colls in same scene, these - # are 2 different view layer colls, we need to grab all. - valid_view_layer_colls = [ - vc for vc in view_layer_colls if vc.name == coll_name - ] - - if not valid_view_layer_colls: - logger.info("No view layer collections named: %s", coll_name) - continue - - for view_layer_coll in valid_view_layer_colls: - view_layer_coll.exclude = True - logger.info("Excluded view layer collection: %s", view_layer_coll.name) - excluded.append(view_layer_coll) - - self.report( - {"INFO"}, f"Excluded Collections: {list([v.name for v in excluded])}" - ) - return {"FINISHED"} - - -# ---------REGISTER ----------. - -classes = [ - AS_OT_create_actions, - AS_OT_setup_workspaces, - AS_OT_load_latest_edit, - AS_OT_import_camera, - AS_OT_import_camera_action, - AS_OT_shift_anim, - AS_OT_apply_additional_settings, - AS_OT_import_asset_actions, - AS_OT_exclude_colls, - AS_OT_import_multi_assets, -] - - -def register(): - for cls in classes: - bpy.utils.register_class(cls) - - -def unregister(): - for cls in reversed(classes): - bpy.utils.unregister_class(cls) diff --git a/anim_setup/opsdata.py b/anim_setup/opsdata.py deleted file mode 100644 index 86062e10..00000000 --- a/anim_setup/opsdata.py +++ /dev/null @@ -1,344 +0,0 @@ -import re -from pathlib import Path -from typing import Optional, Dict, Union, Any, List, Generator -import bpy -from bpy.types import Key - -from . import prefs - - -from .log import LoggerFactory - -logger = LoggerFactory.getLogger() - - -def get_shot_name_from_file() -> Optional[str]: - if not bpy.data.filepath: - return None - - # Default 110_0030_A.anim.blend. - return Path(bpy.data.filepath).name.split(".")[0] - - -def get_sequence_from_file() -> Optional[str]: - if not bpy.data.filepath: - return None - - # ./spritefright/pro/shots/110_rextoria/110_0010_A/110_0010_A.anim.blend. - return Path(bpy.data.filepath).parents[1].name - - -def get_seqeunce_short_from_shot_name(shotname: str) -> str: - return shotname.split("_")[0] - - -def get_cam_action_name_from_shot(shotname: str) -> str: - # ANI-camera.070_0010_A. - return f"ANI-camera.{shotname}" - - -def get_cam_action_name_from_lib(shotname: str, libpath: Path) -> Optional[str]: - - valid_actions = [] - - with bpy.data.libraries.load(libpath.as_posix(), relative=True) as ( - data_from, - data_to, - ): - - for action in data_from.actions: - if action.startswith(get_cam_action_name_from_shot(shotname)): - valid_actions.append(action) - - if not valid_actions: - return None - - return sorted(valid_actions, reverse=True)[0] - - -def get_previs_file(context: bpy.types.Context) -> Optional[Path]: - - addon_prefs = prefs.addon_prefs_get(context) - - shotname = get_shot_name_from_file() - if not shotname: - return None - - seqname = get_seqeunce_short_from_shot_name(shotname) - previs_path = Path(addon_prefs.previs_root_path) - - # Catch custom cases when previs files are split up for specific shots. - if shotname == "020_0010_A": - return previs_path / "020_grove.020_0010_A.blend" - - elif shotname == "020_0020_A" or shotname == "020_0050_A": - return previs_path / "020_grove.shove.blend" - - elif shotname in ["020_0060_A", "020_0070_A"]: - return previs_path / "020_grove.crowdcamping_alt.blend" - - elif shotname in ["020_0160_A", "020_0170_A", "020_0173_A", "020_0176_A"]: - return previs_path / "020_grove.weenie_alt.blend" - - else: - for f in previs_path.iterdir(): - if f.is_file() and f.suffix == ".blend" and f.name.startswith(seqname): - if len(f.name.split(".")) > 2: - continue - return f - return None - - -def traverse_collection_tree( - collection: bpy.types.Collection, -) -> Generator[bpy.types.Collection, None, None]: - yield collection - for child in collection.children: - yield from traverse_collection_tree(child) - - -def import_data_from_lib( - data_category: str, - data_name: str, - libpath: Path, - link: bool = True, -): - - noun = "Appended" - if link: - noun = "Linked" - - with bpy.data.libraries.load(libpath.as_posix(), relative=True, link=link) as ( - data_from, - data_to, - ): - - if data_name not in eval(f"data_from.{data_category}"): - logger.error( - "Failed to import %s %s from %s. Doesn't exist in file.", - data_category, - data_name, - libpath.as_posix(), - ) - return None - - # Check if datablock with same name already exists in blend file. - try: - eval(f"bpy.data.{data_category}['{data_name}']") - except KeyError: - pass - else: - logger.info( - "%s already in bpy.data.%s of this blendfile.", data_name, data_category - ) - return None - - # Append data block. - eval(f"data_to.{data_category}.append('{data_name}')") - logger.info( - "%s: %s from library: %s", - noun, - data_name, - libpath.as_posix(), - ) - - if link: - return eval( - f"bpy.data.{data_category}['{data_name}', '{bpy.path.relpath(libpath.as_posix())}']" - ) - - return eval(f"bpy.data.{data_category}['{data_name}']") - - -def instance_coll_to_scene_and_override( - context: bpy.types.Context, source_collection: bpy.types.Collection -) -> bpy.types.Collection: - instance_obj = _create_collection_instance(source_collection) - _make_library_override(context, instance_obj) - return bpy.data.collections[source_collection.name, None] - - -def _create_collection_instance( - source_collection: bpy.types.Collection, -) -> bpy.types.Object: - - # Name has no effect how the overwritten library collection in the end - # use empty to instance source collection. - instance_obj = bpy.data.objects.new(name="", object_data=None) - instance_obj.instance_collection = source_collection - instance_obj.instance_type = "COLLECTION" - - parent_collection = bpy.context.view_layer.active_layer_collection - parent_collection.collection.objects.link(instance_obj) - - logger.info( - "Instanced collection: %s as: %s", - source_collection.name, - instance_obj.name, - ) - - return instance_obj - - -def _make_library_override( - context: bpy.types.Context, - instance_obj: bpy.types.Object, -) -> None: - log_name = instance_obj.name - # Deselect all. - bpy.ops.object.select_all(action="DESELECT") - - # Needs active object (coll instance). - context.view_layer.objects.active = instance_obj - instance_obj.select_set(True) - - # Add library override. - bpy.ops.object.make_override_library() - - logger.info( - "%s make library override.", - log_name, - ) - - -def find_asset_name(name: str) -> str: - - if name.endswith("_rig"): - name = name[:-4] - return name.split("-")[-1] # CH-rex -> 'rex' - - -def find_rig(coll: bpy.types.Collection) -> Optional[bpy.types.Armature]: - - coll_suffix = find_asset_name(coll.name) - - valid_rigs = [] - - for obj in coll.all_objects: - # Default rig name: 'RIG-rex' / 'RIG-Rex'. - if obj.type != "ARMATURE": - continue - - if not obj.name.startswith("RIG"): - continue - - valid_rigs.append(obj) - - if not valid_rigs: - return None - - elif len(valid_rigs) == 1: - logger.info("Found rig: %s", valid_rigs[0].name) - return valid_rigs[0] - else: - logger.error("%s found multiple rigs %s", coll.name, str(valid_rigs)) - return None - - -def ensure_name_version_suffix(datablock: Any) -> Any: - version_pattern = r"v\d\d\d" - match = re.search(version_pattern, datablock.name) - - if not match: - datablock.name = datablock.name + ".v001" - - return datablock - - -def get_valid_collections(context: bpy.types.Context) -> List[bpy.types.Collection]: - valid_prefixes = ["CH-", "PR-"] - valid_colls: List[bpy.types.Collection] = [] - - for coll in context.scene.collection.children: - if coll.name[:3] not in valid_prefixes: - continue - valid_colls.append(coll) - - return valid_colls - - -def is_multi_asset(asset_name: str) -> bool: - if asset_name.startswith("thorn"): - return True - multi_assets = ["sprite", "snail", "spider"] - if asset_name.lower() in multi_assets: - return True - return False - - -def gen_action_name(coll: bpy.types.Collection): - action_prefix = "ANI" - asset_name = find_asset_name(coll.name).lower() - asset_name = asset_name.replace(".", "_") - version = "v001" - shot_name = get_shot_name_from_file() - - action_name_new = f"{action_prefix}-{asset_name}.{shot_name}.{version}" - - if is_multi_asset(asset_name): - action_name_new = f"{action_prefix}-{asset_name}_A.{shot_name}.{version}" - - return action_name_new - - -def set_layer_coll_exlcude( - layer_collections: List[bpy.types.LayerCollection], exclude: bool -) -> None: - - noun = "Excluded" if exclude else "Included" - - for lcoll in layer_collections: - - if exclude: - if lcoll.exclude: - continue - - lcoll.exclude = True - - else: - if not lcoll.exclude: - continue - - lcoll.exclude = False - - logger.info("%s %s", noun, lcoll.name) - - -def get_all_view_layer_colls( - context: bpy.types.Context, -) -> List[bpy.types.LayerCollection]: - return list(traverse_collection_tree(context.view_layer.layer_collection)) - - -def get_ref_coll(coll: bpy.types.Collection) -> bpy.types.Collection: - if not coll.override_library: - return coll - - return coll.override_library.reference - - -def is_item_local( - item: Union[bpy.types.Collection, bpy.types.Object, bpy.types.Camera] -) -> bool: - # Local collection of blend file. - if not item.override_library and not item.library: - return True - return False - - -def is_item_lib_override( - item: Union[bpy.types.Collection, bpy.types.Object, bpy.types.Camera] -) -> bool: - # Collection from libfile and overwritten. - if item.override_library and not item.library: - return True - return False - - -def is_item_lib_source( - item: Union[bpy.types.Collection, bpy.types.Object, bpy.types.Camera] -) -> bool: - # Source collection from libfile not overwritten. - if not item.override_library and item.library: - return True - return False diff --git a/anim_setup/prefs.py b/anim_setup/prefs.py deleted file mode 100644 index bf1f4654..00000000 --- a/anim_setup/prefs.py +++ /dev/null @@ -1,176 +0,0 @@ -import os -from pathlib import Path -from typing import Union, Optional, Any, Dict, Set - -import bpy - -from .kitsu import KitsuException -from . import asglobals - - -class KitsuPreferences(bpy.types.PropertyGroup): - backend: bpy.props.StringProperty( # type: ignore - name="Server URL", - description="Kitsu server address", - default="https://kitsu.blender.cloud/api", - ) - - email: bpy.props.StringProperty( # type: ignore - name="Email", - description="Email to connect to Kitsu", - ) - - password: bpy.props.StringProperty( # type: ignore - name="Password", - description="Password to connect to Kitsu", - subtype="PASSWORD", - ) - - project_id: bpy.props.StringProperty( # type: ignore - name="Project ID", - description="Server Id that refers to the last active project", - default=asglobals.PROJECT_ID, - options={"HIDDEN", "SKIP_SAVE"}, - ) - - def draw(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - box = layout.box() - box.label(text="Kitsu") - box.prop(self, "backend") - box.prop(self, "email") - box.prop(self, "password") - box.prop(self, "project_id") - - def _validate(self): - if not (self.backend and self.email and self.password and self.project_id): - raise KitsuException( - "Kitsu connector has not been configured in the add-on preferences" - ) - - -class AS_AddonPreferences(bpy.types.AddonPreferences): - bl_idname = __package__ - - project_root: bpy.props.StringProperty( # type: ignore - name="Project Root", - default="", - options={"HIDDEN", "SKIP_SAVE"}, - subtype="DIR_PATH", - ) - edit_export_dir: bpy.props.StringProperty( # type: ignore - name="Edit Export Directory", - default="", - options={"HIDDEN", "SKIP_SAVE"}, - subtype="DIR_PATH", - ) - - kitsu: bpy.props.PointerProperty( # type: ignore - name="Kitsu Preferences", type=KitsuPreferences - ) - - def draw(self, context: bpy.types.Context) -> None: - layout = self.layout - box = layout.box() - box.row().prop(self, "project_root") - - if not self.project_root: - row = box.row() - row.label(text="Please specify the project root directory.", icon="ERROR") - - if not bpy.data.filepath and self.project_root.startswith("//"): - row = box.row() - row.label( - text="In order to use a relative path as root cache directory the current file needs to be saved.", - icon="ERROR", - ) - - box.row().prop(self, "edit_export_dir") - - if not self.edit_export_dir: - row = box.row() - row.label(text="Please specify the edit edxport directory.", icon="ERROR") - - if not bpy.data.filepath and self.edit_export_dir.startswith("//"): - row = box.row() - row.label( - text="In order to use a relative path as edit export directory the current file needs to be saved.", - icon="ERROR", - ) - - self.kitsu.draw(layout, context) - - @property - def project_root_path(self) -> Optional[Path]: - if not self.is_project_root_valid: - return None - return Path(os.path.abspath(bpy.path.abspath(self.project_root))) - - @property - def is_project_root_valid(self) -> bool: - - # Check if file is saved. - if not self.project_root: - return False - - if not bpy.data.filepath and self.project_root.startswith("//"): - return False - - return True - - @property - def is_editorial_valid(self) -> bool: - if not self.edit_export_dir: - return False - - return Path(self.edit_export_dir).exists() - - @property - def edit_export_path(self) -> Optional[Path]: - if not self.is_editorial_valid: - return None - - return Path(self.edit_export_dir) - - @property - def previs_root_path(self) -> Optional[Path]: - if not self.is_project_root_valid: - return None - - previs_path = self.project_root_path / "previz" - - if not previs_path.exists(): - return None - - return previs_path - - @property - def camera_rig_path(self) -> Optional[Path]: - if not self.is_project_root_valid: - return None - - camera_rig_path = self.project_root_path / "pro/lib/cam/camera_rig.blend" - - if not camera_rig_path.exists(): - return None - - return camera_rig_path - - -def addon_prefs_get(context: bpy.types.Context) -> bpy.types.AddonPreferences: - """Shortcut to get cache_manager addon preferences""" - return context.preferences.addons["anim_setup"].preferences - - -# ---------REGISTER ----------. - -classes = [KitsuPreferences, AS_AddonPreferences] - - -def register(): - for cls in classes: - bpy.utils.register_class(cls) - - -def unregister(): - for cls in reversed(classes): - bpy.utils.unregister_class(cls) diff --git a/anim_setup/props.py b/anim_setup/props.py deleted file mode 100644 index 93ac9e0d..00000000 --- a/anim_setup/props.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import List, Any, Generator, Optional - -import bpy - - -class CM_property_group_scene(bpy.types.PropertyGroup): - - layout_cut_in: bpy.props.IntProperty( - name="Layout Cut In", - description="Frame where the camera marker is set for the shot, in the layout file", - default=0, - step=1, - ) - - -# ---------REGISTER ----------. - -classes: List[Any] = [ - CM_property_group_scene, -] - - -def register(): - - for cls in classes: - bpy.utils.register_class(cls) - - # Scene Properties. - bpy.types.Scene.anim_setup = bpy.props.PointerProperty(type=CM_property_group_scene) - - -def unregister(): - for cls in reversed(classes): - bpy.utils.unregister_class(cls) diff --git a/anim_setup/ui.py b/anim_setup/ui.py deleted file mode 100644 index 7764c29c..00000000 --- a/anim_setup/ui.py +++ /dev/null @@ -1,134 +0,0 @@ -import bpy - -from . import opsdata - -from .ops import ( - AS_OT_create_actions, - AS_OT_setup_workspaces, - AS_OT_load_latest_edit, - AS_OT_import_camera, - AS_OT_import_camera_action, - AS_OT_shift_anim, - AS_OT_apply_additional_settings, - AS_OT_import_asset_actions, - AS_OT_exclude_colls, - AS_OT_import_multi_assets -) - - -class AS_PT_view3d_general(bpy.types.Panel): - """ - Animation Setup general operators. - """ - - bl_category = "Anim Setup" - bl_label = "General" - bl_space_type = "VIEW_3D" - bl_region_type = "UI" - bl_order = 10 - - def draw(self, context: bpy.types.Context) -> None: - valid_colls = opsdata.get_valid_collections(context) - layout = self.layout - col = layout.column(align=True) - - # Workspace. - col.operator(AS_OT_setup_workspaces.bl_idname) - - # Load edit. - col.operator(AS_OT_load_latest_edit.bl_idname) - - - -class AS_PT_view3d_animation_and_actions(bpy.types.Panel): - """ - Animation Setup main operators and properties. - """ - - bl_category = "Anim Setup" - bl_label = "Animation and Actions" - bl_space_type = "VIEW_3D" - bl_region_type = "UI" - bl_order = 12 - - def draw(self, context: bpy.types.Context) -> None: - - layout = self.layout - layout.use_property_split = True - layout.use_property_decorate = False # No animation. - - layout.label(text=f"Previs file: {opsdata.get_previs_file(context)}") - - col = layout.column(align=True) - - # Import camera action. - col.operator(AS_OT_import_camera_action.bl_idname) - - # Import action. - col.operator( - AS_OT_import_asset_actions.bl_idname, text=f"Import Char Actions" - ) - - col.operator( - AS_OT_import_multi_assets.bl_idname, text=f"Import Multi Asset Actions" - ) - - col.separator() - col = layout.column() - - # Shift animation. - col.prop(context.scene.anim_setup, "layout_cut_in") - col.separator() - split = col.split(factor=0.5, align=True) - split.operator(AS_OT_shift_anim.bl_idname, text="Shift Char/Cam") - split.operator(AS_OT_shift_anim.bl_idname, text="Shift Multi").multi_assets = True - - col.separator() - - # Create actions. - valid_collections_count = len(opsdata.get_valid_collections(context)) - row = col.row(align=True) - row.operator( - AS_OT_create_actions.bl_idname, text=f"Create {valid_collections_count} actions" - ) - - -class AS_PT_view3d_scene(bpy.types.Panel): - """ - Animation Setup scene operators. - """ - - bl_category = "Anim Setup" - bl_label = "Scene" - bl_space_type = "VIEW_3D" - bl_region_type = "UI" - bl_order = 13 - - def draw(self, context: bpy.types.Context) -> None: - - layout = self.layout - - # Exclude collections. - row = layout.row(align=True) - row.operator( - AS_OT_exclude_colls.bl_idname, text="Exclude Collections" - ) - - -# ---------REGISTER ----------. - -classes = [ - AS_PT_view3d_general, - AS_PT_view3d_animation_and_actions, - AS_PT_view3d_scene, - ] - - -def register(): - for cls in classes: - bpy.utils.register_class(cls) - - -def unregister(): - for cls in reversed(classes): - bpy.utils.unregister_class(cls) diff --git a/blender_kitsu/__init__.py b/blender_kitsu/__init__.py index b8494292..7c8a4122 100644 --- a/blender_kitsu/__init__.py +++ b/blender_kitsu/__init__.py @@ -40,6 +40,8 @@ from blender_kitsu import ( ui, ) + + from blender_kitsu.logger import LoggerFactory, LoggerLevelManager logger = LoggerFactory.getLogger(__name__) @@ -94,6 +96,7 @@ def register(): playblast.register() anim.register() shot_builder.register() + LoggerLevelManager.configure_levels() logger.info("Registered blender-kitsu") diff --git a/blender_kitsu/prefs.py b/blender_kitsu/prefs.py index 5eb158bb..af504f39 100644 --- a/blender_kitsu/prefs.py +++ b/blender_kitsu/prefs.py @@ -21,6 +21,7 @@ import hashlib import sys import os +import re from typing import Optional, Any, Set, Tuple, List from pathlib import Path @@ -39,6 +40,8 @@ from blender_kitsu.auth.ops import ( ) from blender_kitsu.context.ops import KITSU_OT_con_productions_load from blender_kitsu.lookdev.prefs import LOOKDEV_preferences +from blender_kitsu.shot_builder.editorial.core import editorial_export_check_latest + logger = LoggerFactory.getLogger() @@ -249,6 +252,10 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences): name="Show Advanced Settings", description="Show advanced settings that should already have good defaults", ) + shot_builder_show_advanced : bpy.props.BoolProperty( # type: ignore + name="Show Advanced Settings", + description="Show advanced settings that should already have good defaults", + ) shot_pattern: bpy.props.StringProperty( # type: ignore name="Shot Pattern", @@ -294,6 +301,51 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences): subtype='DIR_PATH', ) + edit_export_dir: bpy.props.StringProperty( # type: ignore + name="Editorial Export Directory", + options={"HIDDEN", "SKIP_SAVE"}, + description="Directory path to editorial's export folder containing storyboard/animatic exports. Path should be similar to '~/shared-{proj_name}/editorial/export/'", + subtype="DIR_PATH", + ) + + edit_export_file_pattern: bpy.props.StringProperty( # type: ignore + name="Editorial Export File Pattern", + options={"HIDDEN", "SKIP_SAVE"}, + description="File pattern to search for latest editorial export. Typically '{proj_name}_v\d\d\d.mp4'", + default="petprojects_v\d\d\d.mp4", + + ) + + edit_export_frame_offset: bpy.props.IntProperty( # type: ignore + name="Editorial Export Offset", + description="Shift Editorial Export by this frame-range after set-up.", + default=-102, #HARD CODED FOR PET PROJECTS BLENDER FILM + ) + + shot_builder_frame_offset: bpy.props.IntProperty( # type: ignore + name="Start Frame Offset", + description="All Shots built by 'Shot_builder' should begin at this frame", + default=101, + ) + + shot_builder_armature_prefix: bpy.props.StringProperty( # type: ignore + name="Armature Prefix", + description="Naming convention prefix that exists on published assets containing armatures. Used to create/name actions during 'Shot_Build'. Armature name example:'{prefix}{base_name}'", + default="RIG-", + ) + + shot_builder_action_prefix: bpy.props.StringProperty( # type: ignore + name="Action Prefix", + description="Naming convention prefix to add to new actions. Actions will be named '{prefix}{base_name}.{shot_name}.v001' and set to fake user during 'Shot_Build'", + 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) @@ -372,6 +424,21 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences): icon="ADD", emboss=False, ) + + # Shot_Builder settings. + box = layout.box() + box.label(text="Shot Builder", icon="MOD_BUILD") + box.row().prop(self, "edit_export_dir") + box.row().prop(self, "edit_export_file_pattern") + box.row().prop(self, "edit_export_frame_offset") + box.row().prop(self, "shot_builder_show_advanced") + if self.shot_builder_show_advanced: + start_frame_row = box.row() + start_frame_row.label(text="Start Frame Offset") + 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") # Misc settings. box = layout.box() @@ -386,6 +453,7 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences): box.row().prop(self, "shot_counter_digits") box.row().prop(self, "shot_counter_increment") + @property def playblast_root_path(self) -> Optional[Path]: if not self.is_playblast_root_valid: @@ -427,7 +495,15 @@ class KITSU_addon_preferences(bpy.types.AddonPreferences): return False return True - + + @property + def is_editorial_dir_valid(self) -> bool: + if editorial_export_check_latest(bpy.context) is None: + logger.error( + "Failed to initialize editorial export file model. Invalid path/pattern. Check addon preferences" + ) + return False + return True def session_get(context: bpy.types.Context) -> Session: """ diff --git a/blender_kitsu/shot_builder/__init__.py b/blender_kitsu/shot_builder/__init__.py index cb825046..19cd677e 100644 --- a/blender_kitsu/shot_builder/__init__.py +++ b/blender_kitsu/shot_builder/__init__.py @@ -22,6 +22,8 @@ from blender_kitsu.shot_builder.ui import * from blender_kitsu.shot_builder.connectors.kitsu import * from blender_kitsu.shot_builder.operators import * import bpy +from blender_kitsu.shot_builder.anim_setup import ops as anim_setup_ops #TODO Fix Registraion +from blender_kitsu.shot_builder.editorial import ops as editorial_ops #TODO Fix Registraion # import logging # logging.basicConfig(level=logging.DEBUG) @@ -45,12 +47,18 @@ classes = ( 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) + 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) + diff --git a/blender_kitsu/shot_builder/anim_setup/core.py b/blender_kitsu/shot_builder/anim_setup/core.py new file mode 100644 index 00000000..3e6a2054 --- /dev/null +++ b/blender_kitsu/shot_builder/anim_setup/core.py @@ -0,0 +1,29 @@ +import bpy +import re +from pathlib import Path +from typing import Set +from blender_kitsu import prefs +from blender_kitsu 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": + bpy.ops.workspace.delete({"workspace": ws}) + + + diff --git a/blender_kitsu/shot_builder/anim_setup/ops.py b/blender_kitsu/shot_builder/anim_setup/ops.py new file mode 100644 index 00000000..a1438ce5 --- /dev/null +++ b/blender_kitsu/shot_builder/anim_setup/ops.py @@ -0,0 +1,35 @@ +import bpy +from typing import Set +from blender_kitsu.shot_builder.anim_setup.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) \ No newline at end of file diff --git a/blender_kitsu/shot_builder/builder/__init__.py b/blender_kitsu/shot_builder/builder/__init__.py index 056ea561..a072e65d 100644 --- a/blender_kitsu/shot_builder/builder/__init__.py +++ b/blender_kitsu/shot_builder/builder/__init__.py @@ -7,7 +7,6 @@ from blender_kitsu.shot_builder.builder.init_shot import InitShotStep from blender_kitsu.shot_builder.builder.set_render_settings import SetRenderSettingsStep from blender_kitsu.shot_builder.builder.new_scene import NewSceneStep from blender_kitsu.shot_builder.builder.invoke_hook import InvokeHookStep -from blender_kitsu.shot_builder.builder.save_file import SaveFileStep import bpy @@ -76,8 +75,6 @@ class ShotBuilder: for hook in production.hooks.filter(match_task_type=task_type.name, match_asset_type=asset.asset_type): self._steps.append(InvokeHookStep(hook)) - self._steps.append(SaveFileStep()) - def build(self) -> None: num_steps = len(self._steps) step_number = 1 diff --git a/blender_kitsu/shot_builder/builder/save_file.py b/blender_kitsu/shot_builder/builder/save_file.py index 0122c915..abedb044 100644 --- a/blender_kitsu/shot_builder/builder/save_file.py +++ b/blender_kitsu/shot_builder/builder/save_file.py @@ -11,6 +11,14 @@ 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" @@ -18,7 +26,5 @@ class SaveFileStep(BuildStep): def execute(self, build_context: BuildContext) -> None: shot = build_context.shot file_path = pathlib.Path(shot.file_path) - file_path.mkdir(parents=True, exist_ok=True) - + save_shot_builder_file(file_path) logger.info(f"save file {shot.file_path}") - bpy.ops.wm.save_mainfile(filepath=shot.file_path, relative_remap=True) diff --git a/blender_kitsu/shot_builder/editorial/__init__.py b/blender_kitsu/shot_builder/editorial/__init__.py new file mode 100644 index 00000000..527a061a --- /dev/null +++ b/blender_kitsu/shot_builder/editorial/__init__.py @@ -0,0 +1,30 @@ +# ##### 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 ##### + +# + +import bpy +from blender_kitsu.shot_builder.editorial import ops + + +def register(): + ops.register() + + +def unregister(): + ops.unregister() diff --git a/blender_kitsu/shot_builder/editorial/core.py b/blender_kitsu/shot_builder/editorial/core.py new file mode 100644 index 00000000..6d6fd7fd --- /dev/null +++ b/blender_kitsu/shot_builder/editorial/core.py @@ -0,0 +1,75 @@ +import bpy +import re +from pathlib import Path +from typing import Set +from blender_kitsu import prefs +from blender_kitsu import cache + +def editorial_export_get_latest(context:bpy.types.Context, shot) -> list[bpy.types.Sequence]: #TODO add info to shot + """Loads latest export from editorial department""" + addon_prefs = prefs.addon_prefs_get(context) + strip_channel = 1 + latest_file = editorial_export_check_latest(context) + if not latest_file: + return None + # Check if Kitsu server returned empty shot + if shot.get("id") == '': + return None + strip_filepath = latest_file.as_posix() + strip_frame_start = addon_prefs.shot_builder_frame_offset + + scene = context.scene + if not scene.sequence_editor: + scene.sequence_editor_create() + seq_editor = scene.sequence_editor + movie_strip = seq_editor.sequences.new_movie( + latest_file.name, + strip_filepath, + strip_channel + 1, + strip_frame_start, + fit_method="FIT", + ) + sound_strip = seq_editor.sequences.new_sound( + latest_file.name, + strip_filepath, + strip_channel, + strip_frame_start, + ) + new_strips = [movie_strip, sound_strip] + + # Update shift frame range prop. + frame_in = shot["data"].get("frame_in") + frame_3d_in = shot["data"].get("3d_in") + frame_3d_offset = frame_3d_in - addon_prefs.shot_builder_frame_offset + edit_export_offset = addon_prefs.edit_export_frame_offset + + # Set sequence strip start kitsu data. + for strip in new_strips: + strip.frame_start = -frame_in + (strip_frame_start * 2) + frame_3d_offset + edit_export_offset + return new_strips + + + +def editorial_export_check_latest(context: bpy.types.Context): + """Find latest export in editorial export directory""" + addon_prefs = prefs.addon_prefs_get(context) + + edit_export_path = Path(addon_prefs.edit_export_dir) + + files_list = [ + f + for f in edit_export_path.iterdir() + if f.is_file() and editorial_export_is_valid_edit_name(addon_prefs.edit_export_file_pattern, f.name) + ] + if len(files_list) >= 1: + files_list = sorted(files_list, reverse=True) + return files_list[0] + return None + + +def editorial_export_is_valid_edit_name(file_pattern:str, filename: str) -> bool: + """Verify file name matches file pattern set in preferences""" + match = re.search(file_pattern, filename) + if match: + return True + return False diff --git a/blender_kitsu/shot_builder/editorial/ops.py b/blender_kitsu/shot_builder/editorial/ops.py new file mode 100644 index 00000000..d25f18f0 --- /dev/null +++ b/blender_kitsu/shot_builder/editorial/ops.py @@ -0,0 +1,38 @@ +import bpy +from typing import Set +from blender_kitsu.shot_builder.editorial.core import editorial_export_get_latest +from blender_kitsu import cache, 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_in 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) \ No newline at end of file diff --git a/blender_kitsu/shot_builder/operators.py b/blender_kitsu/shot_builder/operators.py index 544684c5..03e2eb70 100644 --- a/blender_kitsu/shot_builder/operators.py +++ b/blender_kitsu/shot_builder/operators.py @@ -17,13 +17,18 @@ # ##### END GPL LICENSE BLOCK ##### # +import pathlib from typing import * import bpy from blender_kitsu.shot_builder.shot import ShotRef from blender_kitsu.shot_builder.project import ensure_loaded_production, get_active_production from blender_kitsu.shot_builder.builder import ShotBuilder from blender_kitsu.shot_builder.task_type import TaskType -from blender_kitsu import prefs, cache +from blender_kitsu import prefs, cache, gazu +from blender_kitsu.shot_builder.anim_setup.core import animation_workspace_delete_others, animation_workspace_vse_area_add +from blender_kitsu.shot_builder.editorial.core import editorial_export_get_latest +from blender_kitsu.shot_builder.builder.save_file import save_shot_builder_file + _production_task_type_items: List[Tuple[str, str, str]] = [] @@ -73,6 +78,11 @@ class SHOTBUILDER_OT_NewShotFile(bpy.types.Operator): 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", @@ -102,6 +112,36 @@ class SHOTBUILDER_OT_NewShotFile(bpy.types.Operator): 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""" + 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) @@ -122,6 +162,11 @@ class SHOTBUILDER_OT_NewShotFile(bpy.types.Operator): {'ERROR'}, "Operator is not able to determine the project root directory. Check project root directiory is configured in 'Blender Kitsu' addon preferences.") return {'CANCELLED'} + if not addon_prefs.is_editorial_dir_valid: + self.report( + {'ERROR'}, "Shot builder is dependant on a valid editorial export path and file pattern. Check Preferences, errors appear in console") + return {'CANCELLED'} + self.production_root = addon_prefs.project_root_dir self.production_name = project.name @@ -146,19 +191,51 @@ class SHOTBUILDER_OT_NewShotFile(bpy.types.Operator): return cast(Set[str], context.window_manager.invoke_props_dialog(self, width=400)) def execute(self, context: bpy.types.Context) -> Set[str]: + 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'} + addon_prefs = bpy.context.preferences.addons["blender_kitsu"].preferences 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() + + # Build Kitsu Context + sequence = gazu.shot.get_sequence_by_name(production.config['KITSU_PROJECT_ID'], self.seq_id) + shot = gazu.shot.get_shot_by_name(sequence, self.shot_id) + + #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 + frame_length = shot.get('nb_frames') + context.scene.frame_start = addon_prefs.shot_builder_frame_offset + context.scene.frame_end = frame_length + addon_prefs.shot_builder_frame_offset + + # 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'} - return {'FINISHED'} def draw(self, context: bpy.types.Context) -> None: layout = self.layout @@ -168,3 +245,4 @@ class SHOTBUILDER_OT_NewShotFile(bpy.types.Operator): layout.prop(self, "seq_id") layout.prop(self, "shot_id") layout.prop(self, "task_type") + layout.prop(self, "auto_save")