diff --git a/BlenderRemoteDebug.py b/BlenderRemoteDebug.py new file mode 100644 index 0000000..710a9f4 --- /dev/null +++ b/BlenderRemoteDebug.py @@ -0,0 +1,11 @@ +#script to run: +SCRIPT="/project/terry/Dev/eclipse-workspace/ABX/src/abx.py" + +#path to the PyDev folder that contains a file named pydevd.py: +PYDEVD_PATH='/home/terry/.eclipse/360744294_linux_gtk_x86_64/plugins/org.python.pydev.core_7.3.0.201908161924/pysrc/' + +#PYDEVD_PATH='/home/terry/.config/blender/2.79/scripts/addons/modules/pydev_debug.py' + +import pydev_debug as pydev #pydev_debug.py is in a folder from Blender PYTHONPATH + +pydev.debug(SCRIPT, PYDEVD_PATH, trace = True) diff --git a/DebugInBlender.py b/DebugInBlender.py new file mode 100644 index 0000000..0a65e41 --- /dev/null +++ b/DebugInBlender.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +# Run the script in the debugger client within Blender: +import subprocess +subprocess.call(['blender', '-P', '/project/terry/Dev/eclipse-workspace/ABX/BlenderRemoteDebug.py']) diff --git a/MakeAddOnZip.py b/MakeAddOnZip.py new file mode 100644 index 0000000..6bc55a2 --- /dev/null +++ b/MakeAddOnZip.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +""" +MakeAddOnZip.py + +Utility script to package ABX into the "abx-##.##.zip" file needed for Installation +in Blender. +""" +import subprocess, os + +import bpy, bpy.utils + +import abx + +VERSION_PKG = ('a',) +VERSION = abx.bl_info['version'] + VERSION_PKG + +#VERSION = (0,1,2,'a') + +#AODIR = 'abx%d%d%d%s' % VERSION # Addon directory name for Blender +AODIR = 'abx' +PKGNAME = 'abx-%d.%d.%d%s' % VERSION # Package name for ZIP file + +# PROJDIR is the project directory, one above the source tree, where my associated +# stuff lives: documentation, management scripts, etc. +# Normally this script is in it, and so the directory of __file__ is what I want. +# But not if I'm testing this code out on the console! +try: + # Normally if I'm running from a script, I want the directory the script is in + PROJDIR = os.path.dirname(os.path.abspath(__file__)) +except: + # Occasionally I might be trying to run from a console, in which case there's + # no file, and I probably just want to use the "present working directory" + # Hopefully, at that point, I'm smart enough to have set it correctly! + PROJDIR = os.getcwd() + +PKGDIR = os.path.join(PROJDIR, 'pkg') # Directory used for building packages. + +print( "VERSION: %d.%d.%d%s" % VERSION) +print( "PACKAGE DIRECTORY: ", PKGDIR) +print( "WORKING DIRECTORY: ", PROJDIR) + + +subprocess.run(('rm', '-rf', AODIR), cwd=PKGDIR) +subprocess.run(('rm', PKGNAME+'.zip'), cwd=PKGDIR) +subprocess.run(('mkdir', AODIR), cwd=PKGDIR) + +files = os.listdir(os.path.join(PROJDIR, 'abx')) +pkg_files = [] +for ext in ('.py', '.yaml', '.cfg'): + pkg_files.extend([ + os.path.abspath(os.path.join(PROJDIR, 'abx', f)) + for f in files if f.endswith(ext)]) + +subprocess.run(('cp',) + tuple(pkg_files) + ( + os.path.join(PKGDIR, AODIR),), cwd=PROJDIR) +subprocess.run(('zip', '-r', PKGNAME+'.zip', AODIR), cwd=PKGDIR) + +# TODO: It would be good to clean the copied source tree, to get rid of unwanted files +# or else I could make the copy operation more selective. As it is, I'm packaging +# a lot of unnecessary files. + + + + \ No newline at end of file diff --git a/abx/__init__.py b/abx/__init__.py new file mode 100644 index 0000000..798f331 --- /dev/null +++ b/abx/__init__.py @@ -0,0 +1,38 @@ + +bl_info = { + "name": "ABX", + "author": "Terry Hancock / Lunatics.TV Project / Anansi Spaceworks", + "version": (0, 2, 6), + "blender": (2, 79, 0), + "location": "SpaceBar Search -> ABX", + "description": "Anansi Studio Extensions for Blender", + "warning": "", + "wiki_url": "", + "tracker_url": "", + "category": "Object", + } + +blender_present = False +try: + # These are protected so we can read the add-on metadata from my + # management scripts, which run in the O/S standard Python 3 + import bpy, bpy.utils, bpy.types + blender_present = True + +except ImportError: + print("Blender Add-On 'ABX' requires the Blender Python environment to run.") + +print("blender_present = ", blender_present) + +if blender_present: + from . import abx_ui + + def register(): + bpy.utils.register_module(__name__) + + def unregister(): + bpy.utils.unregister_module(__name__) + + +if __name__ == "__main__": + register() diff --git a/abx/abx.yaml b/abx/abx.yaml new file mode 100644 index 0000000..eebaa22 --- /dev/null +++ b/abx/abx.yaml @@ -0,0 +1,72 @@ +# DEFAULT ABX SETTINGS +--- +project_schema: + - rank: project + delimiter: '-' + words: True + type: string + + - rank: sequence + type: + VN: Vague Name + + - rank: shot + type: letter + maxlength: 1 + + - rank: element + type: string + maxlength: 2 + +render_profiles: + previz: + name: PreViz, + desc: 'GL/AVI Previz Render for Animatics', + engine: gl + version: any + fps: 30 + fps_div: 1000 + fps_skip: 1 + suffix: GL + format: AVI_JPEG + extension: avi + freestyle: False + + quick: + name: 30fps Paint + desc: '30fps Simplified Paint-Only Render' + engine: bi + fps: 30 + fps_skip: 3 + suffix: PT + format: AVI_JPEG + extension: avi + freestyle: False, + antialias: False, + motionblur: False + + check: + name: 1fps Check + desc: '1fps Full-Features Check Renders' + engine: bi + fps: 30 + fps_skip: 30 + suffix: CH + format: JPEG + extension: jpg + framedigits: 5 + freestyle: True + antialias: 8 + + full: + name: 30fps Full + desc: 'Full Render with all Features Turned On', + engine: bi + fps: 30 + fps_skip: 1 + suffix: '' + format: PNG + extension: png + framedigits: 5 + freestyle: True + antialias: 8 diff --git a/abx/abx_ui.py b/abx/abx_ui.py new file mode 100644 index 0000000..60d24a5 --- /dev/null +++ b/abx/abx_ui.py @@ -0,0 +1,530 @@ +# Anansi Studio Extensions for Blender 'ABX' +""" +Collection of Blender extension tools to make our jobs easier. +This is not really meant to be an integrated plugin, but rather +a collection of useful scripts we can run to solve problems we +run into. +""" +# +#Copyright (C) 2019 Terry Hancock +# +# 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. +# + + + +import os + +import bpy, bpy.utils, bpy.types, bpy.props + +from . import copy_anim +from . import std_lunatics_ink +from . import render_profile + + +#configfile = os.path.join(os.path.dirname(__file__), 'config.yaml') + +#print("Configuration file path: ", os.path.abspath(configfile)) + +# Lunatics Scene Panel + +# Lunatics file/scene properties: + +# TODO: This hard-coded table is a temporary solution until I have figured +# out a good way to look these up from the project files (maybe YAML?): +seq_id_table = { + ('S1', 0): {'':'', 'mt':'Main Title'}, + ('S1', 1): {'':'', + 'TR':'Train', + 'SR':'Soyuz Rollout', + 'TB':'Touring Baikonur', + 'PC':'Press Conference', + 'SU':'Suiting Up', + 'LA':'Launch', + 'SF':'Soyuz Flight', + 'mt':'Main Title', + 'ad':'Ad Spot', + 'pv':'Preview', + 'et':'Episode Titles', + 'cr':'Credits' + }, + ('S1', 2): {'':'', + 'MM':'Media Montage', + 'mt':'Main Title', + 'et':'Episode Titles', + 'SS':'Space Station', + 'LC':'Loading Cargo', + 'TL':'Trans Lunar Injection', + 'BT':'Bed Time', + 'ad':'Ad Spot', + 'pv':'Preview', + 'cr':'Credits' + }, + ('S1', 3): {'':'', + 'mt':'Main Title', + 'et':'Episode Titles', + 'ZG':'Zero G', + 'LI':'Lunar Injection', + 'LO':'Lunar Orbit', + 'ML':'Moon Landing', + 'IR':'Iridium', + 'TC':'Touring Colony', + 'FD':'Family Dinner', + 'ad':'Ad Spot', + 'pv':'Preview', + 'cr':'Credits' + }, + ('S2', 0): {'':'', 'mt':'Main Title'}, + ('L', 0): {'':'', + 'demo':'Demonstration', + 'prop':'Property', + 'set': 'Set', + 'ext': 'Exterior Set', + 'int': 'Interior Set', + 'prac':'Practical', + 'char':'Character', + 'fx': 'Special Effect', + 'stock': 'Stock Animation' + }, + None: [''] + } + + +def get_seq_ids(self, context): + # + # Note: To avoid the reference bug mentioned in the Blender documentation, + # we only return values held in the global seq_id_table, which + # should remain defined and therefore hold a reference to the strings. + # + if not context: + seq_ids = seq_id_table[None] + else: + scene = context.scene + series = scene.lunaprops.series_id + episode = scene.lunaprops.episode_id + if (series, episode) in seq_id_table: + seq_ids = seq_id_table[(series, episode)] + else: + seq_ids = seq_id_table[None] + seq_enum_items = [(s, s, seq_id_table[series,episode][s]) for s in seq_ids] + return seq_enum_items + +# Another hard-coded table -- for render profiles +render_profile_table = { + 'previz': { + 'name': 'PreViz', + 'desc': 'GL/AVI Previz Render for Animatics', + 'engine':'gl', + 'version':'any', + 'fps': 30, + 'fps_div': 1000, + 'fps_skip': 1, + 'suffix': 'GL', + 'format': 'AVI', + 'freestyle': False + }, + + 'paint6': { + 'name': '6fps Paint', + 'desc': '6fps Simplified Paint-Only Render', + 'engine':'bi', + 'fps': 30, + 'fps_skip': 5, + 'suffix': 'P6', + 'format': 'AVI', + 'freestyle': False, + 'antialias': False, + 'motionblur': False + }, + + 'paint3': { + 'name': '3fps Paint', + 'desc': '3fps Simplified Paint-Only Render', + 'engine': 'bi', + 'fps': 30, + 'fps_skip': 10, + 'suffix': 'P3', + 'format': 'AVI', + 'freestyle': False, + 'antialias': False, + 'motionblur': False, + }, + + 'paint': { + 'name': '30fps Paint', + 'desc': '30fps Simplified Paint-Only Render', + 'engine': 'bi', + 'fps': 30, + 'fps_skip': 1, + 'suffix': 'PT', + 'format': 'AVI', + 'freestyle': False, + 'antialias': False, + 'motionblur': False + }, + + 'check': { + 'name': '1fps Check', + 'desc': '1fps Full-Features Check Renders', + 'engine': 'bi', + 'fps': 30, + 'fps_skip': 30, + 'suffix': 'CH', + 'format': 'JPG', + 'framedigits': 5, + 'freestyle': True, + 'antialias': 8 + }, + + 'full': { + 'name': '30fps Full', + 'desc': 'Full Render with all Features Turned On', + 'engine': 'bi', + 'fps': 30, + 'fps_skip': 1, + 'suffix': '', + 'format': 'PNG', + 'framedigits': 5, + 'freestyle': True, + 'antialias': 8 + }, + } + + +class LunaticsSceneProperties(bpy.types.PropertyGroup): + """ + Properties of the current scene. + """ + series_id = bpy.props.EnumProperty( + items=[ + ('S1', 'S1', 'Series One'), + ('S2', 'S2', 'Series Two'), + ('S3', 'S3', 'Series Three'), + ('A1', 'Aud','Audiodrama'), + ('L', 'Lib','Library') + ], + name="Series", + default='S1', + description="Series/Season of Animated Series, Audiodrama, or Library" + ) + + episode_id = bpy.props.IntProperty( + name="Episode", + default=0, + description="Episode number (0 means multi-use), ignored for Library", + min=0, + max=1000, + soft_max=18 + ) + + seq_id = bpy.props.EnumProperty( + name='', + items=get_seq_ids, + description="Sequence ID" + ) + + block_id = bpy.props.IntProperty( + name='', + default=1, + min=0, + max=20, + soft_max=10, + description="Block number" + ) + + use_multicam = bpy.props.BoolProperty( + name="Multicam", + default=False, + description="Use multicam camera/shot numbering?" + ) + + cam_id = bpy.props.IntProperty( + name="Cam", + default=0, + min=0, + max=20, + soft_max=10, + description="Camera number" + ) + + shot_id = bpy.props.EnumProperty( + name='Shot', + #items=[('NONE', '', 'Single')]+[(c,c,'Shot '+c) for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'], + items=[(c,c,'Shot '+c) for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'], + default='A', + description="Shot ID, normally a single capital letter, can be empty, two letters for transitions" + ) + + shot_name = bpy.props.StringProperty( + name='Name', + description='Short descriptive codename', + maxlen=0 + ) + +bpy.utils.register_class(LunaticsSceneProperties) +bpy.types.Scene.lunaprops = bpy.props.PointerProperty(type=LunaticsSceneProperties) + +class LunaticsScenePanel(bpy.types.Panel): + """ + Add a panel to the Properties-Scene screen + """ + bl_idname = 'SCENE_PT_lunatics' + bl_label = 'Lunatics Project' + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_context = 'scene' + + def draw(self, context): + lunaprops = bpy.context.scene.lunaprops + self.layout.label(text='Lunatics! Project Properties') + row = self.layout.row() + row.prop(lunaprops, 'series_id') + row.prop(lunaprops, 'episode_id') + row = self.layout.row() + row.prop(lunaprops, 'use_multicam') + row = self.layout.row() + row.prop(lunaprops, 'seq_id') + row.prop(lunaprops, 'block_id') + if lunaprops.use_multicam: + row.prop(lunaprops, 'cam_id') + row.prop(lunaprops, 'shot_id') + row.prop(lunaprops, 'shot_name') + +# Buttons + +class RenderProfileSettings(bpy.types.PropertyGroup): + """ + Settings for Render Profiles control. + """ + render_profile = bpy.props.EnumProperty( + name='Profile', + items=[(k, v['name'], v['desc']) + for k,v in render_profile_table.items()], + description="Select from pre-defined profiles of render settings", + default='full') + +bpy.utils.register_class(RenderProfileSettings) +bpy.types.Scene.render_profile_settings = bpy.props.PointerProperty( + type=RenderProfileSettings) + +class RenderProfilesOperator(bpy.types.Operator): + """ + Operator invoked implicitly when render profile is changed. + """ + bl_idname = 'render.render_profiles' + bl_label = 'Apply Render Profile' + bl_options = {'UNDO'} + + def invoke(self, context, event): + scene = context.scene + profile = render_profile_table[scene.render_profile_settings.render_profile] + + render_profile.set_render_from_profile(scene, profile) + + return {'FINISHED'} + +class RenderProfilesPanel(bpy.types.Panel): + """ + Add simple drop-down selector for generating common render settings with + destination set according to project defaults. + """ + bl_idname = 'SCENE_PT_render_profiles' + bl_label = 'Render Profiles' + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_context = 'render' + + def draw(self, context): + rps = bpy.context.scene.render_profile_settings + row = self.layout.row() + row.prop(rps, 'render_profile') + row = self.layout.row() + row.operator('render.render_profiles') + + +class copy_animation(bpy.types.Operator): + """ + Copy animation from active object to selected objects (select source last!). + + Useful for fixing broken proxy rigs (create a new proxy, and used this tool + to copy all animation from the original -- avoids tedious/error-prone NLA work). + + Can also migrate to a re-scaled rig. + """ + bl_idname = 'object.copy_anim' + bl_label = 'Copy Animation' + bl_options = {'UNDO'} + + def invoke(self, context, event): + #print("Copy NLA from selected armature to active armatures.") + + src_ob = context.active_object + tgt_obs = [ob for ob in context.selected_objects if ob != context.active_object] + + # TODO + # Are these type checks necessary? + # Is there any reason to restrict this operator to armature objects? + # I think there isn't. + + if src_ob.type != 'ARMATURE': + self.report({'WARNING'}, 'Cannot copy NLA data from object that is not an ARMATURE.') + return {'CANCELLED'} + + tgt_arm_obs = [] + for ob in tgt_obs: + if ob.type == 'ARMATURE': + tgt_arm_obs.append(ob) + if not tgt_arm_obs: + self.report({'WARNING'}, 'No armature objects selected to copy animation data to.') + return {'CANCELLED'} + + copy_anim.copy_object_animation(src_ob, tgt_arm_obs, + dopesheet=context.scene.copy_anim_settings.dopesheet, + nla=context.scene.copy_anim_settings.nla, + rescale=context.scene.copy_anim_settings.rescale, + scale_factor=context.scene.copy_anim_settings.scale_factor, + report=self.report) + + return {'FINISHED'} + +class copy_animation_settings(bpy.types.PropertyGroup): + """ + Settings for the 'copy_animation' operator. + """ + dopesheet = bpy.props.BoolProperty( + name = "Dope Sheet", + description = "Copy animation from Dope Sheet", + default=True) + + nla = bpy.props.BoolProperty( + name = "NLA Strips", + description = "Copy all strips from NLA Editor", + default=True) + + rescale = bpy.props.BoolProperty( + name = "Re-Scale/Copy", + description = "Make rescaled COPY of actions instead of LINK to original", + default = False) + + scale_factor = bpy.props.FloatProperty( + name = "Scale", + description = "Scale factor for scaling animation (Re-Scale w/ 1.0 copies actions)", + default = 1.0) + +bpy.utils.register_class(copy_animation_settings) +bpy.types.Scene.copy_anim_settings = bpy.props.PointerProperty(type=copy_animation_settings) + +class CharacterPanel(bpy.types.Panel): + bl_space_type = "VIEW_3D" # window type panel is displayed in + bl_context = "objectmode" + bl_region_type = "TOOLS" # region of window panel is displayed in + bl_label = "Character" + bl_category = "ABX" + + def draw(self, context): + settings = bpy.context.scene.copy_anim_settings + layout = self.layout.column(align = True) + layout.label("Animation Data") + layout.operator('object.copy_anim') + layout.prop(settings, 'dopesheet') + layout.prop(settings, 'nla') + layout.prop(settings, 'rescale') + layout.prop(settings, 'scale_factor') + + + + +class lunatics_compositing_settings(bpy.types.PropertyGroup): + """ + Settings for the LX compositor tool. + """ + inkthru = bpy.props.BoolProperty( + name = "Ink-Thru", + description = "Support transparent Freestyle ink effect", + default=True) + + billboards = bpy.props.BoolProperty( + name = "Billboards", + description = "Support material pass for correct billboard inking", + default = False) + + sepsky = bpy.props.BoolProperty( + name = "Separate Sky", + description = "Render sky separately with compositing support (better shadows)", + default = True) + +bpy.utils.register_class(lunatics_compositing_settings) +bpy.types.Scene.lx_compos_settings = bpy.props.PointerProperty(type=lunatics_compositing_settings) + +class lunatics_compositing(bpy.types.Operator): + """ + Set up standard Lunatics scene compositing. + """ + bl_idname = "scene.lunatics_compos" + bl_label = "Ink/Paint Config" + bl_options = {'UNDO'} + bl_description = "Set up standard Lunatics Ink/Paint compositing in scene" + + def invoke(self, context, event): + """ + Add standard 'Lunatics!' shot compositing to the currently-selected scene. + """ + scene = context.scene + + shot = std_lunatics_ink.LunaticsShot(scene, + inkthru=context.scene.lx_compos_settings.inkthru, + billboards=context.scene.lx_compos_settings.billboards, + sepsky=context.scene.lx_compos_settings.sepsky ) + + shot.cfg_scene() + + return {'FINISHED'} + +# def draw(self, context): +# settings = context.scene.lx_compos_settings +# self.col = self.layout.col() +# col.prop(settings, "inkthru", text="Ink Thru") +# col.prop(settings, "billboards", text="Ink Thru") + + + +class LunaticsPanel(bpy.types.Panel): + bl_space_type = "VIEW_3D" + bl_context = "objectmode" + bl_region_type = "TOOLS" + bl_label = "Lunatics" + bl_category = "ABX" + + def draw(self, context): + settings = bpy.context.scene.lx_compos_settings + layout = self.layout.column(align = True) + layout.label("Compositing") + layout.operator('scene.lunatics_compos') + layout.prop(settings, 'inkthru', text="Ink-Thru") + layout.prop(settings, 'billboards', text="Billboards") + layout.prop(settings, 'sepsky', text="Separate Sky") + + +def register(): + bpy.utils.register_module(__name__) + +def unregister(): + bpy.utils.unregister_module(__name__) + +if __name__ == "__main__": + register() + + + diff --git a/abx/accumulate.py b/abx/accumulate.py new file mode 100644 index 0000000..553bef5 --- /dev/null +++ b/abx/accumulate.py @@ -0,0 +1,336 @@ +# accumulate.py +""" +Data structures for accumulating tree-structured data from multiple sources. + +Data is acquired from file and directory names and also from yaml files in the +tree. The yaml files are loaded in increasing priority from upper directories +to the local one, starting from the highest level file to contain a "project_root" +key. + +The files named for their parent directory are assumed to be KitCAT files (i.e. +"kitcat.yaml" and ".yaml" are treated the same way). Only files named +"abx.yaml" are assumed to be configuration files specific to ABX. + +We collect these by going up the file path, and then load them coming down. If +we find a "project_root" key, we ditch the previous data and start over. This way +any project files found above the project root will be ignored. + +As a use case: if we were to store a new project inside of another project, the +new project's project_root would make it blind to the settings in the containing +project. Other directories in the parent project would still go to the parent +project's root. This avoids having the location the project is stored affect +the project data. + +The overall structure is a dictionary. When updating with new data, any element +that is itself a dictionary is treated recursively (that is, it is updated with +directory data when another dictionary is provided for the same key). If an +element is a list, then data from successively-higher directories extends the +list (see UnionList, below). If a scalar replaces a dictionary or list value in +a more specific entry, then it clobbers it and any updated information in it. + +@author: Terry Hancock + +@copyright: 2019 Anansi Spaceworks. + +@license: GNU General Public License, version 2.0 or later. (Python code) + +@contact: digitante@gmail.com + +Demo: + +>>> import accumulate +>>> T1 = accumulate.RecursiveDict(accumulate.TEST_DICT_1) +>>> T2 = accumulate.RecursiveDict(accumulate.TEST_DICT_2) +>>> import copy +>>> Ta = copy.deepcopy(T1) +>>> Tb = copy.deepcopy(T2) +>>> Ta +RecursiveDict({'A': 1, 'B': [1, 2, 3], 'C': {'a': 1, 'b': 2, 'c': 3}, 'D': {}, 'E': None, 'F': {'h': {'i': {'j': {'k': 'abcdefghijk'}}}}}) +>>> Tb +RecursiveDict({'C': {'d': 4, 'e': 5, 'f': 6}, 'D': (1, 2, 3), 'B': [4, 5, 6], 'E': 0}) +>>> Ta.update(T2) +>>> Ta +RecursiveDict({'A': 1, 'B': [4, 5, 6, 1, 2, 3], 'C': {'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6}, 'D': (1, 2, 3), 'E': 0, 'F': {'h': {'i': {'j': {'k': 'abcdefghijk'}}}}}) +>>> Tb.update(T1) +>>> Tb +RecursiveDict({'C': {'d': 4, 'e': 5, 'f': 6, 'a': 1, 'b': 2, 'c': 3}, 'D': {}, 'B': [1, 2, 3, 4, 5, 6], 'E': None, 'A': 1, 'F': {'h': {'i': {'j': {'k': 'abcdefghijk'}}}}}) +>>> + +""" + +TEST_DICT_1 = { 'A':1, + 'B':[1,2,3], + 'C':{'a':1, 'b':2, 'c':3}, + 'D':{}, + 'E':None, + 'F':{'h':{'i':{'j':{'k':'abcdefghijk'}}}}, + } + +TEST_DICT_2 = { 'C':{'d':4, 'e':5, 'f':6}, + 'D':(1,2,3), + 'B':[4,5,6], + 'E':0 + } + +YAML_TEST = """ +A: 1 +B: + - 4 + - 5 + - 6 + - 1 + - 2 + - 3 +C: + a: 1 + b: 2 + c: 3 + d: 4 + e: 5 + f: 6 +D: (1, 2, 3) +E: 0 +F: + h: + i: + j: + k: abcdefghijk +""" + +import os, collections.abc, re +import yaml + +wordre = re.compile(r'([A-Z]+[a-z]*|[a-z]+|[0-9]+)') + +class OrderedSet(collections.abc.Set): + """ + List-based set from Python documentation example. + """ + def __init__(self, iterable): + self.elements = lst = [] + for value in iterable: + if value not in lst: + lst.append(value) + + def __iter__(self): + return iter(self.elements) + + def __contains__(self, value): + return value in self.elements + + def __len__(self): + return len(self.elements) + + def __repr__(self): + return repr(list(self)) + + def union(self, other): + return self.__or__(other) + + def intersection(self, other): + return self.__and__(other) + +class UnionList(list): + """ + Special list-based collection, which implements a "union" operator similar + to the one defined for sets. It only adds options from the other list + which are not already in the current list. + + Note that it is intentionally asymmetric. The initial list may repeat values + and they will be kept, so it does not require the list to consist only of + unique entries (unlike Set collections). + + This allows us to use this type for loading list-oriented data from data + files, which may or may not contain repetitions for different uses, but + also makes accumulation idempotent (running the union twice will not + increase the size of the result, because no new values will be found). + """ + def union(self, other): + combined = UnionList(self) + for element in other: + if element not in self: + combined.append(element) + return combined + +class RecursiveDict(collections.OrderedDict): + """ + A dictionary which updates recursively, updating any values which are + themselves dictionaries when the replacement value is a dictionary, rather + than replacing them, and treating any values which are themselves lists + as UnionLists and applying the union operation to combine them + (when the replacement value is also a list). + """ + def clear(self): + for key in self: + del self[key] + + def update(self, mapping): + for key in mapping: + if key in self: + if (isinstance(self[key], collections.abc.Mapping) and + isinstance(mapping[key], collections.abc.Mapping)): + # Subdictionary + newvalue = RecursiveDict(self[key]) + newvalue.update(RecursiveDict(mapping[key])) + self[key] = newvalue + + elif ((isinstance(self[key], collections.abc.MutableSequence) or + isinstance(self[key], collections.abc.Set)) and + (isinstance(mapping[key], collections.abc.MutableSequence) or + isinstance(mapping[key], collections.abc.Set))): + # Sublist + self[key] = UnionList(self[key]).union(UnionList(mapping[key])) + + else: # scalar + self[key] = mapping[key] + + else: # new key + self[key] = mapping[key] + + def get_data(self): + new = {} + for key in self: + if isinstance(self[key], RecursiveDict): + new[key]=dict(self[key].get_data()) + elif isinstance(self[key], UnionList): + new[key]=list(self[key]) + else: + new[key]=self[key] + return new + + def __setitem__(self, key, value): + if isinstance(value, collections.abc.Mapping): + super().__setitem__(key, RecursiveDict(value)) + + elif isinstance(value, collections.abc.MutableSequence): + super().__setitem__(key, UnionList(value)) + + else: + super().__setitem__(key,value) + + def __repr__(self, compact=False): + s = '' + if not compact: + s = s + '%s(' % self.__class__.__name__ + s = s + '{' + for key in self: + if isinstance(self[key], RecursiveDict): + s = s+"'%s'"%key + ': ' + "%s" % self[key].__repr__(compact=True) + ', ' + else: + s = s+ "'%s'"%key + ': ' + "%s" % repr(self[key]) + ', ' + if s.endswith(', '): s= s[:-2] + s = s + '}' + if not compact: + s = s + ')' + return s + + def from_yaml(self, yaml_string): + self.update(yaml.safe_load(yaml_string)) + return self + + def from_yaml_file(self, path): + with open(path, 'rt') as yamlfile: + self.update(yaml.safe_load(yamlfile)) + return self + + def to_yaml(self): + return yaml.dump(self.get_data()) + + def to_yaml_file(self, path): + with open(path, 'wt') as yamlfile: + yamlfile.write(yaml.dump(self.get_data())) + + +#-------- +# Code for collecting the YAML files we need + + +def collect_yaml_files(path, stems, dirmatch=False, sidecar=False, root='/'): + """ + Collect a list of file paths to YAML files. + + Does not attempt to read or interpret the files. + + @path: The starting point, typically the antecedent filename. + @stems: File stem (or sequence of stems) we recognize (in priority order). + @dirmatch: Also search for stems matching the containing directory name? + @sidecar: Also search for stems matching the antecent filename's stem? + @root: Top level directory to consider (do not search above this). + + "Stem" means the name with any extension after "." removed (typically, + the filetype). + """ + yaml_paths = [] + if type(stems) is str: + stems = (stems,) + + path = os.path.abspath(path) + path, filename = os.path.split(path) + if sidecar: + filestem = os.path.splitext(filename)[0] + sidecar_path = os.path.join(path, filestem + '.yaml') + if os.path.isfile(sidecar_path): + yaml_paths.append(sidecar_path) + + while not os.path.abspath(path) == os.path.dirname(root): + path, base = os.path.split(path) + + if dirmatch: + yaml_path = os.path.join(path, base, base + '.yaml') + if os.path.isfile(yaml_path): + yaml_paths.append(yaml_path) + + for stem in stems: + yaml_path = os.path.join(path, base, stem + '.yaml') + if os.path.isfile(yaml_path): + yaml_paths.append(yaml_path) + + yaml_paths.reverse() + return yaml_paths + + +def has_project_root(yaml_path): + with open(yaml_path, 'rt') as yaml_file: + data = yaml.safe_load(yaml_file) + if 'project_root' in data: + return True + else: + return False + +def trim_to_project_root(yaml_paths): + for i in range(len(yaml_paths)-1,-1,-1): + if has_project_root(yaml_paths[i]): + return yaml_paths[i:] + return yaml_paths + +def get_project_root(yaml_paths): + trimmed = trim_to_project_root(yaml_paths) + if trimmed: + return os.path.dirname(trimmed[0]) + else: + # No project root was found! + return '/' + +def combine_yaml(yaml_paths): + data = RecursiveDict() + for path in yaml_paths: + with open(path, 'rt') as yaml_file: + data.update(yaml.safe_load(yaml_file)) + return data + +def get_project_data(filepath): + # First, get the KitCAT data. + kitcat_paths = collect_yaml_files(filepath, + ('kitcat', 'project'), dirmatch=True, sidecar=True) + + kitcat_data = combine_yaml(trim_to_project_root(kitcat_paths)) + + kitcat_root = get_project_root(kitcat_paths) + + abx_data = combine_yaml(collect_yaml_files(filepath, + 'abx', root=kitcat_root)) + + return kitcat_root, kitcat_data, abx_data + + + \ No newline at end of file diff --git a/abx/blender_context.py b/abx/blender_context.py new file mode 100644 index 0000000..1a7d217 --- /dev/null +++ b/abx/blender_context.py @@ -0,0 +1,169 @@ +# blender_context.py +""" +Contextual metadata acquired from internal values in a Blender file. + +This module must be invoked from within Blender to work, as it relies on the bpy Blender API +module and the currently-open Blender file's data graph in order to work. + +It collects data about scenes, objects, groups, and other datablocks in the Blender file, +as well as data encoded in text blocks in different formats. Overall file data is incorporated +into a PropertyGroup attached to the "WindowManager" object identified as 'WinMan' (normally, +it appears there is only ever one of these in a Blender file, but if there is more than one, this +is the one that will be used). +""" + +import io +import bpy, bpy.app, bpy.props, bpy.utils +from bpy.app.handlers import persistent +from accumulate import UnionList, RecursiveDict +import yaml + +def EnumFromList(schema, listname): + return [(e, e.capitalize(), e.capitalize()) for e in schema[listname]] + +prop_types = { + 'string':{ + 'property': bpy.props.StringProperty, + 'keywords': { 'name', 'description', 'default', 'maxlen', 'options', 'subtype'}, + 'translate': { + 'desc': ('description', None)}}, + 'enum': { + 'property': bpy.props.EnumProperty, + 'keywords': { 'items', 'name', 'description', 'default', 'options'}, + 'translate': { + 'desc': ('description', None), + 'items_from': ('items', EnumFromList)}}, + 'int': { + 'property': bpy.props.IntProperty, + 'keywords': { 'name', 'description', 'default', 'min', 'max', 'soft_min', 'soft_max', + 'step', 'options', 'subtype'}, + 'translate': { + 'desc': ('description', None)}}, + 'float': { + 'property': bpy.props.FloatProperty, + 'keywords': { 'name', 'description', 'default', 'min', 'max', 'soft_min', 'soft_max', + 'step', 'options', 'subtype', 'precision', 'unit'}, + 'translate': { + 'desc': ('description', None)}}, + 'bool': { + 'property': bpy.props.BoolProperty, + 'keywords': { 'name', 'description', 'default', 'options', 'subtype'}, + 'translate': { + 'desc': ('description', None)}} + } + +class AbxMeta(bpy.types.PropertyGroup): + """ + Metadata property group factory for attachment to Blender object types. + Definitions come from a YAML source (or default defined below). + """ + default_schema = yaml.safe_load(io.StringIO("""\ +--- +blender: + - id: project + type: string + level: project + name: Project Name + desc: Name of the project + maxlen: 32 + + - id: project_title + type: string + level: project + name: Project Title + desc: Full title for the project + maxlen: 64 + + - id: project_description + type: string + level: project + name: Project Description + desc: Brief description of the project + maxlen: 128 + + - id: project_url + type: list string + level: project + name: Project URL + desc: URL for Project home page, or comma-separated list of Project URLs + + - id: level + type: enum + items_from: levels + name: Level + desc: Level of the file in the project hierarchy + +levels: + - project + - series + - episode + - seq + - subseq + - camera + - shot + - element + - frame + +hierarchies: + - library + - episodes + """)) + + def __new__(cls, schema=default_schema): + class CustomPropertyGroup(bpy.types.PropertyGroup): + pass + for definition in schema['blender']: + # Translate and filter parameters + try: + propmap = prop_types[definition['type']] + except KeyError: + # If no 'type' specified or 'type' not found, default to string: + propmap = prop_types['string'] + + filtered = {} + for param in definition: + if 'translate' in propmap and param in propmap['translate']: + filter = propmap['translate'][param][1] + if callable(filter): + # Filtered translation + filtered[propmap['translate'][param][0]] = filter(schema, definition[param]) + else: + # Simple translation + filtered[propmap['translate'][param][0]] = definition[param] + + # Create the Blender Property object + kwargs = dict((key,filtered[key]) for key in propmap['keywords'] if key in filtered) + setattr(CustomPropertyGroup, definition['id'], propmap['property'](**kwargs)) + + bpy.utils.register_class(CustomPropertyGroup) + return(CustomPropertyGroup) + + + +class BlenderContext(RecursiveDict): + """ + Dictionary accumulating data from sources within the currently-open Blender file. + """ + filepath = '' + defaults = {} + + def __init__(self): + self.clear() + + @classmethod + def update(cls): + try: + cls.file_metadata = bpy.data.window_managers['WinMan'].metadata + except AttributeError: + bpy.data.window_managers['WinMan'].new(FileMeta()) + + + def clear(self): + for key in self: + del self[key] + self.update(self.defaults) + + + + + \ No newline at end of file diff --git a/abx/context.py b/abx/context.py new file mode 100644 index 0000000..0ea3c65 --- /dev/null +++ b/abx/context.py @@ -0,0 +1,26 @@ +# context.py +""" +Combines context sources to create AbxContext object (dictionary tree). +""" + +import bpy, bpy.app, bpy.data, bpy.ops + +from bpy.app.handlers import persistent +#from accumulate import UnionList, RecursiveDict + +from . import file_context + +if os.path.exists(bpy.data.filepath): + BlendfileContext = file_context.FileContext(bpy.data.filepath) +else: + BlendfileContext = file_context.FileContext() + +# Attach a handler to keep our filepath context up to date with Blender +@persistent +def update_handler(ctxt): + BlendfileContext.update(bpy.data.filepath) + +bpy.app.handlers.save_post.append(update_handler) +bpy.app.handlers.load_post.append(update_handler) +bpy.app.handlers.scene_update_post.append(update_handler) + diff --git a/abx/copy_anim.py b/abx/copy_anim.py new file mode 100644 index 0000000..b3a3c59 --- /dev/null +++ b/abx/copy_anim.py @@ -0,0 +1,126 @@ +# copy_anim.py +""" +Blender Python code to copy animation between armatures or proxy armatures. +""" + +import bpy, bpy.types, bpy.utils, bpy.props + +#---------------------------------------- +## TOOLS +# This might be moved into another module later + +def copy_object_animation(sourceObj, targetObjs, + dopesheet=False, nla=False, rescale=False, scale_factor=1.0, + report=print): + """ + Copy Dope Sheet & NLA editor animation from active object to selected objects. + Most useful with armatures. Assumes bones match. Can be rescaled in the process. + + From StackExchange post: + https://blender.stackexchange.com/questions/74183/how-can-i-copy-nla-tracks-from-one-armature-to-another + """ + for targetObj in targetObjs: + if targetObj.animation_data is not None: + targetObj.animation_data_clear() + + targetObj.animation_data_create() + + source_animation_data = sourceObj.animation_data + target_animation_data = targetObj.animation_data + + # copy the dopesheet animation (active animation) + if dopesheet: + report({'INFO'}, 'Copying Dopesheet animation') + if source_animation_data.action is None: + report({'WARNING'}, + "CLEARING target dope sheet - old animation saved with 'fake user'") + if target_animation_data.action is not None: + target_animation_data.action.use_fake_user = True + target_animation_data.action = None + else: + if rescale: + target_animation_data.action = copy_animation_action_with_rescale( + source_animation_data.action, scale_factor) + else: + target_animation_data.action = copy_animation_action_with_rescale( + source_animation_data.action, scale_factor) + + target_animation_data.action.name = targetObj.name + 'Action' + + if nla: + report({'INFO'}, 'Copying NLA strips') + if source_animation_data: + # Create new NLA tracks based on the source + for source_nla_track in source_animation_data.nla_tracks: + target_nla_track = target_animation_data.nla_tracks.new() + target_nla_track.name = source_nla_track.name + # In each track, create action strips base on the source + for source_action_strip in source_nla_track.strips: + + if rescale: + new_action = copy_animation_action_with_rescale( + source_action_strip.action, scale_factor) + else: + new_action = source_action_strip.action + + target_action_strip = target_nla_track.strips.new( + new_action.name, + source_action_strip.frame_start, + new_action) + + # For each strip, copy the properties -- EXCEPT the ones we + # need to protect or can't copy + # introspect property names (is there a better way to do this?) + props = [p for p in dir(source_action_strip) if + not p in ('action',) + and not p.startswith('__') and not p.startswith('bl_') + and source_action_strip.is_property_set(p) + and not source_action_strip.is_property_readonly(p) + and not source_action_strip.is_property_hidden(p)] + for prop in props: + setattr(target_action_strip, prop, getattr(source_action_strip, prop)) + + +# Adapted from reference: +# https://www.reddit.com/r/blender/comments/eu3w6m/guide_how_to_scale_a_rigify_rig/ +# + +def reset_armature_stretch_constraints(rig_object): + """ + Reset stretch-to constraints on an armature object - necessary after rescaling. + """ + bone_count = 0 + for bone in rig_object.pose.bones: + for constraint in bone.constraints: + if constraint.type == "STRETCH_TO": + constraint.rest_length = 0 + bone_count += 1 + return bone_count + + +def rescale_animation_action_in_place(action, scale_factor): + """ + Rescale a list of animation actions by a scale factor (in-place). + """ + #for fcurve in bpy.data.actions[action].fcurves: + for fcurve in action.fcurves: + data_path = fcurve.data_path + if data_path.startswith('pose.bones[') and data_path.endswith('].location'): + for p in fcurve.keyframe_points: + p.co[1] *= scale_factor + p.handle_left[1] *= scale_factor + p.handle_right[1] *= scale_factor + return action + +def copy_animation_action_with_rescale(action, scale_factor): + """ + Copy an animation action, rescaled. + """ + new_action = action.copy() + new_action.name = new_action.name[:-4]+'.rescale' + return rescale_animation_action_in_place(new_action, scale_factor) + + + + +#---------------------------------------- diff --git a/abx/file_context.py b/abx/file_context.py new file mode 100644 index 0000000..10769f2 --- /dev/null +++ b/abx/file_context.py @@ -0,0 +1,1038 @@ +# file_context.py +""" +Contextual metadata acquired from the file system, file name, directory structure, and +sidecar data files. + +Data is acquired from file and directory names and also from yaml files in the tree. +The yaml files are loaded in increasing priority from metadata.yaml, abx.yaml, .yaml. +They are also loaded from the top of the tree to the bottom, with the most local Values +overriding the top-level ones. + +@author: Terry Hancock + +@copyright: 2019 Anansi Spaceworks. + +@license: GNU General Public License, version 2.0 or later. (Python code) + Creative Commons Attribution-ShareAlike, version 3.0 or later. (Website Templates). + +@contact: digitante@gmail.com + +Demo: +>>> +>>> fc = FileContext(TESTPATH) + +>>> fc.notes +['Data from implicit + explicit sources'] + +>>> fc['project']['name'] +'My Project' + +>>> fc['episode']['code'] +1 + +>>> fc['rank'] +'block' + +>>> fc['block']['title'] +'Beginning Of End' + +>>> fc['seq']['title'] +'LastPoint' + +>>> fc['episode']['title'] +'Pilot' +>>> fc['hierarchy'] +'episode' + +>>> fc['filename'] +'A.001-LP-1-BeginningOfEnd-anim.txt' + +>>> fc['path'] +'/project/terry/Dev/eclipse-workspace/ABX/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.txt' + +>>> fc.root +'/project/terry/Dev/eclipse-workspace/ABX/testdata/myproject' + +""" + +import os, re, copy, string, collections +import yaml + +TESTPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'testdata', 'myproject', 'Episodes', 'A.001-Pilot', 'Seq', 'LP-LastPoint', 'A.001-LP-1-BeginningOfEnd-anim.txt')) + +from . import accumulate + +from .accumulate import RecursiveDict + +wordre = re.compile(r'([A-Z][a-z]+|[a-z]+|[0-9]+|[A-Z][A-Z]+)') + +NameParsers = {} # Parser registry + +def registered_parser(parser): + """ + Decorator function to register a parser class. + """ + NameParsers[parser.name] = parser + return parser + +@registered_parser +class Parser_ABX_Episode: + """ + Default filename parsing algorithm. + + Assumes field-based filenames of the form: + + E[-[-[-Cam][-]]][-]-<role>.<filetype> + + Where the <field> indicates fields with fieldnames, and there are three expected separators: + + - is the 'field_separator' + E is the 'episode_separator' + . is the 'filetype_separator' + + (These can be overridden in the initialization). + The class is callable, taking a string as input and returning a dictionary of fields. + """ + name = 'abx_episode' + + max_score = 10 # Maximum number of fields parsed + + # supported values for filetype + filetypes = { + 'blend': "Blender File", + 'kdenlive': "Kdenlive Video Editor File", + 'mlt': "Kdenlive Video Mix Script", + 'svg': "Scalable Vector Graphics (Inkscape)", + 'kra': "Krita Graphic File", + 'xcf': "Gimp Graphic File", + 'png': "Portable Network Graphics (PNG) Image", + 'jpg': "Joint Photographic Experts Group (JPEG) Image", + 'aup': "Audacity Project", + 'ardour': "Ardour Project", + 'flac': "Free Lossless Audio Codec (FLAC)", + 'mp3': "MPEG Audio Layer III (MP3) Audio File", + 'ogg': "Ogg Vorbis Audio File", + 'avi': "Audio Video Interleave (AVI) Video Container", + 'mkv': "Matroska Video Container", + 'mp4': "Moving Picture Experts Group (MPEG) 4 Format}", + 'txt': "Plain Text File" + } + + # Roles that make sense in an episode context + roles = { + 'extras': "Extras, crowds, auxillary animated movement", + 'mech': "Mechanical animation", + 'anim': "Character animation", + 'cam': "Camera direction", + 'vfx': "Visual special effects", + 'compos': "Compositing", + 'bkg': "Background 2D image", + 'bb': "Billboard 2D image", + 'tex': "Texture 2D image", + 'foley': "Foley sound", + 'voice': "Voice recording", + 'fx': "Sound effects", + 'music': "Music track", + 'cue': "Musical cue", + 'amb': "Ambient sound", + 'loop': "Ambient sound loop", + 'edit': "Video edit" + } + + # A few filetypes imply their roles: + roles_by_filetype = { + 'kdenlive': 'edit', + 'mlt': 'edit' + } + + + def __init__(self, field_separator='-', episode_separator='E', filetype_separator='.', + fields=None, filetypes=None, roles=None, **kwargs): + if not fields: + fields = {} + if filetypes: + self.filetypes = copy.deepcopy(self.filetypes) # Copy class attribute to instance + self.filetypes.update(filetypes) # Update with new values + if roles: + self.roles = copy.deepcopy(self.roles) # Copy class attribute to instance + self.roles.update(roles) # Update with new values + self.field_separator = field_separator + self.episode_separator = episode_separator + self.filetype_separator = filetype_separator + + def __call__(self, filename, namepath): + score = 0.0 + fielddata = {} + + # Check for filetype ending + i_filetype = filename.rfind(self.filetype_separator) + if i_filetype < 0: + fielddata['filetype'] = None + else: + fielddata['filetype'] = filename[i_filetype+1:] + filename = filename[:i_filetype] + score = score + 1.0 + + components = filename.split(self.field_separator) + + # Check for role marker in last component + if components[-1] in self.roles: + fielddata['role'] = components[-1] + del components[-1] + fielddata['hierarchy'] = 'episode' + score = score + 2.0 + elif fielddata['filetype'] in self.roles_by_filetype: + fielddata['role'] = self.roles_by_filetype[fielddata['filetype']] + fielddata['hierarchy'] = 'episode' + else: + fielddata['role'] = None + fielddata['hierarchy'] = None + + # Check for a descriptive title (must be 3+ characters in length) + if components and len(components[-1])>2: + # Normalize the title as words with spaces + title = ' '.join(w for w in wordre.split(components[-1]) if wordre.fullmatch(w)) + del components[-1] + score = score + 1.0 + else: + title = None + + # Check if first field contains series/episode number + if components: + prefix = components[0] + try: + fielddata['series'] = {} + fielddata['episode'] = {} + fielddata['series']['code'], episode_id = prefix.split(self.episode_separator) + fielddata['episode']['code'] = int(episode_id) + fielddata['rank'] = 'episode' + del components[0] + score = score + 2.0 + except: + pass + + # Check for sequence/block/shot/camera designations + if components: + fielddata['seq'] = {} + fielddata['seq']['code'] = components[0] + fielddata['rank'] = 'seq' + del components[0] + score = score + 1.0 + + if components: + try: + fielddata['block'] = {} + fielddata['block']['code'] = int(components[0]) + del components[0] + fielddata['rank'] = 'block' + score = score + 1.0 + except: + pass + + if components and components[0].startswith('Cam'): + fielddata['camera'] = {} + fielddata['camera']['code'] = components[0][len('Cam'):] + fielddata['rank'] = 'camera' + del components[0] + score = score + 1.0 + + if components: + # Any remaining structure is joined back to make the shot ID + fielddata['shot'] = {} + fielddata['shot']['code'] = ''.join(components) + fielddata['rank'] = 'shot' + components = None + score = score + 1.0 + + if title and fielddata['rank'] in fielddata: + fielddata[fielddata['rank']]['title'] = title + + return score/self.max_score, fielddata + +@registered_parser +class Parser_ABX_Schema(object): + """ + Parser based on using the project_schema defined in the project root directory YAML. + """ + name = 'abx_schema' + + def __init__(self, schemas=None, definitions=None, + filetype_separator = '.', + comment_separator = '--', + role_separator = '-', + title_separator = '-', + **kwargs): + + self.filetype_separator = filetype_separator + self.comment_separator = comment_separator + self.role_separator = role_separator + self.title_separator = title_separator + + self.schemas = schemas + + if 'roles' in definitions: + self.roles = definitions['roles'] + else: + self.roles = [] + + if 'filetypes' in definitions: + self.filetypes = definitions['filetypes'] + else: + self.filetypes = [] + + if 'roles_by_filetype' in definitions: + self.roles_by_filetype = definitions['roles_by_filetype'] + else: + self.roles_by_filetype = [] + + def _parse_ending(self, filename, separator): + try: + remainder, suffix = filename.rsplit(separator, 1) + score = 1.0 + except ValueError: + remainder = filename + suffix = None + score = 0.0 + return (suffix, remainder, score) + + def _parse_beginning(self, filename, separator): + try: + prefix, remainder = filename.split(separator, 1) + score = 1.0 + except ValueError: + prefix = filename + remainder = '' + score = 0.0 + return (prefix, remainder, score) + + def __call__ (self, filename, namepath, debug=False): + fields = {} + score = 0.0 + possible = 0.0 + + # First get specially-handled extensions + remainder = filename + field, newremainder, s = self._parse_ending(remainder, self.filetype_separator) + if field and field in self.filetypes: + remainder = newremainder + fields['filetype'] = field + score += s*1.0 + else: + fields['filetype'] = None + + field, remainder, s = self._parse_ending(remainder, self.comment_separator) + fields['comment'] = field + score += s*0.5 + + field, newremainder, s = self._parse_ending(remainder, self.role_separator) + if field and field in self.roles: + remainder = newremainder + fields['role'] = field + score += s*0.5 + else: + fields['role'] = None + + field, remainder, s = self._parse_ending(remainder, self.title_separator) + fields['title'] = field + score += s*0.5 + + possible += 3.0 + + # Implicit roles + if ( not fields['role'] and + fields['filetype'] and + fields['role'] in self.roles_by_filetype): + self.role = self.roles_by_filetype[fields['filetype']] + score += 0.2 + + #possible += 0.2 + + # Figure the rest out from the schema + # Find the matching rank start position for the filename + start = 0 + for start, (schema, name) in enumerate(zip(self.schemas, namepath)): + field, r, s = self._parse_beginning(remainder, schema.delimiter) + if field.lower() == schema.format.format(name).lower(): + score += 1.0 + break + + possible += 1.0 + + # Starting from that position, try to match fields + # up to the end of the namepath (checking against it) + irank = 0 + for irank, (schema, name) in enumerate( + zip(self.schemas[start:], namepath[start:])): + if not remainder: break + field, remainder, s = self._parse_beginning(remainder, schema.delimiter) + score += s + if ( type(field) == str and + field.lower() == schema.format.format(name).lower()): + fields[schema.rank]={'code':field} + fields['rank'] = schema.rank + score += 1.0 + possible += 2.0 + + # Remaining fields are authoritative (doesn't affect score) + for schema in self.schemas[irank:]: + if not remainder: break + field, remainder, s = self._parse_beginning(remainder, schema.delimiter) + fields[schema.rank]={'code':field} + fields['rank'] = schema.rank + + if 'rank' in fields: + fields[fields['rank']]['title'] = fields['title'] + + if not fields['role'] and fields['filetype'] in self.roles_by_filetype: + fields['role'] = self.roles_by_filetype[fields['filetype']] + + return score/possible, fields + + +class RankNotFound(LookupError): + pass + +class NameSchema(object): + """ + Represents a schema used for parsing and constructing designations, names, etc. + """ + # Defaults + _default_schema = { + 'delimiter':'-', + + 'type': 'string', + 'format':'{:s}', + 'minlength':1, # Must be at least one character + 'maxlength':0, # 0 means unlimited + 'words': False, # If true, treat value as words and spaces + 'pad': '0', # Left-padding character for fixed length + 'default': None, + + 'rank': 'project', + 'irank': 0, + 'ranks': ('series', 'episode', 'sequence', + 'block', 'camera', 'shot', 'element') + } + + _codetypes = { + 'number':{}, + 'string':{}, + 'letter':{}, + 'lowercase':{}, + } + + _letter = tuple((A,A,A) for A in string.ascii_uppercase) + _lowercase = tuple((a,a,a) for a in string.ascii_lowercase) + + rank = 'project' + irank = 0 + default = None + + ranks = ('project',) + + def __init__(self, parent=None, rank=None, schema=None, debug=False): + # Three types of schema data: + + # Make sure schema is a copy -- no side effects! + if not schema: + schema = {} + else: + s = {} + s.update(schema) + schema = s + + if not rank and 'rank' in schema: + rank = schema['rank'] + + # Stepped down in rank from parent: + self.parent = parent + + if parent and rank: + # Check rank is defined in parent ranks and use that + # We can skip optional ranks + if rank in parent.ranks: + j = parent.ranks.index(rank) + self.ranks = parent.ranks[j+1:] + self.rank = rank + else: + # It's an error to ask for a rank that isn't defined + raise RankNotFound( + '"%s" not in defined ranks for "%s"' % (rank, parent)) + + elif parent and not rank: + # By default, get the first rank below parent + self.rank = parent.ranks[0] + self.ranks = parent.ranks[1:] + + elif rank and not parent: + # With no parent, we're starting a new tree and renaming the root + self.rank = rank + self.ranks = self._default_schema['ranks'] + + else: # not rank and not parent: + # New tree with default rank + self.rank = self._default_schema['rank'] + self.ranks = self._default_schema['ranks'] + + # Directly inherited/acquired from parent + # So far, only need a delimiter specified, but might be other stuff + self.delimiter = self._default_schema['delimiter'] + if parent and parent.delimiter: self.delimiter = parent.delimiter + + # Explicit override by the new schema: + if 'ranks' in schema: self.ranks = schema['ranks'] + if 'delimiter' in schema: self.delimiter = schema['delimiter'] + if 'default' in schema: + if schema['default'] == 'None': + self.default = None + else: + self.default = schema['default'] + + # Default unless specified (i.e. not inherited from parent) + newschema = {} + newschema.update(self._default_schema) + newschema.update(schema) + + self.format = str(newschema['format']) + + self.minlength = int(newschema['minlength']) + self.maxlength = int(newschema['maxlength']) + self.pad = str(newschema['pad']) + self.words = bool(newschema['words']) + + if newschema['type'] == 'letter': + self.codetype = self._letter + + elif newschema['type'] == 'lowercase': + self.codetype = self._lowercase + + elif newschema['type'] == 'number': + # Recognized Python types + self.codetype = int + if 'minlength' or 'maxlength' in schema: + self.format = '{:0>%dd}' % self.minlength + + elif newschema['type'] == 'string': + self.codetype = str + + if ('minlength' in schema) or ('maxlength' in schema): + if self.maxlength == 0: + # Special case for unlimited length + self.format = '{:%1.1s>%ds}' % (self.pad, self.minlength) + self.format = '{:%1.1s>%d.%ds}' % ( + self. pad, self.minlength, self.maxlength) + + elif newschema['type'] == 'bool': + self.codetype = bool + + elif isinstance(newschema['type'], collections.Sequence): + # Enumerated types + # This is somewhat specific to Blender -- setting the + # enumeration values requires a sequence in a particular format + self.codetype = [] + for option in newschema['type']: + if type(option) is not str and isinstance(option, collections.Sequence): + option = tuple([str(e) for e in option][:3]) + else: + option = (str(option), str(option), str(option)) + self.codetype.append(option) + + elif isinstance(newschema['type'], collections.Mapping): + self.codetype = [] + for key, val in newschema['type'].items(): + if type(val) is not str and isinstance(val, collections.Sequence): + if len(val) == 0: + option = (str(key), str(key), str(key)) + elif len(val) == 1: + option = (str(key), str(val[0]), str(val[0])) + else: + option = (str(key), str(val[0]), str(val[1])) + else: + option = (str(key), str(val), str(val)) + self.codetype.append(option) + else: + # If all else fails, just list the string + self.codetype = None + + + + def __repr__(self): + return('<(%s).NameSchema: %s (%s, %s, %s, (%s))>' % ( + repr(self.parent), + #self.irank, + self.rank, + self.delimiter, + self.default, + self.format, + self.codetype + )) + + +class NameContext(object): + """ + Single naming context within the file (e.g. a Blender scene). + """ + + def __init__(self, container, fields=None, namepath_segment=(), ): + self.schemas = [] + self.fields = {} + self.container = container + + if namepath_segment: + self.namepath_segment = namepath_segment + else: + self.namepath_segment = [] + + try: + #self.namepath = self.container.namepath + self.schemas = self.container.schemas + + except AttributeError: + self.schemas = [] + #self.namepath = [] + + try: + self.omit_ranks = self.container.omit_ranks + except AttributeError: + self.omit_ranks = {} + self.omit_ranks.update({ + 'edit': 0, + 'render': 1, + 'filename': 1, + 'scene': 3}) + + if fields: + if isinstance(fields, dict): + self.fields.update(fields) + elif isinstance(fields, str): + self.fields.update(yaml.safe_load(fields)) + +# if 'code' in self.fields: +# self.namepath.append(self.fields['code']) + + #self.code = self.fields[self.rank]['code'] + + + def update_fields(self, data): + self.fields.update(data) + + def _load_schemas(self, schemas, start=0): + """ + Load schemas from a list of schema dictionaries. + + @schemas: list of dictionaries containing schema field data (see NameSchema). + The data will typically be extracted from YAML, and is + expected to be a list of dictionaries, each of which defines + fields understood by the NameSchema class, to instantiate + NameSchema objects. The result is a linked chain of schemas from + the top of the project tree down. + + @start: if a start value is given, the top of the existing schema + chain is kept, and the provided schemas starts under the rank of + the start level in the existing schema. This is what happens when + the schema is locally overridden at some point in the hierarchy. + """ + self.schemas = self.schemas[:start] + if self.schemas: + last = self.schemas[-1] + else: + last = None + for schema in schemas: + self.schemas.append(NameSchema(last, schema['rank'], schema=schema)) + #last = self.schemas[-1] + + def _parse_words(self, wordtext): + words = [] + groups = re.split(r'[\W_]', wordtext) + for group in groups: + if len(group)>1: + group = group[0].upper() + group[1:] + words.extend(re.findall(r'[A-Z][a-z]*', group)) + elif len(group)==1: + words.append(group[0].upper()) + else: + continue + return words + + def _cap_words(self, words): + return ''.join(w.capitalize() for w in words) + + def _underlower_words(self, words): + return '_'.join(w.lower() for w in words) + + def _undercap_words(self, words): + return '_'.join(w.capitalize() for w in words) + + def _spacecap_words(self, words): + return ' '.join(w.capitalize() for w in words) + + def _compress_name(self, name): + return self._cap_words(self._parse_words(name)) + + @property + def namepath(self): + if self.container: + return self.container.namepath + self.namepath_segment + else: + return self.namepath_segment + + @property + def rank(self): + if 'rank' in self.fields: + return self.fields['rank'] + else: + return None + + @rank.setter + def set_rank(self, rank): + self.fields['rank'] = rank + + @property + def name(self): + if 'name' in self.fields: + return self.fields['name'] + elif 'title' in self.fields: + return self._compress_name(self.fields['title']) +# elif 'code' in self.fields: +# return self.fields['code'] + else: + return '' + + @name.setter + def set_name(self, name): + self.fields['name'] = name + + @property + def code(self): + if self.rank: + return self.fields[self.rank]['code'] + else: + return self.fields['code'] + + @code.setter + def code_setter(self, code): + if self.rank: + self.fields[self.rank] = {'code': code} + else: + self.fields['code'] = code + + @property + def description(self): + if 'description' in self.fields: + return self.fields['description'] + else: + return '' + + @description.setter + def set_description(self, description): + self.fields['description'] = str(description) + + def _get_name_components(self): + components = [] + for code, schema in zip(self.namepath, self.schemas): + if code is None: continue + components.append(schema.format.format(code)) + components.append(schema.delimiter) + return components[:-1] + + @property + def fullname(self): + if self.name: + return (self.designation + + self.schemas[-1].delimiter + + self._compress_name(self.name) ) + else: + return self.designation + + @property + def designation(self): + return ''.join(self._get_name_components()) + + @property + def shortname(self): + namebase = self.omit_ranks['filename']*2 + return (''.join(self._get_name_components()[namebase:]) + + self.schemas[-1].delimiter + + self._compress_name(self.name)) + + def get_scene_name(self, suffix=''): + namebase = self.omit_ranks['scene']*2 + desig = ''.join(self._get_name_components()[namebase:]) + + if suffix: + return desig + ' ' + suffix + else: + return desig + + def get_render_path(self, suffix='', framedigits=5, ext='png'): + + desig = ''.join(self._get_name_components()[self.omit_ranks['render']+1:]) + + if ext in ('avi', 'mov', 'mp4', 'mkv'): + if suffix: + path = os.path.join(self.render_root, suffix, + desig + '-' + suffix + '.' + ext) + else: + path = os.path.join(self.render_root, ext.upper(), + desig + '.' + ext) + else: + if suffix: + path = os.path.join(self.render_root, + suffix, desig, + desig + '-' + suffix + '-f' + '#'*framedigits + '.' + ext) + else: + path = os.path.join(self.render_root, + ext.upper(), desig, + desig + '-f' + '#'*framedigits + '.' + ext) + return path + + + +class FileContext(NameContext): + """ + Collected information about an object's location on disk: metadata + about filename, directory names, and project, based on expected keywords. + """ +# hierarchies = () +# hierarchy = None + #schema = None + + filepath = None + root = None + folders = () + #ranks = () + project_units = () + + filename = None + + fields = None + #subunits = () + + code = '_' + +# defaults = { +# 'filetype': None, 'role': None, 'hierarchy': None, 'project': None, +# 'series': None, 'episode': None, 'seq': None, 'block': None, +# 'camera': None, 'shot': None, 'title': None } + + def __init__(self, path=None): + """ + Collect path context information from a given filepath. + (Searches the filesystem for context information). + """ + #self.clear() + self.notes = [] + + # First init the superclass NameContext + NameContext.__init__(self, None, {}) + + self.namepath_segment = [] + + # TODO: + # I need to specify what happens when the path isn't defined. + # (Like we might need to initialize later?) + + if path: + self.update(path) + + + def update(self, path): + # Basic File Path Info + self.filepath = os.path.abspath(path) + self.filename = os.path.basename(path) + + # Does the file path exist? + # - Should we create it? / Are we creating it? + + # We should add a default YAML file in the ABX software to guarantee + # necessary fields are in place, and to document the configuration for + # project developers. + + # Data from YAML Files + self._collect_yaml_data() + + # Did we find the YAML data for the project? + # Did we find the project root? + + # TODO: Bug? + # Note that 'project_schema' might not be correct if overrides are given. + # As things are, I think it will simply append the overrides, and this + # may lead to odd results. We'd need to actively compress the list by + # overwriting according to rank + # + self._load_schemas(self.provided_data['project_schema']) + self.namepath_segment = [d['code'] for d in self.provided_data['project_unit']] + self.code = self.namepath[-1] + + # Was there a "project_schema" section? + # - if not, do we fall back to a default? + + # Was there a "project_unit" section? + # - if not, can we construct what we need from project_root & folders? + + # Is there a definitions section? + # Do we provide defaults? + + try: + self.render_root = os.path.join(self.root, + self.provided_data['definitions']['render_root']) + except KeyError: + self.render_root = os.path.join(self.root, 'Renders') + + self.omit_ranks = {} + try: + for key, val in self.provided_data['definitions']['omit_ranks'].items(): + self.omit_ranks[key] = int(val) + except KeyError: + self.omit_ranks.update({ + 'edit': 0, + 'render': 1, + 'filename': 1, + 'scene': 3}) + + # Data from Parsing the File Name + try: + self.parsers = [NameParsers[self.provided_data['definitions']['parser']](**self.schema['filenames'])] + except (TypeError, KeyError, IndexError): + self.parsers = [ + #Parser_ABX_Episode(), + Parser_ABX_Schema(self.schemas, self.provided_data['definitions'])] + + self.parser_chosen, self.parser_score = self._parse_filename() + + self.filetype = self.fields['filetype'] + self.role = self.fields['role'] + self.title = self.fields['title'] + self.comment = self.fields['comment'] + + # TODO: + # We don't currently consider the information from the folder names, + # though we could get some additional information this way + + # Empty / default attributes + self.name_contexts = [] + + + def __repr__(self): + s = '{0}(data='.format(self.__class__.__name__) + #s = s + super().__repr__() + s = s + str(self.code) + '(' + str(self.rank) + ')' + s = s + ')' + return s + + + def _parse_filename(self): + """ + Try available fuzzy data parsers on the filename, and pick the one + that returns the best score. + """ + fields = {} + best_score = 0.0 + for parser in self.parsers: + score, fielddata = parser(self.filename, self.namepath) + if score > best_score: + fields = fielddata + best_parser_name = parser.name + best_score = score + self.fields.update(fields) + self._pull_up_last_rank_fields() + return best_parser_name, best_score + + def _pull_up_last_rank_fields(self): + if ( 'rank' in self.fields and + self.fields['rank'] in self.fields and + isinstance(self.fields[self.fields['rank']], collections.Mapping) ): + for key, val in self.fields[self.fields['rank']].items(): + self.fields[key] = val + + + def _collect_yaml_data(self): + self.provided_data = RecursiveDict() + kitcat_root, kitcat_data, abx_data = accumulate.get_project_data(self.filepath) + self.root = kitcat_root + self.provided_data.update(kitcat_data) + path = os.path.abspath(os.path.normpath(self.filepath)) + root = os.path.abspath(self.root) + self.folders = [os.path.basename(self.root)] + self.folders.extend(os.path.normpath(os.path.relpath(path, root)).split(os.sep)[:-1]) + + self.abx_fields = abx_data + + + @classmethod + def deref_implications(cls, values, matchfields): + subvalues = {} + for key in values: + # TODO: is it safe to use type tests here instead of duck tests? + if type(values[key])==int and values[key] < len(matchfields): + subvalues[key]=matchfields[values[key]] + elif type(values[key]==dict): + subvalues[key]=cls.deref_implications(values[key], matchfields) + elif type(values[key]==list): + vals = [] + for val in values[key]: + vals.append(cls.deref_implications(val, matchfields)) + return subvalues + + def get_path_implications(self, path): + data = {} + prefix = r'(?:.*/)?' + suffix = r'(?:/.*)?' + for implication in self.schema['path_implications']: + matched = re.compile(prefix + implication['match'] + suffix).match(path) + if matched and matched.groups: + data.update(self.deref_implications(implication['values'], matched.groups())) + return data + + def new_name_context(self, rank=None, **kwargs): + """ + Get a subunit from the current file. + Any rank in the hierarchy may be specified, though element, shot, + camera, and block are most likely. + """ + fields = {} + fields.update(self.fields) + + namepath_segment = [] + + ranks = [s.rank for s in self.schemas] + + i_rank = len(self.namepath) + + old_rank = ranks[i_rank -1] + + # The new rank will be the highest rank mentioned, or the + # explicitly requested rank or + # one rank past the namepath + # + new_rank = self.schemas[i_rank].rank + + for schema in self.schemas[i_rank:]: + if schema.rank in kwargs: + fields[schema.rank] = {'code':kwargs[schema.rank]} + new_rank = schema.rank + namepath_segment.append(kwargs[schema.rank]) + elif rank is not None: + namepath_segment.append(schema.default) + if ranks.index(schema.rank) <= ranks.index(rank): + new_rank = schema.rank + + delta_rank = ranks.index(new_rank) - ranks.index(old_rank) + + # Truncate to the new rank: + namepath_segment = namepath_segment[:delta_rank] + + fields['rank'] = new_rank + fields['code'] = namepath_segment[-1] + + self.name_contexts.append(NameContext(self, fields, + namepath_segment=namepath_segment)) + return self.name_contexts[-1] + + + + + + + + \ No newline at end of file diff --git a/abx/render_profile.py b/abx/render_profile.py new file mode 100644 index 0000000..a2665df --- /dev/null +++ b/abx/render_profile.py @@ -0,0 +1,83 @@ +# render_profile.py +""" +Blender Python code to set parameters based on render profiles. +""" + +import bpy, bpy.types, bpy.utils, bpy.props + +from . import std_lunatics_ink + +render_formats = { + # VERY simplified and limited list of formats from Blender that we need: + # <API 'format'>: (<bpy file format>, <filename extension>), + 'PNG': ('PNG', 'png'), + 'JPG': ('JPEG', 'jpg'), + 'EXR': ('OPEN_EXR_MULTILAYER', 'exr'), + 'AVI': ('AVI_JPEG', 'avi'), + 'MKV': ('FFMPEG', 'mkv') + } + + +def set_render_from_profile(scene, profile): + if 'engine' in profile: + if profile['engine'] == 'gl': + pass + elif profile['engine'] == 'bi': + scene.render.engine = 'BLENDER_RENDER' + elif profile['engine'] == 'cycles': + scene.render.engine = 'CYCLES' + elif profile['engine'] == 'bge': + scene.render.engine = 'BLENDER_GAME' + + if 'fps' in profile: + scene.render.fps = profile['fps'] + + if 'fps_skip' in profile: + scene.frame_step = profile['fps_skip'] + + if 'format' in profile: + scene.render.image_settings.file_format = render_formats[profile['format']][0] + + if 'freestyle' in profile: + scene.render.use_freestyle = profile['freestyle'] + + if 'antialias' in profile: + if profile['antialias']: + scene.render.use_antialiasing = True + if profile['antialias'] in (5,8,11,16): + scene.render.antialiasing_samples = str(profile['antialias']) + else: + scene.render.use_antialiasing = False + + if 'motionblur' in profile: + if profile['motionblur']: + scene.render.use_motion_blur = True + if type(profile['motionblur'])==int: + scene.render.motion_blur_samples = profile['motionblur'] + else: + scene.render.use_motion_blur = False + + # Use Lunatics naming scheme for render target: + if 'framedigits' in profile: + framedigits = profile['framedigits'] + else: + framedigits = 5 + + if 'suffix' in profile: + suffix = profile['suffix'] + else: + suffix = '' + + if 'format' in profile: + rdr_fmt = render_formats[profile['format']][0] + ext = render_formats[profile['format']][1] + else: + rdr_fmt = 'PNG' + ext = 'png' + + path = std_lunatics_ink.LunaticsShot(scene).render_path( + suffix=suffix, framedigits=framedigits, ext=ext, rdr_fmt=rdr_fmt) + + scene.render.filepath = path + + \ No newline at end of file diff --git a/abx/std_lunatics_ink.py b/abx/std_lunatics_ink.py new file mode 100644 index 0000000..1e81df9 --- /dev/null +++ b/abx/std_lunatics_ink.py @@ -0,0 +1,678 @@ +# std_lunatics_ink.py +""" +Functions to set up the standard ink and paint compositing arrangement +for "Lunatics" +""" + +import os + +import bpy, bpy.props, bpy.utils + +# Hard-coded default parameters: +INK_THICKNESS = 3 +INK_COLOR = (0,0,0) +THRU_INK_THICKNESS = 2 +THRU_INK_COLOR = (20,100,50) + + + +# TODO: probably should have a dialog somewhere that can change these through the UI? + +class LunaticsShot(object): + """ + General class for Lunatics Blender Scene data. + """ + colorcode = { + 'paint': (1.00, 1.00, 1.00), + 'ink': (0.75, 0.50, 0.35), + 'thru': (0.35, 0.50, 0.75), + 'bb': (0.35, 0.75, 0.50), + 'bbthru': (0.35, 0.75, 0.75), + 'sky': (0.50, 0.25, 0.75), + 'compos': (0.75, 0.75, 0.75), + 'output': (0.35, 0.35, 0.35) + } + + def __init__(self, scene, inkthru=False, billboards=False, sepsky=False): + self.scene = scene + self.inkthru = bool(inkthru) + self.billboards = bool(billboards) + self.sepsky = bool(sepsky) + + self.series_id = scene.lunaprops.series_id + self.episode_id = scene.lunaprops.episode_id + self.seq_id = scene.lunaprops.seq_id + self.block_id = scene.lunaprops.block_id + self.shot_id = scene.lunaprops.shot_id + self.cam_id = scene.lunaprops.cam_id + self.shot_name = scene.lunaprops.shot_name + + self.render_root = '//../../Renders/' + + @property + def fullname(self): + return self.designation + '-' + self.name + + @property + def designation(self): + episode_code = "%2.2sE%2.2d" % (self.series_id, self.episode_id) + return episode_code + '-' + self.shortname + + @property + def shortname(self): + desig = str(self.seq_id) + '-' + str(self.block_id) + if self.cam_id: + desig = desig + '-Cam' + str(self.cam_id) + if self.shot_id: + desig = desig + '-' + str(self.shot_id) + return desig + + @property + def scene_name(self): + if self.shot_name: + return self.shortname + ' ' + self.shot_name + else: + return self.shortname + + def render_path(self, suffix='', framedigits=5, ext='png', rdr_fmt='PNG'): + if suffix: + suffix = '-' + suffix + if rdr_fmt in ('AVI', 'MKV'): + path = os.path.join(self.render_root, suffix, + self.designation + suffix + '.' + ext) + else: + path = os.path.join(self.render_root, suffix, self.designation, + self.designation + suffix + '-f' + '#'*framedigits + '.' + ext) + return path + + def cfg_scene(self, scene=None, thru=True, exr=True, multicam=False, role='shot'): + if not scene: + scene = self.scene + + scene.name = self.scene_name + scene.render.filepath = self.render_path() + #os.path.join(self.render_root, 'PNG', self.designation, self.designation + '-f#####.png') + scene.render.image_settings.file_format='PNG' + scene.render.image_settings.compression = 50 + scene.render.image_settings.color_mode = 'RGB' + scene.render.use_freestyle = True + + # Create Paint & Ink Render Layers + for rlayer in scene.render.layers: + rlayer.name = '~' + rlayer.name + rlayer.use = False + # Rename & turn off existing layers (but don't delete, in case they were wanted) + + scene.render.layers.new('Paint') + self.cfg_paint(scene.render.layers['Paint']) + + scene.render.layers.new('Ink') + self.cfg_ink(scene.render.layers['Ink'], + thickness=INK_THICKNESS, color=INK_COLOR) + + if self.inkthru: + scene.render.layers.new('Ink-Thru') + self.cfg_ink(scene.render.layers['Ink-Thru'], + thickness=THRU_INK_THICKNESS, color=THRU_INK_COLOR) + + if self.billboards: + scene.render.layers.new('BB-Alpha') + self.cfg_bbalpha(scene.render.layers['BB-Alpha']) + + scene.render.layers.new('BB-Mat') + self.cfg_bbmat(scene.render.layers['BB-Mat'], thru=False) + + if self.billboards and self.inkthru: + scene.render.layers.new('BB-Mat-Thru') + self.cfg_bbmat(scene.render.layers['BB-Mat-Thru'], thru=True) + + if self.sepsky: + scene.render.layers.new('Sky') + self.cfg_sky(scene.render.layers['Sky']) + + self.cfg_nodes(scene) + + def _new_rlayer_in(self, name, scene, rlayer, location, color): + tree = scene.node_tree + rlayer_in = tree.nodes.new('CompositorNodeRLayers') + rlayer_in.name = '_'.join([n.lower() for n in name.split('-')])+'_in' + rlayer_in.label = name+'-In' + rlayer_in.scene = scene + rlayer_in.layer = rlayer + rlayer_in.color = color + rlayer_in.use_custom_color = True + rlayer_in.location = location + return rlayer_in + + def cfg_nodes(self, scene): + # Create Compositing Node Tree + scene.use_nodes = True + tree = scene.node_tree + # clear default nodes + for node in tree.nodes: + tree.nodes.remove(node) + + # Paint RenderLayer Nodes + paint_in = self._new_rlayer_in('Paint', scene, 'Paint', + (0,1720), self.colorcode['paint']) + + if self.sepsky: + sky_in = self._new_rlayer_in('Sky', scene, 'Sky', + (0, 1200), self.colorcode['sky']) + + # Configure EXR format + exr_paint = tree.nodes.new('CompositorNodeOutputFile') + exr_paint.name = 'exr_paint' + exr_paint.label = 'Paint EXR' + exr_paint.location = (300,1215) + exr_paint.color = self.colorcode['paint'] + exr_paint.use_custom_color = True + exr_paint.format.file_format = 'OPEN_EXR_MULTILAYER' + exr_paint.format.color_mode = 'RGBA' + exr_paint.format.color_depth = '16' + exr_paint.format.exr_codec = 'ZIP' + exr_paint.base_path = os.path.join(self.render_root, 'EXR', + self.designation, self.designation + '-Paint-f#####' + '.exr') + if 'Image' in exr_paint.layer_slots: + exr_paint.layer_slots.remove(exr_paint.inputs['Image']) + + # Create EXR layers and connect to render passes + rpasses = ['Image', 'Depth', 'Normal', 'Vector', + 'Spec', 'Shadow','Reflect','Emit'] + for rpass in rpasses: + exr_paint.layer_slots.new(rpass) + tree.links.new(paint_in.outputs[rpass], exr_paint.inputs[rpass]) + + if self.sepsky: + exr_paint.layer_slots.new('Sky') + tree.links.new(sky_in.outputs['Image'], exr_paint.inputs['Sky']) + + # Ink RenderLayer Nodes + ink_in = self._new_rlayer_in('Ink', scene, 'Ink', + (590, 1275), self.colorcode['ink']) + + if self.inkthru: + thru_in = self._new_rlayer_in('Thru', scene, 'Ink-Thru', + (590, 990), self.colorcode['thru']) + + if self.billboards: + bb_in = self._new_rlayer_in('BB', scene, 'BB-Alpha', + (0, 870), self.colorcode['bb']) + + bb_mat = self._new_rlayer_in('BB-Mat', scene, 'BB-Mat', + (0, 590), self.colorcode['bb']) + + if self.inkthru and self.billboards: + bb_mat_thru = self._new_rlayer_in('BB-Mat-Thru', scene, 'BB-Mat-Thru', + (0, 280), self.colorcode['bbthru']) + + # Ink EXR + exr_ink = tree.nodes.new('CompositorNodeOutputFile') + exr_ink.name = 'exr_ink' + exr_ink.label = 'Ink EXR' + exr_ink.location = (1150,700) + exr_ink.color = self.colorcode['ink'] + exr_ink.use_custom_color = True + exr_ink.format.file_format = 'OPEN_EXR_MULTILAYER' + exr_ink.format.color_mode = 'RGBA' + exr_ink.format.color_depth = '16' + exr_ink.format.exr_codec = 'ZIP' + exr_ink.base_path = os.path.join(self.render_root, 'EXR', + self.designation, self.designation + '-Ink-f#####' + '.exr') + + # Create EXR Ink layers and connect + if 'Image' in exr_ink.layer_slots: + exr_ink.layer_slots.remove(exr_ink.inputs['Image']) + exr_ink.layer_slots.new('Ink') + tree.links.new(ink_in.outputs['Image'], exr_ink.inputs['Ink']) + + if self.inkthru: + exr_ink.layer_slots.new('Ink-Thru') + tree.links.new(thru_in.outputs['Image'], exr_ink.inputs['Ink-Thru']) + + if self.billboards: + exr_ink.layer_slots.new('BB-Alpha') + tree.links.new(bb_in.outputs['Alpha'], exr_ink.inputs['BB-Alpha']) + + exr_ink.layer_slots.new('BB-Mat') + tree.links.new(bb_mat.outputs['IndexMA'], exr_ink.inputs['BB-Mat']) + + if self.inkthru and self.billboards: + exr_ink.layer_slots.new('BB-Mat-Thru') + tree.links.new(bb_mat_thru.outputs['IndexMA'], exr_ink.inputs['BB-Mat-Thru']) + + + # Preview Compositing + mix_shadow = tree.nodes.new('CompositorNodeMixRGB') + mix_shadow.name = 'mix_shadow' + mix_shadow.label = 'Mix-Shadow' + mix_shadow.location = (510,1820) + mix_shadow.color = self.colorcode['compos'] + mix_shadow.use_custom_color = True + mix_shadow.blend_type = 'MULTIPLY' + mix_shadow.inputs['Fac'].default_value = 0.6 + mix_shadow.use_clamp = True + tree.links.new(paint_in.outputs['Image'], mix_shadow.inputs[1]) + tree.links.new(paint_in.outputs['Shadow'], mix_shadow.inputs[2]) + + mix_reflect = tree.nodes.new('CompositorNodeMixRGB') + mix_reflect.name = 'mix_reflect' + mix_reflect.label = 'Mix-Reflect' + mix_reflect.location = (910, 1620) + mix_reflect.color = self.colorcode['compos'] + mix_reflect.use_custom_color = True + mix_reflect.blend_type = 'ADD' + mix_reflect.inputs['Fac'].default_value = 1.1 + mix_reflect.use_clamp = True + tree.links.new(paint_in.outputs['Reflect'], mix_reflect.inputs[2]) + + mix_emit = tree.nodes.new('CompositorNodeMixRGB') + mix_emit.name = 'mix_emit' + mix_emit.label = 'Mix-Emit' + mix_emit.location = (1110, 1520) + mix_emit.blend_type = 'ADD' + mix_emit.inputs['Fac'].default_value = 1.1 + mix_emit.use_clamp = True + tree.links.new(mix_reflect.outputs['Image'], mix_emit.inputs[1]) + tree.links.new(paint_in.outputs['Emit'], mix_emit.inputs[2]) + + if self.sepsky: + sky_mix = tree.nodes.new('CompositorNodeMixRGB') + sky_mix.name = 'sky_mix' + sky_mix.label = 'Sky Mix' + sky_mix.location = (710,1720) + sky_mix.color = self.colorcode['sky'] + sky_mix.use_custom_color = True + sky_mix.blend_type = 'MIX' + sky_mix.use_clamp = True + tree.links.new(sky_in.outputs['Image'], sky_mix.inputs[1]) + tree.links.new(paint_in.outputs['Alpha'], sky_mix.inputs['Fac']) + tree.links.new(mix_shadow.outputs['Image'], sky_mix.inputs[2]) + tree.links.new(sky_mix.outputs['Image'], mix_reflect.inputs[1]) + else: + tree.links.new(mix_shadow.outputs['Image'], mix_reflect.inputs[1]) + + if self.billboards: + mat_idx = tree.nodes.new('CompositorNodeIDMask') + mat_idx.name = "mat_idx" + mat_idx.label = "BB-ID" + mat_idx.location = (260, 670) + mat_idx.index = 1 + mat_idx.use_antialiasing = True + mat_idx.color = self.colorcode['bb'] + mat_idx.use_custom_color = True + tree.links.new(bb_mat.outputs['IndexMA'], mat_idx.inputs['ID value']) + + combine_bb_ma = tree.nodes.new('CompositorNodeMath') + combine_bb_ma.name = 'combine_bb_ma' + combine_bb_ma.label = 'Material x BB' + combine_bb_ma.location = (440,670) + combine_bb_ma.color = self.colorcode['bb'] + combine_bb_ma.use_custom_color = True + combine_bb_ma.operation = 'MULTIPLY' + combine_bb_ma.use_clamp = True + tree.links.new(mat_idx.outputs['Alpha'], combine_bb_ma.inputs[0]) + tree.links.new(bb_in.outputs['Alpha'], combine_bb_ma.inputs[1]) + + invert_bb_mask = tree.nodes.new('CompositorNodeInvert') + invert_bb_mask.name = 'invert_bb_mask' + invert_bb_mask.label = 'Invert Mask' + invert_bb_mask.location = (650,670) + invert_bb_mask.color = self.colorcode['bb'] + invert_bb_mask.use_custom_color = True + invert_bb_mask.invert_rgb = True + tree.links.new(combine_bb_ma.outputs['Value'], invert_bb_mask.inputs['Color']) + + bb_ink_mask = tree.nodes.new('CompositorNodeMath') + bb_ink_mask.name = 'bb_ink_mask' + bb_ink_mask.label = 'BB Ink Mask' + bb_ink_mask.location = (1150,1315) + bb_ink_mask.color = self.colorcode['bb'] + bb_ink_mask.use_custom_color = True + bb_ink_mask.operation = 'MULTIPLY' + bb_ink_mask.use_clamp = True + tree.links.new(invert_bb_mask.outputs['Color'], bb_ink_mask.inputs[0]) + + blur_ink = tree.nodes.new('CompositorNodeBlur') + blur_ink.name = 'blur_ink' + blur_ink.label = 'Blur-Ink' + blur_ink.location = (1620, 1110) + blur_ink.color = self.colorcode['ink'] + blur_ink.use_custom_color = True + blur_ink.filter_type = 'FAST_GAUSS' + blur_ink.size_x = 1.0 + blur_ink.size_y = 1.0 + blur_ink.use_extended_bounds = False + blur_ink.inputs['Size'].default_value = 1.0 + + if self.inkthru: + merge_ink_ao = tree.nodes.new('CompositorNodeAlphaOver') + merge_ink_ao.name = 'merge_ink' + merge_ink_ao.label = 'Merge-Ink' + merge_ink_ao.location = (1150,910) + merge_ink_ao.color = self.colorcode['thru'] + merge_ink_ao.use_custom_color = True + merge_ink_ao.use_premultiply = False + merge_ink_ao.premul = 0.0 + merge_ink_ao.inputs['Fac'].default_value = 1.0 + tree.links.new(ink_in.outputs['Image'], merge_ink_ao.inputs[1]) + tree.links.new(thru_in.outputs['Image'], merge_ink_ao.inputs[2]) + tree.links.new(merge_ink_ao.outputs['Image'], blur_ink.inputs['Image']) + else: + tree.links.new(ink_in.outputs['Image'], blur_ink.inputs['Image']) + + overlay_ink = tree.nodes.new('CompositorNodeAlphaOver') + overlay_ink.name = 'Overlay Ink' + overlay_ink.label = 'Overlay Ink' + overlay_ink.location = (1820,1315) + overlay_ink.color = self.colorcode['compos'] + overlay_ink.use_custom_color = True + overlay_ink.use_premultiply = False + overlay_ink.premul = 0.0 + overlay_ink.inputs['Fac'].default_value = 1.0 + tree.links.new(mix_emit.outputs['Image'], overlay_ink.inputs[1]) + tree.links.new(blur_ink.outputs['Image'], overlay_ink.inputs[2]) + + if self.billboards: + tree.links.new(ink_in.outputs['Alpha'], bb_ink_mask.inputs[1]) + tree.links.new(bb_ink_mask.outputs['Value'], overlay_ink.inputs['Fac']) + + if self.inkthru and self.billboards: + mat_idx_thru = tree.nodes.new('CompositorNodeIDMask') + mat_idx_thru.name = "mat_idx_thru" + mat_idx_thru.label = "BB-ID-Thru" + mat_idx_thru.location = (260, 425) + mat_idx_thru.index = 1 + mat_idx_thru.use_antialiasing = True + mat_idx_thru.color = self.colorcode['bbthru'] + mat_idx_thru.use_custom_color = True + tree.links.new(bb_mat_thru.outputs['IndexMA'], mat_idx_thru.inputs['ID value']) + + combine_bbthru_ma = tree.nodes.new('CompositorNodeMath') + combine_bbthru_ma.name = 'combine_bbthru_ma' + combine_bbthru_ma.label = 'Material x BB-Thru' + combine_bbthru_ma.location = (440,425) + combine_bbthru_ma.color = self.colorcode['bbthru'] + combine_bbthru_ma.use_custom_color = True + combine_bbthru_ma.operation = 'MULTIPLY' + combine_bbthru_ma.use_clamp = True + tree.links.new(mat_idx_thru.outputs['Alpha'], combine_bbthru_ma.inputs[0]) + tree.links.new(bb_in.outputs['Alpha'], combine_bbthru_ma.inputs[1]) + + invert_bbthru_mask = tree.nodes.new('CompositorNodeInvert') + invert_bbthru_mask.name = 'invert_bbthru_mask' + invert_bbthru_mask.label = 'Invert Mask' + invert_bbthru_mask.location = (650,425) + invert_bbthru_mask.color = self.colorcode['bbthru'] + invert_bbthru_mask.use_custom_color = True + invert_bbthru_mask.invert_rgb = True + tree.links.new(combine_bbthru_ma.outputs['Value'], invert_bbthru_mask.inputs['Color']) + + bb_thru_mask = tree.nodes.new('CompositorNodeMath') + bb_thru_mask.name = 'bb_thru_mask' + bb_thru_mask.label = 'BB Ink Thru Mask' + bb_thru_mask.location = (1150,1115) + bb_thru_mask.color = self.colorcode['bbthru'] + bb_thru_mask.use_custom_color = True + bb_thru_mask.operation = 'MULTIPLY' + bb_thru_mask.use_clamp = True + tree.links.new(thru_in.outputs['Alpha'], bb_thru_mask.inputs[0]) + tree.links.new(invert_bbthru_mask.outputs['Color'], bb_thru_mask.inputs[1]) + + merge_bb_ink_masks = tree.nodes.new('CompositorNodeMath') + merge_bb_ink_masks.name = 'merge_bb_ink_masks' + merge_bb_ink_masks.label = 'Merge BB Ink Masks' + merge_bb_ink_masks.location = (1415, 1215) + merge_bb_ink_masks.color = self.colorcode['bbthru'] + merge_bb_ink_masks.use_custom_color = True + merge_bb_ink_masks.operation = 'ADD' + merge_bb_ink_masks.use_clamp = True + tree.links.new(bb_ink_mask.outputs['Value'], merge_bb_ink_masks.inputs[0]) + tree.links.new(bb_thru_mask.outputs['Value'], merge_bb_ink_masks.inputs[1]) + + tree.links.new(merge_bb_ink_masks.outputs['Value'], overlay_ink.inputs['Fac']) + + composite = tree.nodes.new('CompositorNodeComposite') + composite.name = 'Composite' + composite.label = 'Preview Render' + composite.location = (2050,1215) + composite.color = self.colorcode['output'] + composite.use_custom_color = True + composite.use_alpha = True + composite.inputs['Alpha'].default_value = 1.0 + composite.inputs['Z'].default_value = 1.0 + tree.links.new(overlay_ink.outputs['Image'], composite.inputs['Image']) + + def _cfg_renderlayer(self, rlayer, + includes=False, passes=False, excludes=False, + layers=range(20)): + # Utility to set all the includes and passes on or off, initially + + # Weird Includes (we never use these -- always have to turn these on explicitly) + rlayer.use_zmask = False + rlayer.invert_zmask = False + rlayer.use_all_z = False + + # Includes + rlayer.use_solid = includes + rlayer.use_halo = includes + rlayer.use_ztransp = includes + rlayer.use_sky = includes + rlayer.use_edge_enhance = includes + rlayer.use_strand = includes + rlayer.use_freestyle = includes + + # Passes + rlayer.use_pass_combined = passes + rlayer.use_pass_z = passes + rlayer.use_pass_vector = passes + rlayer.use_pass_normal = passes + + rlayer.use_pass_uv = passes + rlayer.use_pass_mist = passes + rlayer.use_pass_object_index = passes + rlayer.use_pass_material_index = passes + rlayer.use_pass_color = passes + + rlayer.use_pass_diffuse = passes + rlayer.use_pass_specular = passes + rlayer.use_pass_shadow = passes + rlayer.use_pass_emit = passes + + rlayer.use_pass_ambient_occlusion = passes + rlayer.use_pass_environment = passes + rlayer.use_pass_indirect = passes + + rlayer.use_pass_reflection = passes + rlayer.use_pass_refraction = passes + + # Exclusions + rlayer.exclude_specular = excludes + rlayer.exclude_shadow = excludes + rlayer.exclude_emit = excludes + rlayer.exclude_ambient_occlusion = excludes + rlayer.exclude_environment = excludes + rlayer.exclude_indirect = excludes + rlayer.exclude_reflection = excludes + rlayer.exclude_refraction = excludes + + for i in range(20): + if i in layers: + rlayer.layers[i] = True + else: + rlayer.layers[i] = False + + + def cfg_paint(self, paint_layer, name="Paint"): + + self._cfg_renderlayer(paint_layer, + includes=True, passes=False, excludes=False, + layers = (0,1,2,3,4, 5,6,7, 10,11,12,13,14)) + + # Includes + if self.sepsky: + paint_layer.use_sky = False + + paint_layer.use_freestyle = False + + # Passes + paint_layer.use_pass_combined = True + paint_layer.use_pass_z = True + paint_layer.use_pass_vector = True + paint_layer.use_pass_normal = True + + paint_layer.use_pass_shadow = True + paint_layer.exclude_shadow = True + + paint_layer.use_pass_emit = True + paint_layer.exclude_emit = True + + paint_layer.use_pass_specular = True + paint_layer.exclude_specular = True + + paint_layer.use_pass_reflection = True + paint_layer.exclude_reflection = True + + + def cfg_bbalpha(self, bb_render_layer): + self._cfg_renderlayer(bb_render_layer, + includes=False, passes=False, excludes=False, + layers=(5,6, 14)) + # Includes + bb_render_layer.use_solid = True + bb_render_layer.use_ztransp = True + # Passes + bb_render_layer.use_pass_combined = True + + def cfg_bbmat(self, bb_mat_layer, thru=False): + self._cfg_renderlayer(bb_mat_layer, + includes=False, passes=False, excludes=False, + layers=(0,1,2,3, 5,6,7, 10,11,12,13,14, 15,16)) + # Includes + bb_mat_layer.use_solid = True + bb_mat_layer.use_ztransp = True + + # Passes + bb_mat_layer.use_pass_combined = True + bb_mat_layer.use_pass_material_index = True + + if not thru: + bb_mat_layer.layers[4] = True + + + def cfg_sky(self, sky_render_layer): + self._cfg_renderlayer(sky_render_layer, + includes=False, passes=False, excludes=False, + layers=(0,1,2,3,4, 5,6,7, 10,11,12,13,14)) + # Includes + sky_render_layer.use_sky = True + # Passes + sky_render_layer.use_pass_combined = True + + + def cfg_ink(self, ink_layer, name="Ink", thickness=3, color=(0,0,0)): + self._cfg_renderlayer(ink_layer, + includes=False, passes=False, excludes=False, + layers=(0,1,2,3, 5,6,7, 10,11,12,13, 15,16)) + # Includes + ink_layer.use_freestyle = True + # Passes + ink_layer.use_pass_combined = True + + # Freestyle + ink_layer.freestyle_settings.crease_angle = 2.617944 + ink_layer.freestyle_settings.use_smoothness = True + ink_layer.freestyle_settings.use_culling = True + + if len(ink_layer.freestyle_settings.linesets)>0: + ink_layer.freestyle_settings.linesets[0].name = name + else: + ink_layer.freestyle_settings.linesets.new(name) + + lineset = ink_layer.freestyle_settings.linesets[name] + + self.cfg_lineset(lineset, thickness, color) + + # Turn on the transparency layer for the regular ink: + if ink_layer.name!='Ink-Thru': + ink_layer.layers[4] = True + + + def cfg_lineset(self, lineset, thickness=3, color=(0,0,0)): + """ + Configure the lineset. + """ + #lineset.name = 'NormalInk' + # Selection options + lineset.select_by_visibility = True + lineset.select_by_edge_types = True + lineset.select_by_image_border = True + lineset.select_by_face_marks = False + lineset.select_by_group = True + + # Visibility Option + lineset.visibility = 'VISIBLE' + + # Edge Type Options + lineset.edge_type_negation = 'INCLUSIVE' + lineset.edge_type_combination = 'OR' + lineset.select_silhouette = True + lineset.select_border = True + lineset.select_contour = True + lineset.select_crease = True + lineset.select_edge_mark = True + lineset.select_external_contour = True + + # No Freestyle Group (If it exists) + if 'No Freestyle' in bpy.data.groups: + lineset.select_by_group = True + lineset.group = bpy.data.groups['No Freestyle'] + lineset.group_negation = 'EXCLUSIVE' + else: + lineset.select_by_group = False + + # Basic Ink linestyle: + if 'Ink' in bpy.data.linestyles: + lineset.linestyle = bpy.data.linestyles['Ink'] + else: + lineset.linestyle.name = 'Ink' + self.cfg_linestyle(lineset.linestyle, thickness, color) + + + def cfg_linestyle(self, linestyle, thickness=INK_THICKNESS, color=INK_COLOR): + # These are the only changeable parameters: + linestyle.color = color + linestyle.thickness = thickness + + # The rest of this function just sets a common fixed style for "Lunatics!" + linestyle.alpha = 1.0 + linestyle.thickness_position = 'CENTER' + linestyle.use_chaining = True + linestyle.chaining = 'PLAIN' + linestyle.use_same_object = True + linestyle.caps = 'ROUND' + + # ADD THE ALONG-STROKE MODIFIER CURVE + # TODO: try using the .new(type=...) idiom to see if it works? + # This probably needs the scene context set? + # bpy.ops.scene.freestyle_thickness_modifier_add(type='ALONG_STROKE') + + linestyle.thickness_modifiers.new(type='ALONG_STROKE', name='taper') + linestyle.thickness_modifiers['taper'].blend = 'MULTIPLY' + linestyle.thickness_modifiers['taper'].mapping = 'CURVE' + + # These are defaults, so maybe unnecessary? + linestyle.thickness_modifiers['taper'].influence = 1.0 + linestyle.thickness_modifiers['taper'].invert = False + linestyle.thickness_modifiers['taper'].value_min = 0.0 + linestyle.thickness_modifiers['taper'].value_max = 1.0 + + # This API is awful, but what it has to do is to change the location of the first two + # points (which can't be removed), then add a third point. Then update to pick up the + # changes: + linestyle.thickness_modifiers['taper'].curve.curves[0].points[0].location = (0.0,0.0) + linestyle.thickness_modifiers['taper'].curve.curves[0].points[1].location = (0.5,1.0) + linestyle.thickness_modifiers['taper'].curve.curves[0].points.new(1.0,0.0) + linestyle.thickness_modifiers['taper'].curve.update() + + \ No newline at end of file diff --git a/doc/NOTES.rst b/doc/NOTES.rst new file mode 100644 index 0000000..34714b6 --- /dev/null +++ b/doc/NOTES.rst @@ -0,0 +1,205 @@ +Design Notes +============ + +**ABX** or "Anansi Blender Extensions" is a catch-all Blender plugin to hold +current, custom, and experimental Blender extensions we use in Anansi +Spaceworks Studio projects. As we mature projects, we may choose to move +some of them into a more stable package or packages for wider distribution. + +This file accumulates some design notes for additional projects to incorporate +into ABX, from my daily worklog notes. + +Copy Animation +-------------- + +Options: + +* Copy Active Action (Dopesheet animation) +* Copy All NLA Actions +* Apply a scale factor and copy animations instead of linking +* Floating-point scale factor + +This was my first goal with ABX. Blender provides no simple way to copy +ALL of the animation from one object to another. This makes it very awkward +to refactor or repair broken animation rig proxies -- a problem that can +easily happen on a large project if things get renamed or files get moved. + +Sometimes it's necessary to just create a new proxy from a character and +transfer the animation to it. "Copy Animation" allows that. + +With the new rescaling feature (added April 2021), it also allows us to fix +scaling errors. For example, when we set up the "TR-Train" sequence in +"Lunatics!" S1E01, the file's scale was set up wrong -- the rest of the project +is in meter scale. But it was very awkward to try to change anything. It may +still be hard, but we should be able to apply scales using this tool. + +References: + +https://blender.stackexchange.com/questions/74183/how-can-i-copy-nla-tracks-from-one-armature-to-another +https://www.reddit.com/r/blender/comments/eu3w6m/guide_how_to_scale_a_rigify_rig/ + + + +Change Armature Proxy Name +-------------------------- + +An alternative approach would be to change the name of a proxy armature. + +Seems to work, but not sure:: + >>> bpy.data.objects['Georgiana_Pinafore_proxy'].proxy.data.name = 'georgiana_pinafore-TEST' + +I wonder if we can just fix the broken proxy case without having to copy? + + +Ink/Paint Configuration +----------------------- + +The "Ink/Paint Config" operator allows us to set up standard shot files ink/paint +compositing, including several tricks I've come up with for "Lunatics!" to handle +transparency, billboard objects, and the sky background correctly with the ink/paint +setup. + +So far (April 2021), it only supports "shot rendering" ("cam") files. I should +also provide support at least for setting up "shot compositing" ("compos") files, +which would take their input from the EXR files I create in the rendering phase. + + +Setup Compositing Files +----------------------- + + Also should be able to automate compositing and repacking setups + - find ranges of numbered frame files (EXR or PNG streams, etc), + and choose from them to set up ranges (maybe checkbox list?). + + Command line script using Blender to repack EXR files from + source EXR directory or glob to target directory or with new extension/prefix. Currently can do this one by one in Blender, + or by manually setting up ranges (but images must exist). + + Want to automatically detect what frames exist and render those. + We can use this to repack bulky EXRs from 2.71 into a more + compact format in 2.79. + + + +Compare Armatures +----------------- + +Getting bone objects from an armature in a proxy:: + + bpy.data.objects['Georgiana_Pinafore_proxy'].proxy.data.bones[0] + +Research how to walk the armature to find all the bone names (and check one against the other). +(Bones are not hierarchically organized in file. You have to trace the parent relationships +and construct the hierarchy):: + + [(root_A, [(A1, []), (A2, []), (A3, [(A3a,[]), (A3b,[])], (root_B, [])] + +Then print indented & ordered:: + + root_A + A1 + A2 + A3 + A3a + A3b + root_B + +or as paths:: + + root_A + root_A/A1 + root_A/A2 + root_A/A3 + root_A/A3/A3a + root_A/A3/A3b + root_B + +Find "missing" bones -- in src, but not tgt +Find "extra" bones -- in tgt, but not src + + +Link Character into Scene +------------------------- + +How to add a character along with proxy for animation. Can we do this with tagging on character libraries? + +Could add render profile & automatic naming tool here to set up correct +rendering for project. + +Initialize Files +---------------- + +Wizard to create basic types of files we need: + + - char + - set + - prop + - anim + - extra + - mech + - cam + - compos + + +Freestyle Camera-Clipping Configuration +--------------------------------------- + +Create a companion scene from the current scene, with a Freestyle camera to +be used to generate ink lines, but with a shorter camera clipping range. + +fs_scene_name = bpy.context.scene.name + '-FS' +CamOb = bpy.context.scene.camera +FsCamOb = bpy.context.scene.camera.copy() +FsCamOb.name = Cam.name + '-FS' + + +NewScene = bpy.data.scenes.new(name=bpy.context.scene.name + '-FS') +(Equivalent to bpy.ops.scene.new(type="NEW"), does not copy settings) + +NewScene = bpy.ops.scene.new(type="EMPTY") +(Better. Copies settings. But does not allow setting the name. Named by the current scene +plus .001 -- probably will be .002 if there is already a .001) +NewScene = bpy.data.scenes[OldScene.name = '.001'] + +NewScene.name = OldScene.name + '-FS' + +No settings! + +Instead: +bpy.ops.scene.new(type="LINK_OBJECTS") +NewScene = bpy.context.scene # Because ops updates the context +# OR +NewScene = bpy.data.scenes[OldScene.name + '.001'] +# IF that name wasn't used + +NewScene.name = OldScene.name + '-FS' + +for ob in OldScene.objects: + if ob != OldScene.camera: + NewScene.objects.link(ob) + +NewScene.objects.link(FsCamOb) +FsCamOb.data = FsCam.data.copy() +FsCamOb.data.name = FsCam.data.name + '-FS' + +NewScene.objects.unlink(OldScene.camera) + +FsCamOb.data.clip_end = 10.0 # Just setting it to 10 meters + +Had to fix my script to name the Color and Ink input renderlayer nodes +(Now 'Color-In' and 'Ink-In'. Was just "Render Layer" and "Render Layer.001") + +# Cross the streams! +OldScene.node_tree.nodes['Ink-In'].scene = NewScene + +NewScene.render.layers['Ink'].use = True +if 'Ink-Thru' in NewScene.render.layers: + NewScene.render.layers['Ink-Thru'].use = True +NewScene.render.layers['Color'].use = False + +OldScene.render.layers['Color'].use = True +OldScene.render.layers['Ink'].use = False +if 'Ink-Thru' in OldScene.render.layers: + OldScene.render.layers['Ink-Thru'].use = False + + \ No newline at end of file diff --git a/doc/versions.txt b/doc/versions.txt new file mode 100644 index 0000000..96468a3 --- /dev/null +++ b/doc/versions.txt @@ -0,0 +1,138 @@ +I started ABX very informally, so I did not really have logical version numbers +initially, and I'm not sure about the earliest packages I made. + +In April 2021, I started trying to formally package with a consistent version +number, with the following meanings: + + +0.1.0 (<2021): + Versions before 2021, with "Copy NLA" and "L.Ink Compos" and not much else. + + There were some experimental version of context.py, file_context.py, and + blender_context.py in the source, but they were unused and buggy or incomplete. + + +0.2.0 (2021-02 to 2021-05): + Versions from April/May 2021, with major design changes: + + * "Copy NLA" became "Copy Animation", and acquired new options: + - NLA (Old NLA behavior) + - Dopesheet (copy the "active action", which appears in the dopesheet). + - Copy/Rescale (instead of linking the animation, copy it, possibly rescaling) + - Scale Factor (if other than 1.0, the animation is scaled when copied) + + * "L.Ink Compos" became "Ink/Paint Config", and also got new options: + - Ink-Thru (Freestyle ink workaround for transparency, can now be turned off) + - Billboards (Accurate alpha-billboard ink masking technique) + - Separate Sky (Generate sky on a separate render layer for accurate compositing) + + * "Lunatics! Properties" got a new field for the scene "suffix" or title, used + exclusively to give some extra information in the scene name. This was to + accommodate short shot codes I was using, like "G.PoV" meaning "Georgiana + point-of-view shot", to clarify the letter-codes for the shots, especially + since I was changing some of them around. + + +0.2.5 (2021-05): + The last version before introducing the file_context data system into the main + functions of the program. It contains fully-working and tested file_context.py + and accumulate.py, but they haven't been updated with specific changes to + accommodate the Blender UI yet, and the abx_ui.py module doesn't use them. + + I made this version as a stable fallback for production with all of the recent + bugfixes, but none of the new (and likely buggy) data system features. + + Includes: + + * render_profiles feature in the Render panel. This is a quick select for + changing the render settings and output filepath to be named consistently + and use the correct settings for the most common pre-visualization renders + I make, and the full PNG stream render. + - The profiles are hard-coded, because there's no contextual data yet. + - EXR files may be generated from previz. I didn't make any attempt to + stop this, because it's usually not relevant (they'll be clobbered by + the full PNG renders, which are done last -- but this could be a problem + if I tried to regenerate the previz later) + + + +ROADMAP (for 2021?): + +0.2.6 (2021-05): + The first attempt to incorporate YAML configuration into the program. + + * Introduce better fallback behavior when project YAML files aren't found, + and give informative messages to the user about them. + + * Move program defaults into abx.yaml and project.yaml files in the ABX + source code. Load these before project files to guarantee default requirements + are met. + + * Use abx.yaml 'render_profiles' block to set options for render_profiles + +0.2.7: + Replace "Lunatics! Properties" with "Project Properties" (the project name will + be one of the properties, and probably read-only). Parse the "role" field. + Generate read-only data from file_context and appropriate data-type fields + for unspecified ranks. + +0.2.8: + Fix the schema override issue to allow for use with library assets or other + hierarchy besides episodes. Improve the handling of "rank" (possibly refactor + the code to make rank-handling more intuitive?) + +0.2.9: + Write / save / update to filepath to allow changing the name based on the + input from the Project Properties (Not 100% sure I want this). + +0.3.0: + 0.3.0 should have full data support driving the program features. + +0.3.1: + Pop-up report generation? I had this working experimentally, years ago, and + it'd be useful to generate pop-up reports for some of the introspection + features I want to add. + +0.3.x: + Improvements to data system and other capabilities. + + * Name context by group, rather than scene? Would be handy for library assets. + + * Possible integration with KitCAT? There's significant overlap between ABX + and KitCAT and I already call the metadata files "kitcat" data in the sources. + Will they be merely interoperable, or should I actually make ABX into the KitCAT + plugin for Blender? + + * Port ABX to Blender 2.8+, keeping 2.7 support, if possible. If I'm really + going to use 2.8 for compositing, I will need this. + +0.4.x (hopefully during 2021, because these would be very useful to have!): + New features. Hopefully: + + * Recognize/use "role" field. + * Ink/Paint Config for compositing (as opposed to rendering) + * Appropriate config tools for anim, mech, etc -- other roles + * Automate Freestyle "Ink-Camera" clipping system. + * Character Armature comparison (find misnamed or extra bones) + * Insert characters into an animation file + * Find/report broken dupligroups and/or animation proxies + * Generate linking report(s) -- information to support correct + linking diagrams, or possibly source for auto-generating them? + +0.5.x: + Integration with Papagayo / Lipsync data for animation? + Maybe find the appropriate data files and run the lipsync importer on them? + Or perhaps incorporate the lipsync importer code? + + +0.6.x: + Automated management of credits & licensing? (Or does this all fall under + the heading of KitCAT?) + + + + + + + + \ No newline at end of file diff --git a/testdata/abx.yaml b/testdata/abx.yaml new file mode 100644 index 0000000..5040f4e --- /dev/null +++ b/testdata/abx.yaml @@ -0,0 +1,4 @@ +--- +above_root: True + +# This file should NOT be found by testing! \ No newline at end of file diff --git a/testdata/blendfiles/copy_animation-test1-walkingtables.blend b/testdata/blendfiles/copy_animation-test1-walkingtables.blend new file mode 100644 index 0000000..6478d8e Binary files /dev/null and b/testdata/blendfiles/copy_animation-test1-walkingtables.blend differ diff --git a/testdata/blendfiles/inkpaint-test3-billboards.blend b/testdata/blendfiles/inkpaint-test3-billboards.blend new file mode 100644 index 0000000..1db81a3 Binary files /dev/null and b/testdata/blendfiles/inkpaint-test3-billboards.blend differ diff --git a/testdata/blendfiles/inkpaint-test4-new_arch.blend b/testdata/blendfiles/inkpaint-test4-new_arch.blend new file mode 100644 index 0000000..f6da7c8 Binary files /dev/null and b/testdata/blendfiles/inkpaint-test4-new_arch.blend differ diff --git a/testdata/images/expected/inkpaint-result-bb+sky.png b/testdata/images/expected/inkpaint-result-bb+sky.png new file mode 100644 index 0000000..6f3b24b Binary files /dev/null and b/testdata/images/expected/inkpaint-result-bb+sky.png differ diff --git a/testdata/images/expected/inkpaint-result-bb.png b/testdata/images/expected/inkpaint-result-bb.png new file mode 100644 index 0000000..a8350b5 Binary files /dev/null and b/testdata/images/expected/inkpaint-result-bb.png differ diff --git a/testdata/images/expected/inkpaint-result-bb_sky.png b/testdata/images/expected/inkpaint-result-bb_sky.png new file mode 100644 index 0000000..92d7986 Binary files /dev/null and b/testdata/images/expected/inkpaint-result-bb_sky.png differ diff --git a/testdata/images/expected/inkpaint-result-no_options.png b/testdata/images/expected/inkpaint-result-no_options.png new file mode 100644 index 0000000..030f13b Binary files /dev/null and b/testdata/images/expected/inkpaint-result-no_options.png differ diff --git a/testdata/images/expected/inkpaint-result-sky.png b/testdata/images/expected/inkpaint-result-sky.png new file mode 100644 index 0000000..57d9b0d Binary files /dev/null and b/testdata/images/expected/inkpaint-result-sky.png differ diff --git a/testdata/images/expected/inkpaint-result-thru+bb+sky.png b/testdata/images/expected/inkpaint-result-thru+bb+sky.png new file mode 100644 index 0000000..ae5f316 Binary files /dev/null and b/testdata/images/expected/inkpaint-result-thru+bb+sky.png differ diff --git a/testdata/images/expected/inkpaint-result-thru+bb.png b/testdata/images/expected/inkpaint-result-thru+bb.png new file mode 100644 index 0000000..b29814a Binary files /dev/null and b/testdata/images/expected/inkpaint-result-thru+bb.png differ diff --git a/testdata/images/expected/inkpaint-result-thru+sky.png b/testdata/images/expected/inkpaint-result-thru+sky.png new file mode 100644 index 0000000..e9522c4 Binary files /dev/null and b/testdata/images/expected/inkpaint-result-thru+sky.png differ diff --git a/testdata/images/expected/inkpaint-result-thru.png b/testdata/images/expected/inkpaint-result-thru.png new file mode 100644 index 0000000..ed49aa1 Binary files /dev/null and b/testdata/images/expected/inkpaint-result-thru.png differ diff --git a/testdata/images/expected/inkpaint-result-thru_bb-FAIL.png b/testdata/images/expected/inkpaint-result-thru_bb-FAIL.png new file mode 100644 index 0000000..ebd978b Binary files /dev/null and b/testdata/images/expected/inkpaint-result-thru_bb-FAIL.png differ diff --git a/testdata/images/expected/inkpaint-result-thru_bb_sky-PASS.png b/testdata/images/expected/inkpaint-result-thru_bb_sky-PASS.png new file mode 100644 index 0000000..57ee18c Binary files /dev/null and b/testdata/images/expected/inkpaint-result-thru_bb_sky-PASS.png differ diff --git a/testdata/images/expected/inkpaint-result.png b/testdata/images/expected/inkpaint-result.png new file mode 100644 index 0000000..7d59717 Binary files /dev/null and b/testdata/images/expected/inkpaint-result.png differ diff --git a/testdata/kitcat.yaml b/testdata/kitcat.yaml new file mode 100644 index 0000000..9eb77da --- /dev/null +++ b/testdata/kitcat.yaml @@ -0,0 +1,4 @@ +--- +above_root: True + +# This file should NOT be found by testing! diff --git a/testdata/myproject/Episodes/A.001-Pilot/A.001-Pilot.yaml b/testdata/myproject/Episodes/A.001-Pilot/A.001-Pilot.yaml new file mode 100644 index 0000000..c101f6a --- /dev/null +++ b/testdata/myproject/Episodes/A.001-Pilot/A.001-Pilot.yaml @@ -0,0 +1,30 @@ +# Episode definitions and metadata +--- +project_unit: + - rank: episode + code: 001 + name: Pilot + + sequences: + - ZP + - OP + - LP + # Override alphabetical order for subunits. + + schema: + delimiter: '-' + # Go back to the default field separator for things under episode + # + # So it'll actually parse like this: + # + # A.001-LP-1-BeginningOfEnd-anim.txt + # + # (A, 001-LP-1-BeginningOfEnd-anim, txt) + # (A, (001, LP, 1, BeginningOfEnd, anim), txt) + # + # Not sure I'm happy about the filetype being cutoff at the top level + # + +definitions: + render_root: 'Episodes/A.001-Pilot/Renders' # Relative to project_root! + diff --git a/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.txt b/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.txt new file mode 100644 index 0000000..1f31024 --- /dev/null +++ b/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.txt @@ -0,0 +1,9 @@ +The contents of this file are not used. +The file_context module only reads the exterior metadata from the filesystem, +including: + + - file name + - file stats (modification dates, permissions, etc) + - directory path to the file + - "sidecar" data files in YAML format for each level of directory + (these are treated as attributes of the directory they are contained in). \ No newline at end of file diff --git a/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.yaml b/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.yaml new file mode 100644 index 0000000..dc347a5 --- /dev/null +++ b/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.yaml @@ -0,0 +1,5 @@ +# Example 'sidecar' file +--- +project_unit: + - rank: block + code: 1 diff --git a/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/LP-LastPoint.yaml b/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/LP-LastPoint.yaml new file mode 100644 index 0000000..563104c --- /dev/null +++ b/testdata/myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/LP-LastPoint.yaml @@ -0,0 +1,9 @@ +--- +project_unit: + - code: LP + rank: sequence + name: Last Point + description: > + Final scene in our exciting production 'My Project' in which the + heroes finally get to the end of the metadata. + \ No newline at end of file diff --git a/testdata/myproject/Episodes/A.001-Pilot/abx.yaml b/testdata/myproject/Episodes/A.001-Pilot/abx.yaml new file mode 100644 index 0000000..0882a0e --- /dev/null +++ b/testdata/myproject/Episodes/A.001-Pilot/abx.yaml @@ -0,0 +1,15 @@ +--- +testscalar: loweryaml + +testdict: + A: + - 'item2' + - 'item4' + + B: 2 + + C: + a: 1 + b: 3 + d: 3 + e: 4 diff --git a/testdata/myproject/Episodes/A.002-Second/kitcat.yaml b/testdata/myproject/Episodes/A.002-Second/kitcat.yaml new file mode 100644 index 0000000..2c023af --- /dev/null +++ b/testdata/myproject/Episodes/A.002-Second/kitcat.yaml @@ -0,0 +1,7 @@ +--- +# This is a project root file in the wrong place, it makes everything below it a separate +# project, which was probably not intended. + +project_root: + title: "Full Project Under Project" + \ No newline at end of file diff --git a/testdata/myproject/Episodes/Episodes.yaml b/testdata/myproject/Episodes/Episodes.yaml new file mode 100644 index 0000000..1b29a5b --- /dev/null +++ b/testdata/myproject/Episodes/Episodes.yaml @@ -0,0 +1,12 @@ +--- +project_unit: + - rank: series + + code: A + name: Series A + + schema: + delimiter: '.' + # Kind of a hack to fix the S1E01 notation with a strict delimiter-splitting model + # for fields? + \ No newline at end of file diff --git a/testdata/myproject/abx.yaml b/testdata/myproject/abx.yaml new file mode 100644 index 0000000..ef4d8f6 --- /dev/null +++ b/testdata/myproject/abx.yaml @@ -0,0 +1,20 @@ +# Anansi Blender Extensions (specific) data file (Overrides generic values as needed) +--- +abx: True + +testscalar: topyaml + +testdict: + A: + - 'item1' + - 'item2' + - 'item3' + + B: 1 + + C: + a: 1 + b: 1 + c: 2 + d: 3 + diff --git a/testdata/myproject/myproject.yaml b/testdata/myproject/myproject.yaml new file mode 100644 index 0000000..cf5a98b --- /dev/null +++ b/testdata/myproject/myproject.yaml @@ -0,0 +1,159 @@ +# Project definitions and metadata +--- +project_root: + # Top level information about the project. + owner: "My Production Company" + copyright: "(C) My Production Company, LLC" + contact: "The Boss <the_boss@my_production_company.com>" + url: my_production_company.com + + +project_unit: + # Project unit for the whole project (i.e. top of the DAMS path) + # This is expressed as a list, because with UnionList, it will accumulate + # project_units down to the filepath, based on any intervening control + # files (which should be there in this design, though they can be very + # simple). + + - rank: project + # Just explicitly tell the DAMS path level + # (avoid need to infer from directory path) + + code: myproject + # Short name used internally for reference (usually same as directory name) + # Avoid spaces. Use identifier characters ([A-Za-z_0-9]). Can start with or + # be a number, though. + + name: My Project + # Short name used to refer to project (for humans). Can have spacing and punctuation, + # but should still be short. + # Optional -- use id if not provided. + + title: Project of Mine + # Longer name used in headings (optional -- if not provided, name is used). + + description: > + This is a test project used only for testing the ABX and KitCAT file context + analysis system. + + # Descriptive paragraph for documentation and browsing interfaces. + # Note the '>': this is how you put block text in YAML. + +project_schema: + - rank: project + delimiter: '-' + words: True + type: string + maxlength: 32 + + - rank: series + delimiter: '.' + type: letter + + - rank: episode + delimiter: '-' + type: number + maxlength: 3 + minlength: 3 + pad: 0 + format: "{:0>3d}" + + - rank: sequence + type: + mt: Main Title + et: Episode Title + ec: Episode Credits + oe: Out Eyecatch + ie: In Eyecatch + pr: Promotional + ad: Sponsor Ad + ZP: Zero Point + OP: One Point + LP: Last Point + maxlength: 2 + minlength: 2 + pad: '_' + + - rank: block + type: number + default: 0 + maxlength: 1 + minlength: 1 + pad: 0 + + - rank: camera + type: + - 'c1' + - 'c2' + - 'c3' + - 'c4' + default: None + maxlength: 2 + minlength: 2 + + - rank: shot + type: letter + default: A + maxlength: 1 + minlength: 1 + pad: 0 + + - rank: element + type: string + default: None + maxlength: 5 + minlength: 0 + + +definitions: + filetypes: + blend: "Blender File" + kdenlive: "Kdenlive Video Editor File" + mlt: "Kdenlive Video Mix Script" + svg: "Scalable Vector Graphics (Inkscape)" + kra: "Krita Graphic File" + xcf: "Gimp Graphic File" + png: "Portable Network Graphics (PNG) Image" + jpg: "Joint Photographic Experts Group (JPEG) Image" + aup: "Audacity Project" + ardour: "Ardour Project" + flac: "Free Lossless Audio Codec (FLAC)" + mp3: "MPEG Audio Layer III (MP3) Audio File" + ogg: "Ogg Vorbis Audio File" + avi: "Audio Video Interleave (AVI) Video Container" + mkv: "Matroska Video Container" + mp4: "Moving Picture Experts Group (MPEG) 4 Format" + txt: "Plain Text File" + + roles: + extras: "Extras, crowds, auxillary animated movement" + mech: "Mechanical animation" + anim: "Character animation" + cam: "Camera direction" + vfx: "Visual special effects" + compos: "Compositing" + bkg: "Background 2D image" + bb: "Billboard 2D image" + tex: "Texture 2D image" + foley: "Foley sound" + voice: "Voice recording" + fx: "Sound effects" + music: "Music track" + cue: "Musical cue" + amb: "Ambient sound" + loop: "Ambient sound loop" + edit: "Video edit" + + roles_by_filetype: + kdenlive: edit + mlt: edit + + omit_ranks: # Controls how much we shorten names + edit: 0 # Use the entire designation for final output + render: 1 # Use everything but project name for rendering + filename: 1 # Same for filenames in general + scene: 3 # Omit project, series, episode for (Blender) scene names + + + + diff --git a/testdata/yaminimal/Episodes/Ae1-Void/Seq/VN-VagueName/Ae1-VN-1-VoidOfData-anim.txt b/testdata/yaminimal/Episodes/Ae1-Void/Seq/VN-VagueName/Ae1-VN-1-VoidOfData-anim.txt new file mode 100644 index 0000000..1f31024 --- /dev/null +++ b/testdata/yaminimal/Episodes/Ae1-Void/Seq/VN-VagueName/Ae1-VN-1-VoidOfData-anim.txt @@ -0,0 +1,9 @@ +The contents of this file are not used. +The file_context module only reads the exterior metadata from the filesystem, +including: + + - file name + - file stats (modification dates, permissions, etc) + - directory path to the file + - "sidecar" data files in YAML format for each level of directory + (these are treated as attributes of the directory they are contained in). \ No newline at end of file diff --git a/testdata/yaminimal/abx.yaml b/testdata/yaminimal/abx.yaml new file mode 100644 index 0000000..db1c573 --- /dev/null +++ b/testdata/yaminimal/abx.yaml @@ -0,0 +1,42 @@ +--- +render_profiles: + - previz: + engine: gl + version: any + fps: 30 # Basic FPS setting + fps_div: 1000 # FPS divisor (for NTSC) + fps_skip: 1 # Frames to skip ("on Ns") + suffix: GL # Suffix used on render files + format: AVI_JPEG # (AVI_JPEG, AVI_PNG, PNG, JPG, MP4...) + freestyle: off + + - paint: # Paint-only renders, full 30fps + engine: bi + fps: 30 + fps_skip: 10 + suffix: PT + format: AVI_JPEG + freestyle: off + antialias: off + motionblur: off + + - check: # Check renders (1 fps, all effects) + engine: bi + fps: 30 + fps_skip: 30 + suffix: CHK + format: JPG + framedigits: 5 # No. of digits for frame suffix + freestyle: on + antialias: 8 + + - full: + engine: bi + fps: 30 + fps_skip: 1 + suffix: off + format: PNG + framedigits: 5 + freestyle: on + antialias: 8 + \ No newline at end of file diff --git a/testdata/yaminimal/yaminimal.yaml b/testdata/yaminimal/yaminimal.yaml new file mode 100644 index 0000000..bfbed67 --- /dev/null +++ b/testdata/yaminimal/yaminimal.yaml @@ -0,0 +1,41 @@ +--- +# Project root must exist, but contents aren't really necessary unless +# we want to have the data available (the value here doesn't even matter, +# just that 'project_root' exists as a key in this data: +project_root: True + +# By default, the project_root's directory name is the code for the project. +# Nothing is strictly require of the 'project_unit' item, but it can give +# us an explicit, longer-form title than the directory name: +project_unit: + - title: Project with Minimal YAML Data + +# Without SOME kind of schema, we can't figure much out, so there HAS +# to be a project_schema: +project_schema: + - rank: project + delimiter: '-' + words: True + type: string + + - rank: series + delimiter: 'e' + type: letter + + - rank: episode + delimiter: '-' + type: number + maxlength: 1 + + - rank: sequence + type: + VN: Vague Name + + - rank: block + type: number + maxlength: 1 + + - rank: shot + type: letter + maxlength: 1 + \ No newline at end of file diff --git a/testdata/yamlless/Episodes/Ae1-Void/Seq/VN-VagueName/Ae1-VN-1-VoidOfData-anim.txt b/testdata/yamlless/Episodes/Ae1-Void/Seq/VN-VagueName/Ae1-VN-1-VoidOfData-anim.txt new file mode 100644 index 0000000..1f31024 --- /dev/null +++ b/testdata/yamlless/Episodes/Ae1-Void/Seq/VN-VagueName/Ae1-VN-1-VoidOfData-anim.txt @@ -0,0 +1,9 @@ +The contents of this file are not used. +The file_context module only reads the exterior metadata from the filesystem, +including: + + - file name + - file stats (modification dates, permissions, etc) + - directory path to the file + - "sidecar" data files in YAML format for each level of directory + (these are treated as attributes of the directory they are contained in). \ No newline at end of file diff --git a/tests/test_accumulate.py b/tests/test_accumulate.py new file mode 100644 index 0000000..dec16a5 --- /dev/null +++ b/tests/test_accumulate.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +The 'accumulate' module implements mutable data structures, like Dictionary +and List, but which implement set operations that allow information from +additional dictionaries and lists (whether using the new data types or not), +to be combined with the existing information in a recursive way. + +The problem it solves: + +For various purposes, usually involving metadata, I'm going to want to +collect information from various sources around a project: YAML files, +INI files, JSON or YAML strings inside Blender documents, query responses +from a central database, and so on. Each will generally have an incomplete +picture of the information I want, and I want the data to be filled in. + +Ordinary Python dictionaries and lists do not work well for this. + +With the "update" method of standard Python dictionaries, if a key exists in +the new dictionary, its value will always REPLACE the value in the old +dictionary -- even if that value is itself a data structure, such as a +dictionary. There is no recursion into sub-dictionaries. This makes it poor at +combining nested data structures. + +With the "extend" method of lists, if two source show the same information, +there will now be two copies with redundant information, and extending it +again will produce additional ones. + +The accumulate module therefore provides RecursiveDict, with an update +method that will recurse into sub-dictionaries and combine sequence elements +using an idempotent "ordered-set-union" operation. + +It also provides convenient conversions to YAML or JSON serializations of +the data for output to text files or text blocks. +""" + +import unittest, os + +# This is the most ridiculous work-around, but it seems to be necessary to +# get Python 3 to import the modules for testing +import sys +print("__file__ = ", __file__) +sys.path.append(os.path.normpath(os.path.join(__file__, '..', '..'))) + +from abx import accumulate + +class AccumulationTests(unittest.TestCase): + """ + Test combination operations give correct results. + """ + + def setUp(self): + # AFAIK, I don't need any setup + # I'm only putting this in in case I need to add something to it. + pass + + def tearDown(self): + # AFAIK, I don't need any teardown + pass + + def test_union_list_union(self): + # I start out with a UnionList containing a simple list: + A = accumulate.UnionList([1,2,3]) + + # I then union it with a new list with some shared and some + # new elements: + C = A.union([3,4,5]) + + # The new list should have the original elements plus the + # the new elements that don't repeat existing elements: + self.assertEqual(C, [1,2,3,4,5]) + + def test_subdictionary_updates_instead_of_being_replaced(self): + # I start out with a dictionary that contains a subdictionary + # with some data, under key 'A': + first = {'A': {'a':1}} + + # And another dictionary that has the same subdictionary, with + # different information: + second = {'A': {'b':2}} + + # I convert first to a RecursiveDict + first_recursive = accumulate.RecursiveDict(first) + + # Then I update it with the second dictionary (it shouldn't + # matter that it isn't a recursive dictionary) + first_recursive.update(second) + + # The subdictionary should contain the new value: + self.assertEqual(first_recursive['A']['b'], 2) + + # And it should still contain the old value: + self.assertEqual(first_recursive['A']['a'], 1) + + + def test_sublist_updates_as_an_ordered_set_union(self): + # I start with a dictionary that contains a sublist under a key: + first = {'L':[1,2,3,4]} + + # And a second dictionary with a different sublist under the + # same key: + second = {'L':[5,4,3,6]} + + # I convert first to a recursive dict: + first_recursive = accumulate.RecursiveDict(first) + + # Then I update it with the second dictionary: + first_recursive.update(second) + + # The resulting list should combine, but only with + # the unique new elements: + self.assertEqual(first_recursive['L'], [1,2,3,4,5,6]) + + # Then I update it again: + first_recursive.update(second) + + # This shouldn't make any difference! + self.assertEqual(first_recursive['L'], [1,2,3,4,5,6]) + + +class CollectYaml(unittest.TestCase): + TESTDATA = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'testdata')) + + TESTPATH = os.path.join(TESTDATA, 'myproject/Episodes/' + + 'A.001-Pilot/Seq/LP-LastPoint/' + + 'A.001-LP-1-BeginningOfEnd-anim.txt') + + TESTPATH_EMBEDDED_PROJ = os.path.join(TESTDATA, 'myproject/') + + def test_collect_yaml_files_w_abx_rules(self): + files = accumulate.collect_yaml_files(self.TESTPATH, 'abx', + root = os.path.join(self.TESTDATA, 'myproject')) + + self.assertEqual([os.path.abspath(f) for f in files], + [os.path.join(self.TESTDATA, 'myproject/abx.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/abx.yaml')]) + + + def test_collect_yaml_files_w_kitcat_rules(self): + files = accumulate.collect_yaml_files(self.TESTPATH, + ('kitcat', 'project'), dirmatch=True, sidecar=True, + root = os.path.join(self.TESTDATA, 'myproject')) + + self.assertEqual([os.path.join(self.TESTDATA, f) for f in files], + [os.path.join(self.TESTDATA, 'myproject/myproject.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/Episodes.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/A.001-Pilot.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/' + + 'LP-LastPoint.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/Seq/LP-LastPoint/' + + 'A.001-LP-1-BeginningOfEnd-anim.yaml') + ]) + + def test_detecting_project_root(self): + self.assertFalse(accumulate.has_project_root( + os.path.join(self.TESTDATA, 'kitcat.yaml'))) + self.assertTrue(accumulate.has_project_root( + os.path.join(self.TESTDATA, 'myproject/myproject.yaml'))) + self.assertFalse(accumulate.has_project_root( + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/A.001-Pilot.yaml'))) + + def test_trim_to_project_root(self): + trimmed = accumulate.trim_to_project_root( + [os.path.join(self.TESTDATA, 'kitcat.yaml'), + os.path.join(self.TESTDATA, 'myproject/myproject.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/A.001-Pilot.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/Seq/' + + 'LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.yaml')] + ) + + self.assertEqual([os.path.abspath(f) for f in trimmed], + [os.path.join(self.TESTDATA, 'myproject/myproject.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/A.001-Pilot.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/Seq/' + + 'LP-LastPoint/A.001-LP-1-BeginningOfEnd-anim.yaml')]) + + def test_trim_to_project_under_project(self): + trimmed = accumulate.trim_to_project_root( + [os.path.join(self.TESTDATA, 'kitcat.yaml'), + os.path.join(self.TESTDATA, 'myproject/myproject.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.002-Second/kitcat.yaml')]) + + self.assertEqual([os.path.abspath(f) for f in trimmed], + [os.path.join(self.TESTDATA, 'myproject/Episodes/A.002-Second/kitcat.yaml')]) + + def test_finding_project_root_dir_from_kitcat_files(self): + rootdir = accumulate.get_project_root( + accumulate.collect_yaml_files( + os.path.abspath(self.TESTPATH), + ('kitcat', 'project'), dirmatch=True, sidecar=True)) + + self.assertEqual(os.path.abspath(rootdir), + os.path.join(self.TESTDATA, 'myproject')) + + def test_finding_abx_files_from_kitcat_root(self): + rootdir = accumulate.get_project_root( + accumulate.collect_yaml_files( + os.path.abspath(self.TESTPATH), + ('kitcat', 'project'), dirmatch=True, sidecar=True)) + + abx_files = accumulate.collect_yaml_files( + os.path.abspath(self.TESTPATH), + 'abx', root=rootdir) + + self.assertEqual([os.path.abspath(f) for f in abx_files], + [os.path.join(self.TESTDATA, 'myproject/abx.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/abx.yaml')]) + + def test_combining_abx_yaml_files(self): + abx_files = [ + os.path.join(self.TESTDATA, 'myproject/abx.yaml'), + os.path.join(self.TESTDATA, 'myproject/Episodes/A.001-Pilot/abx.yaml')] + + testdata = accumulate.combine_yaml(abx_files) + + self.assertEqual(testdata['testscalar'], 'loweryaml') + self.assertEqual( + list(testdata['testdict']['A']), + ['item1', 'item2', 'item3', 'item4']) + + def test_collecting_yaml_from_empty_dir(self): + files = accumulate.collect_yaml_files( + os.path.join(self.TESTDATA, 'empty/'), + 'spam', root = self.TESTDATA) + + self.assertEqual(list(files), []) + + def test_collecting_yaml_from_nonexistent_file(self): + files = accumulate.collect_yaml_files( + os.path.join(self.TESTDATA, 'empty/no_such_file.txt'), + 'spam', root = self.TESTDATA) + + self.assertEqual(list(files), []) + + def test_combining_yamls_from_empty_list(self): + data = accumulate.combine_yaml([]) + + self.assertEqual(dict(data), {}) + + def test_getting_project_data_from_path(self): + root, kitcat_data, abx_data = accumulate.get_project_data(self.TESTPATH) + + self.assertEqual( + os.path.abspath(root), + os.path.join(self.TESTDATA, 'myproject')) + + self.assertEqual(kitcat_data['project_unit'][0]['code'], 'myproject') + + self.assertEqual(abx_data['testdict']['A'], + ['item1', 'item2', 'item3', 'item4']) + + + diff --git a/tests/test_file_context.py b/tests/test_file_context.py new file mode 100644 index 0000000..f9c88ac --- /dev/null +++ b/tests/test_file_context.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +""" +Test the file_context module. + +This was written well after I wrote the module, and starts out as a conversion +from the doctests I had in the module already. +""" + + +import unittest, os, textwrap +import yaml + +import sys +print("__file__ = ", __file__) +sys.path.append(os.path.normpath(os.path.join(__file__, '..', '..'))) + +from abx import file_context + +class FileContext_NameSchema_Interface_Tests(unittest.TestCase): + """ + Test the interfaces presented by NameSchema. + + NameSchema is not really intended to be used from outside the + file_context module, but it is critical to the behavior of the + module, so I want to make sure it's working as expected. + """ + TESTDATA = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'testdata')) + + TESTPROJECTYAML = os.path.join(TESTDATA, 'myproject', 'myproject.yaml') + + TESTPATH = os.path.join(TESTDATA, 'myproject/Episodes/' + + 'A.001-Pilot/Seq/LP-LastPoint/' + + 'A.001-LP-1-BeginningOfEnd-anim.txt') + + + # Normally from 'project_schema' in YAML + TESTSCHEMA_LIST =[ + {'rank': 'project', 'delimiter':'-', 'format':'{:s}', 'words':True}, + {'rank': 'series', 'delimiter':'E', 'format':'{:2s}'}, + {'rank': 'episode', 'delimiter':'-', 'format':'{!s:>02s}'}, + {'rank': 'sequence','delimiter':'-', 'format':'{:2s}'}, + {'rank': 'block', 'delimiter':'-', 'format':'{!s:1s}'}, + {'rank': 'shot', 'delimiter':'-', 'format':'{!s:s}'}, + {'rank': 'element', 'delimiter':'-', 'format':'{!s:s}'}] + + def test_NameSchema_create_single(self): + ns = file_context.NameSchema(schema = self.TESTSCHEMA_LIST[0]) + + # Test for ALL the expected properties: + + # Set by the test schema + self.assertEqual(ns.rank, 'project') + self.assertEqual(ns.delimiter, '-') + self.assertEqual(ns.format, '{:s}') + self.assertEqual(ns.words, True) + self.assertEqual(ns.codetype, str) + + # Default values + self.assertEqual(ns.pad, '0') + self.assertEqual(ns.minlength, 1) + self.assertEqual(ns.maxlength, 0) + self.assertEqual(ns.default, None) + + # Candidates for removal: + self.assertEqual(ns.irank, 0) # Is this used at all? + self.assertEqual(ns.parent, None) + self.assertListEqual(list(ns.ranks), + ['series', 'episode', 'sequence', + 'block', 'camera', 'shot', 'element']) + + def test_NameSchema_load_chain_from_project_yaml(self): + with open(self.TESTPROJECTYAML, 'rt') as yaml_file: + data = yaml.safe_load(yaml_file) + schema_dicts = data['project_schema'] + + schema_chain = [] + last = None + for schema_dict in schema_dicts: + rank = schema_dict['rank'] + parent = last + schema_chain.append(file_context.NameSchema( + parent = parent, + rank = rank, + schema = schema_dict)) + last = schema_chain[-1] + + #print( schema_chain ) + + self.assertEqual(len(schema_chain), 8) + + self.assertEqual( + schema_chain[-1].parent.parent.parent.parent.parent.parent.parent.rank, + 'project') + + self.assertEqual(schema_chain[5].rank, 'camera') + self.assertEqual(schema_chain[5].codetype[1], ('c2', 'c2', 'c2')) + + + + +class FileContext_Parser_UnitTests(unittest.TestCase): + TESTFILENAMES = ('S1E01-SF-4-SoyuzDMInt-cam.blend', 'S1E02-MM-MediaMontage-compos.blend', + 'S1E01-PC-PressConference-edit.kdenlive', + 'S1E01-LA-Launch.kdenlive') + + # Collected by analyzing YAML control files ('project_unit'). + TESTNAMEPATHS = (('Lunatics', 'S1', '1', 'SF', '4'), + ('Lunatics', 'S1', '2', 'MM'), + ('Lunatics', 'S1', '1', 'PC'), + ('Lunatics', 'S1', '1', 'LA')) + + # Normally from 'project_schema' in YAML + TESTSCHEMA_LIST =[ + {'rank': 'project', 'delimiter':'-', 'format':'{:s}', 'words':True}, + {'rank': 'series', 'delimiter':'E', 'format':'{:2s}'}, + {'rank': 'episode', 'delimiter':'-', 'format':'{!s:>02s}'}, + {'rank': 'sequence','delimiter':'-', 'format':'{:2s}'}, + {'rank': 'block', 'delimiter':'-', 'format':'{!s:1s}'}, + {'rank': 'shot', 'delimiter':'-', 'format':'{!s:s}'}, + {'rank': 'element', 'delimiter':'-', 'format':'{!s:s}'}] + + # Normally from 'definitions' in YAML + TESTDEFS = { + 'filetypes':{ + 'blend': "Blender File", + 'kdenlive': "Kdenlive Video Editor File", + 'mlt': "Kdenlive Video Mix Script", + 'svg': "Scalable Vector Graphics (Inkscape)", + 'kra': "Krita Graphic File", + 'xcf': "Gimp Graphic File", + 'png': "Portable Network Graphics (PNG) Image", + 'jpg': "Joint Photographic Experts Group (JPEG) Image", + 'aup': "Audacity Project", + 'ardour': "Ardour Project", + 'flac': "Free Lossless Audio Codec (FLAC)", + 'mp3': "MPEG Audio Layer III (MP3) Audio File", + 'ogg': "Ogg Vorbis Audio File", + 'avi': "Audio Video Interleave (AVI) Video Container", + 'mkv': "Matroska Video Container", + 'mp4': "Moving Picture Experts Group (MPEG) 4 Format}", + }, + 'roles':{ + 'extras': "Extras, crowds, auxillary animated movement", + 'mech': "Mechanical animation", + 'anim': "Character animation", + 'cam': "Camera direction", + 'vfx': "Visual special effects", + 'compos': "Compositing", + 'bkg': "Background 2D image", + 'bb': "Billboard 2D image", + 'tex': "Texture 2D image", + 'foley': "Foley sound", + 'voice': "Voice recording", + 'fx': "Sound effects", + 'music': "Music track", + 'cue': "Musical cue", + 'amb': "Ambient sound", + 'loop': "Ambient sound loop", + 'edit': "Video edit" + }, + 'roles_by_filetype': { + 'kdenlive': 'edit', + 'mlt': 'edit' + } + } + + TESTDATA = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'testdata')) + + TESTPATH = os.path.join(TESTDATA, 'myproject/Episodes/' + + 'A.001-Pilot/Seq/LP-LastPoint/' + + 'A.001-LP-1-BeginningOfEnd-anim.txt') + + def setUp(self): + self.TESTSCHEMAS = [file_context.NameSchema( #rank=s['rank'], + schema=s) + for s in self.TESTSCHEMA_LIST] + + def test_parsing_filenames_w_episode_parser(self): + abx_episode_parser = file_context.NameParsers['abx_episode']() + + data = abx_episode_parser('S1E01-SF-4-SoyuzDMInt-cam.blend', []) + self.assertDictEqual(data[1], + {'filetype': 'blend', + 'role': 'cam', + 'hierarchy': 'episode', + 'series': {'code': 'S1'}, + 'episode': {'code': 1}, + 'rank': 'block', + 'seq': {'code': 'SF'}, + 'block': {'code': 4, 'title': 'Soyuz DMI nt'}}) + + data = abx_episode_parser('S1E02-MM-MediaMontage-compos.blend', []) + self.assertDictEqual(data[1], + {'filetype': 'blend', + 'role': 'compos', + 'hierarchy': 'episode', + 'series': {'code': 'S1'}, + 'episode': {'code': 2}, + 'rank': 'seq', + 'seq': {'code': 'MM', 'title': 'Media Montage'}}) + + data = abx_episode_parser('S1E01-PC-PressConference-edit.kdenlive', []) + self.assertDictEqual(data[1], + {'filetype': 'kdenlive', + 'role': 'edit', + 'hierarchy': 'episode', + 'series': {'code': 'S1'}, + 'episode': {'code': 1}, + 'rank': 'seq', + 'seq': {'code': 'PC', 'title': 'Press Conference'}}) + + data = abx_episode_parser('S1E01-LA-Launch.kdenlive', []) + self.assertDictEqual(data[1], + {'filetype': 'kdenlive', + 'role': 'edit', + 'hierarchy': 'episode', + 'series': {'code': 'S1'}, + 'episode': {'code': 1}, + 'rank': 'seq', + 'seq': {'code': 'LA', 'title': 'Launch'}}) + + + def test_parsing_filenames_w_schema_parser(self): + abx_schema_parser = file_context.NameParsers['abx_schema']( + schemas=self.TESTSCHEMAS, definitions=self.TESTDEFS) + + data = abx_schema_parser('S1E01-SF-4-SoyuzDMInt-cam.blend', + namepath=self.TESTNAMEPATHS[0]) + self.assertDictEqual(data[1], + {'filetype': 'blend', + 'comment': None, + 'role': 'cam', + 'title': 'SoyuzDMInt', + 'series': {'code': 'S1'}, + 'episode': {'code': '01'}, + 'sequence': {'code': 'SF'}, + 'block': {'code': '4', 'title':'SoyuzDMInt'}, + 'rank': 'block'} + ) + + data = abx_schema_parser('S1E02-MM-MediaMontage-compos.blend', + namepath=self.TESTNAMEPATHS[1]) + self.assertDictEqual(data[1], + {'filetype': 'blend', + 'comment': None, + 'role': 'compos', + 'title': 'MediaMontage', + 'series': {'code': 'S1'}, + 'episode': {'code': '02'}, + 'sequence': {'code': 'MM', 'title':'MediaMontage'}, + 'rank': 'sequence'} + ) + + data = abx_schema_parser('S1E01-PC-PressConference-edit.kdenlive', + namepath=self.TESTNAMEPATHS[2]) + self.assertDictEqual(data[1], + {'filetype': 'kdenlive', + 'comment': None, + 'role': 'edit', + 'title': 'PressConference', + 'series': {'code': 'S1'}, + 'episode': {'code': '01'}, + 'sequence': {'code': 'PC', 'title':'PressConference'}, + 'rank': 'sequence'} + ) + + data = abx_schema_parser('S1E01-LA-Launch.kdenlive', + namepath=self.TESTNAMEPATHS[3]) + self.assertDictEqual(data[1], + {'filetype': 'kdenlive', + 'comment': None, + 'role': 'edit', + 'title': 'Launch', + 'series': {'code': 'S1'}, + 'episode': {'code': '01'}, + 'sequence': {'code': 'LA', 'title':'Launch'}, + 'rank': 'sequence'} + ) + + +class FileContext_Implementation_UnitTests(unittest.TestCase): + TESTDATA = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'testdata')) + + TESTPATH = os.path.join(TESTDATA, 'myproject/Episodes/' + + 'A.001-Pilot/Seq/LP-LastPoint/' + + 'A.001-LP-1-BeginningOfEnd-anim.txt') + + def test_filecontext_finds_and_loads_file(self): + fc = file_context.FileContext(self.TESTPATH) + self.assertEqual(fc.filename, 'A.001-LP-1-BeginningOfEnd-anim.txt') + self.assertEqual(fc.root, os.path.join(self.TESTDATA, 'myproject')) + self.assertListEqual(fc.folders, + ['myproject', 'Episodes', 'A.001-Pilot', 'Seq', 'LP-LastPoint']) + + def test_filecontext_gets_correct_yaml_for_file(self): + fc = file_context.FileContext(self.TESTPATH) + # Look for data from three expected YAML files: + # From the project YAML file: + self.assertEqual(fc.provided_data['definitions']['omit_ranks']['scene'], 3) + # From the sequence directory YAML file: + self.assertEqual(fc.provided_data['project_unit'][-2]['name'], 'Last Point') + # From the sidecar YAML file: + self.assertEqual(fc.provided_data['project_unit'][-1]['code'], 1) + + def test_filecontext_gets_correct_filename_info(self): + fc = file_context.FileContext(self.TESTPATH) + self.assertEqual(fc.filetype, 'txt') + self.assertEqual(fc.role, 'anim') + self.assertEqual(fc.title, 'BeginningOfEnd') + self.assertEqual(fc.comment, None) + + +class FileContext_API_UnitTests(unittest.TestCase): + TESTDATA = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'testdata')) + + TESTPATH = os.path.join(TESTDATA, 'myproject/Episodes/' + + 'A.001-Pilot/Seq/LP-LastPoint/' + + 'A.001-LP-1-BeginningOfEnd-anim.txt') + + def setUp(self): + self.fc = file_context.FileContext(self.TESTPATH) + + def test_filecontext_API_namepath(self): + self.assertListEqual( self.fc.namepath, ['myproject', 'A', 1, 'LP', 1]) + + def test_filecontext_API_rank(self): + self.assertEqual(self.fc.rank, 'block') + + def test_filecontext_API_code(self): + self.assertEqual(self.fc.code, 1) + + def test_filecontext_API_name(self): + self.assertEqual(self.fc.name, 'BeginningOfEnd') + + def test_filecontext_API_designation(self): + self.assertEqual(self.fc.designation, + 'myproject-A.001-LP-1') + + def test_filecontext_API_fullname(self): + self.assertEqual(self.fc.fullname, 'myproject-A.001-LP-1-BeginningOfEnd') + + def test_filecontext_API_shortname(self): + self.assertEqual(self.fc.shortname, 'A.001-LP-1-BeginningOfEnd') + + def test_filecontext_API_scene_name(self): + self.assertEqual(self.fc.get_scene_name('Test'), 'LP-1 Test') + + def test_filecontext_API_render_root(self): + self.assertEqual(os.path.abspath(self.fc.render_root), + os.path.abspath(os.path.join(self.TESTDATA, + 'myproject/Episodes/A.001-Pilot/Renders'))) + + def test_filecontext_API_get_render_path(self): + self.assertEqual(os.path.abspath(self.fc.get_render_path(suffix='T')), + os.path.abspath(os.path.join(self.TESTDATA, + 'myproject', 'Episodes', 'A.001-Pilot', 'Renders', + 'T', 'A.001-LP-1', 'A.001-LP-1-T-f#####.png'))) + + def test_filecontext_API_new_name_context_explicit(self): + nc = self.fc.new_name_context(shot='A') + self.assertEqual(nc.get_scene_name('Exp'), 'LP-1-A Exp') + + def test_filecontext_API_new_name_context_implicit(self): + nc = self.fc.new_name_context(rank='shot') + self.assertEqual(nc.get_scene_name('Imp'), 'LP-1-A Imp') + + +class NameContext_API_Tests(unittest.TestCase): + TESTDATA = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'testdata')) + + TESTPATH = os.path.join(TESTDATA, 'myproject/Episodes/' + + 'A.001-Pilot/Seq/LP-LastPoint/' + + 'A.001-LP-1-BeginningOfEnd-anim.txt') + + def setUp(self): + fc = file_context.FileContext(self.TESTPATH) + self.nc = fc.new_name_context(rank='shot', shot='A') + + def test_namecontext_reports_correct_rank(self): + self.assertEqual(self.nc.rank, 'shot') + + def test_namecontext_reports_correct_code(self): + self.assertEqual(self.nc.code, 'A') + + def test_namecontext_reports_correct_namepath(self): + self.assertEqual(self.nc.namepath, ['myproject', 'A', 1, 'LP', 1, None, 'A']) + + +class FileContext_FailOver_Tests(unittest.TestCase): + """ + Tests of how well FileContext copes with imperfect data. + + It's very likely that ABX will encounter projects that aren't + set up perfectly (or at all), and we don't want it to crash + in that situation, but rather fail gracefully or even work + around the problem. + """ + TESTDATA = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', 'testdata')) + + TEST_EMPTY_PROJECT = os.path.join(TESTDATA, 'empty') + + TEST_NONEXISTENT_PATH = os.path.join(TESTDATA, + 'empty', 'Non', 'Existing', 'F', 'F-1-NonExisting-anim.blend') + + TEST_NO_YAML = os.path.join(TESTDATA, + 'yamlless', 'Episodes', 'Ae1-Void', 'Seq', 'VN-VagueName', + 'Ae1-VN-1-VoidOfData-anim.txt') + + TEST_MINIMAL_YAML = os.path.join(TESTDATA, + 'yaminimal', 'Episodes', 'Ae1-Void', 'Seq', 'VN-VagueName', + 'Ae1-VN-1-VoidOfData-anim.txt') + + def test_filecontext_no_project_path(self): + fc = file_context.FileContext() + # What to test? + # The main thing is that it doesn't crash. + + def test_filecontext_failover_empty_project(self): + fc = file_context.FileContext(self.TEST_EMPTY_PROJECT) + + def test_filecontext_failover_nonexisting_file(self): + fc = file_context.FileContext(self.TEST_NONEXISTENT_PATH) + + def test_filecontext_failover_no_yaml(self): + fc = file_context.FileContext(self.TEST_NO_YAML) + + def test_filecontext_failover_minimal_yaml(self): + fc = file_context.FileContext(self.TEST_MINIMAL_YAML) + +