From 0d81cc58575fa26f2b8abb5e830eb3a47820c5a2 Mon Sep 17 00:00:00 2001 From: Terry Hancock Date: Fri, 28 May 2021 11:54:09 -0500 Subject: [PATCH] Integrating data system; unit tests in Blender --- BlenderRemoteDebug.py | 11 - DebugInBlender.py | 2 +- TestInBlender.py | 4 + abx/__init__.py | 19 +- abx/abx.yaml | 175 ++- abx/abx_ui.py | 90 +- abx/accumulate.py | 10 +- abx/file_context.py | 466 ++++-- abx/{std_lunatics_ink.py => ink_paint.py} | 0 abx/render_profile.py | 252 +++- pkg/abx-0.2.6a.zip | Bin 0 -> 33541 bytes pkg/abx/__init__.py | 39 + pkg/abx/abx.yaml | 113 ++ pkg/abx/abx_ui.py | 560 ++++++++ pkg/abx/accumulate.py | 342 +++++ pkg/abx/blender_context.py | 169 +++ pkg/abx/context.py | 26 + pkg/abx/copy_anim.py | 126 ++ pkg/abx/file_context.py | 1266 +++++++++++++++++ pkg/abx/render_profile.py | 203 +++ pkg/abx/std_lunatics_ink.py | 678 +++++++++ scripts/BlenderRemoteDebug.py | 13 + scripts/TestInBlender_bpy.py | 15 + .../test_accumulate.cpython-35.pyc | Bin 0 -> 10527 bytes .../test_file_context.cpython-35.pyc | Bin 0 -> 20599 bytes .../test_render_profile.cpython-35.pyc | Bin 0 -> 3392 bytes tests/test_file_context.py | 116 +- tests/test_render_profile.py | 74 + 28 files changed, 4470 insertions(+), 299 deletions(-) delete mode 100644 BlenderRemoteDebug.py create mode 100644 TestInBlender.py rename abx/{std_lunatics_ink.py => ink_paint.py} (100%) create mode 100644 pkg/abx-0.2.6a.zip create mode 100644 pkg/abx/__init__.py create mode 100644 pkg/abx/abx.yaml create mode 100644 pkg/abx/abx_ui.py create mode 100644 pkg/abx/accumulate.py create mode 100644 pkg/abx/blender_context.py create mode 100644 pkg/abx/context.py create mode 100644 pkg/abx/copy_anim.py create mode 100644 pkg/abx/file_context.py create mode 100644 pkg/abx/render_profile.py create mode 100644 pkg/abx/std_lunatics_ink.py create mode 100644 scripts/BlenderRemoteDebug.py create mode 100644 scripts/TestInBlender_bpy.py create mode 100644 tests/__pycache__/test_accumulate.cpython-35.pyc create mode 100644 tests/__pycache__/test_file_context.cpython-35.pyc create mode 100644 tests/__pycache__/test_render_profile.cpython-35.pyc create mode 100644 tests/test_render_profile.py diff --git a/BlenderRemoteDebug.py b/BlenderRemoteDebug.py deleted file mode 100644 index 710a9f4..0000000 --- a/BlenderRemoteDebug.py +++ /dev/null @@ -1,11 +0,0 @@ -#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 index 0a65e41..83628f3 100644 --- a/DebugInBlender.py +++ b/DebugInBlender.py @@ -1,4 +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']) +subprocess.call(['blender279', '-P', '/project/terry/Dev/Git/abx/scripts/BlenderRemoteDebug.py']) diff --git a/TestInBlender.py b/TestInBlender.py new file mode 100644 index 0000000..1af9ca7 --- /dev/null +++ b/TestInBlender.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +# Inject the unittest runner script into Blender and run it in batch mode: +import subprocess +subprocess.call(['blender279', '-b', '-P', '/project/terry/Dev/Git/abx/scripts/TestInBlender_bpy.py']) diff --git a/abx/__init__.py b/abx/__init__.py index d8fc801..fee1767 100644 --- a/abx/__init__.py +++ b/abx/__init__.py @@ -2,7 +2,7 @@ bl_info = { "name": "ABX", "author": "Terry Hancock / Lunatics.TV Project / Anansi Spaceworks", - "version": (0, 2, 5), + "version": (0, 2, 6), "blender": (2, 79, 0), "location": "SpaceBar Search -> ABX", "description": "Anansi Studio Extensions for Blender", @@ -22,17 +22,20 @@ try: except ImportError: print("Blender Add-On 'ABX' requires the Blender Python environment to run.") -print("blender_present = ", blender_present) - -if blender_present: +if blender_present: from . import abx_ui - + + BlendFile = abx_ui.BlendFile + def register(): - bpy.utils.register_module(__name__) - + abx_ui.register() + #bpy.utils.register_module(__name__) + def unregister(): - bpy.utils.unregister_module(__name__) + abx_ui.unregister() + #bpy.utils.unregister_module(__name__) if __name__ == "__main__": register() + \ No newline at end of file diff --git a/abx/abx.yaml b/abx/abx.yaml index eebaa22..364a862 100644 --- a/abx/abx.yaml +++ b/abx/abx.yaml @@ -1,72 +1,113 @@ # 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 +abx_default: True -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 +project_unit: [] + +project_schema: [] + +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" - 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 + 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 + render: 0 + filename: 0 + scene: 0 + +abx: + 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 - 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 + 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 + motionblur: 2 + rendersize: 100 + compress: 50 diff --git a/abx/abx_ui.py b/abx/abx_ui.py index 60d24a5..954b2ea 100644 --- a/abx/abx_ui.py +++ b/abx/abx_ui.py @@ -23,14 +23,22 @@ run into. # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # - - import os import bpy, bpy.utils, bpy.types, bpy.props +from bpy.app.handlers import persistent + +from . import file_context + +# if bpy.data.filepath: +# BlendfileContext = file_context.FileContext(bpy.data.filepath) +# else: +# BlendfileContext = file_context.FileContext() +# +# abx_data = BlendfileContext.abx_data from . import copy_anim -from . import std_lunatics_ink +from abx import ink_paint from . import render_profile @@ -273,8 +281,7 @@ class LunaticsSceneProperties(bpy.types.PropertyGroup): maxlen=0 ) -bpy.utils.register_class(LunaticsSceneProperties) -bpy.types.Scene.lunaprops = bpy.props.PointerProperty(type=LunaticsSceneProperties) + class LunaticsScenePanel(bpy.types.Panel): """ @@ -315,9 +322,7 @@ class RenderProfileSettings(bpy.types.PropertyGroup): 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): """ @@ -335,6 +340,7 @@ class RenderProfilesOperator(bpy.types.Operator): return {'FINISHED'} + class RenderProfilesPanel(bpy.types.Panel): """ Add simple drop-down selector for generating common render settings with @@ -354,6 +360,7 @@ class RenderProfilesPanel(bpy.types.Panel): row.operator('render.render_profiles') + class copy_animation(bpy.types.Operator): """ Copy animation from active object to selected objects (select source last!). @@ -399,6 +406,8 @@ class copy_animation(bpy.types.Operator): return {'FINISHED'} + + class copy_animation_settings(bpy.types.PropertyGroup): """ Settings for the 'copy_animation' operator. @@ -423,8 +432,7 @@ class copy_animation_settings(bpy.types.PropertyGroup): 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 @@ -443,7 +451,7 @@ class CharacterPanel(bpy.types.Panel): layout.prop(settings, 'rescale') layout.prop(settings, 'scale_factor') - + class lunatics_compositing_settings(bpy.types.PropertyGroup): @@ -465,9 +473,7 @@ class lunatics_compositing_settings(bpy.types.PropertyGroup): 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. @@ -483,7 +489,7 @@ class lunatics_compositing(bpy.types.Operator): """ scene = context.scene - shot = std_lunatics_ink.LunaticsShot(scene, + shot = ink_paint.LunaticsShot(scene, inkthru=context.scene.lx_compos_settings.inkthru, billboards=context.scene.lx_compos_settings.billboards, sepsky=context.scene.lx_compos_settings.sepsky ) @@ -497,7 +503,7 @@ class lunatics_compositing(bpy.types.Operator): # self.col = self.layout.col() # col.prop(settings, "inkthru", text="Ink Thru") # col.prop(settings, "billboards", text="Ink Thru") - + class LunaticsPanel(bpy.types.Panel): @@ -515,16 +521,52 @@ class LunaticsPanel(bpy.types.Panel): layout.prop(settings, 'inkthru', text="Ink-Thru") layout.prop(settings, 'billboards', text="Billboards") layout.prop(settings, 'sepsky', text="Separate Sky") - + + +BlendFile = file_context.FileContext() + +@persistent +def update_handler(ctxt): + BlendFile.update(bpy.data.filepath) + def register(): - bpy.utils.register_module(__name__) + bpy.utils.register_class(LunaticsSceneProperties) + bpy.types.Scene.lunaprops = bpy.props.PointerProperty(type=LunaticsSceneProperties) + bpy.utils.register_class(LunaticsScenePanel) + + bpy.utils.register_class(RenderProfileSettings) + bpy.types.Scene.render_profile_settings = bpy.props.PointerProperty( + type=RenderProfileSettings) + bpy.utils.register_class(RenderProfilesOperator) + bpy.utils.register_class(RenderProfilesPanel) + + bpy.utils.register_class(copy_animation) + bpy.utils.register_class(copy_animation_settings) + bpy.types.Scene.copy_anim_settings = bpy.props.PointerProperty(type=copy_animation_settings) + bpy.utils.register_class(CharacterPanel) + + bpy.utils.register_class(lunatics_compositing_settings) + bpy.types.Scene.lx_compos_settings = bpy.props.PointerProperty(type=lunatics_compositing_settings) + bpy.utils.register_class(lunatics_compositing) + bpy.utils.register_class(LunaticsPanel) + + 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) def unregister(): - bpy.utils.unregister_module(__name__) + bpy.utils.unregister_class(LunaticsSceneProperties) + bpy.utils.unregister_class(LunaticsScenePanel) -if __name__ == "__main__": - register() - - - + bpy.utils.unregister_class(RenderProfileSettings) + bpy.utils.unregister_class(RenderProfilesOperator) + bpy.utils.unregister_class(RenderProfilesPanel) + + bpy.utils.unregister_class(copy_animation) + bpy.utils.unregister_class(copy_animation_settings) + bpy.utils.unregister_class(CharacterPanel) + + bpy.utils.unregister_class(lunatics_compositing_settings) + bpy.utils.unregister_class(lunatics_compositing) + bpy.utils.unregister_class(LunaticsPanel) diff --git a/abx/accumulate.py b/abx/accumulate.py index 553bef5..b592be3 100644 --- a/abx/accumulate.py +++ b/abx/accumulate.py @@ -244,6 +244,10 @@ class RecursiveDict(collections.OrderedDict): #-------- # Code for collecting the YAML files we need +ABX_YAML = os.path.join(os.path.dirname( + os.path.abspath(os.path.join(__file__))), + 'abx.yaml') + def collect_yaml_files(path, stems, dirmatch=False, sidecar=False, root='/'): """ @@ -327,8 +331,10 @@ def get_project_data(filepath): kitcat_root = get_project_root(kitcat_paths) - abx_data = combine_yaml(collect_yaml_files(filepath, - 'abx', root=kitcat_root)) + abx_data = combine_yaml([ABX_YAML])['abx'] + + abx_data.update(combine_yaml(collect_yaml_files(filepath, + 'abx', root=kitcat_root))) return kitcat_root, kitcat_data, abx_data diff --git a/abx/file_context.py b/abx/file_context.py index 10769f2..4dec9b9 100644 --- a/abx/file_context.py +++ b/abx/file_context.py @@ -58,6 +58,11 @@ Demo: import os, re, copy, string, collections import yaml +DEFAULT_YAML = {} +with open(os.path.join(os.path.dirname(__file__), 'abx.yaml')) as def_yaml_file: + DEFAULT_YAML.update(yaml.safe_load(def_yaml_file)) + + 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 @@ -66,6 +71,53 @@ from .accumulate import RecursiveDict wordre = re.compile(r'([A-Z][a-z]+|[a-z]+|[0-9]+|[A-Z][A-Z]+)') +class Enum(dict): + def __init__(self, *options): + for i, option in enumerate(options): + if isinstance(option, list) or isinstance(option, tuple): + name = option[0] + self[i] = tuple(option) + else: + name = str(option) + self[i] = (option, option, option) + self[name] = i + if name not in ('name', 'number', 'options'): + setattr(self, name, i) + + @property + def options(self): + """ + This gives the options in a Blender-friendly format, with + tuples of three strings for initializing bpy.props.Enum(). + + If the Enum was initialized with strings, the options will + contain the same string three times. If initialized with + tuples of strings, they will be used unaltered. + """ + options = [] + number_keys = sorted([k for k in self.keys() if type(k) is int]) + return [self[i] for i in number_keys] + + def name(self, n): + if type(n) is int: + return self[n][0] + elif type(n) is str: + return n + else: + return None + + def number(self, n): + if type(n) is str: + return self[n] + elif type(n) is int: + return n + else: + return None + + +log_level = Enum('DEBUG', 'INFO', 'WARNING', 'ERROR') + + NameParsers = {} # Parser registry def registered_parser(parser): @@ -352,9 +404,12 @@ class Parser_ABX_Schema(object): 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 + try: + if field.lower() == schema.format.format(name).lower(): + score += 1.0 + break + except ValueError: + print(' (365) field, format', field, schema.format) possible += 1.0 @@ -366,11 +421,14 @@ class Parser_ABX_Schema(object): 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 + try: + if ( type(field) == str and + field.lower() == schema.format.format(name).lower()): + fields[schema.rank]={'code':field} + fields['rank'] = schema.rank + score += 1.0 + except ValueError: + print(' (384) field, format', field, schema.format) possible += 2.0 # Remaining fields are authoritative (doesn't affect score) @@ -387,6 +445,77 @@ class Parser_ABX_Schema(object): fields['role'] = self.roles_by_filetype[fields['filetype']] return score/possible, fields + +@registered_parser +class Parser_ABX_Fallback(object): + """ + Highly-tolerant parser to fall back to if the others fail + or can't be used. + """ + name = 'abx_fallback' + + filetypes = DEFAULT_YAML['definitions']['filetypes'] + roles = DEFAULT_YAML['definitions']['roles'] + roles_by_filetype = ( + DEFAULT_YAML['definitions']['roles_by_filetype']) + + main_sep_re = re.compile(r'\W+') # Any single non-word char + comment_sep_re = re.compile(r'[\W_][\W_]+|[~#$!=+&]+') + + + def __init__(self, **kwargs): + pass + + 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 __call__(self, filename, namepath): + fields = {} + score = 1.0 + possible = 4.5 + + split = filename.rsplit('.', 1) + if len(split)<2 or split[1] not in self.filetypes: + fields['filetype'] = None + remainder = filename + score += 1.0 + else: + fields['filetype'] = split[1] + remainder = split[0] + + comment_match = self.comment_sep_re.search(remainder) + if comment_match: + fields['comment'] = remainder[comment_match.end():] + remainder = remainder[:comment_match.start()] + else: + fields['comment'] = None + + role = self.main_sep_re.split(remainder)[-1] + if role in self.roles: + fields['role'] = role + remainder = remainder[:-1-len(role)] + score += 1.0 + else: + fields['role'] = None + + # Implied role + if fields['filetype'] in self.roles_by_filetype: + fields['role'] = self.roles_by_filetype[fields['filetype']] + score += 1.0 + + words = self.main_sep_re.split(remainder) + fields['code'] = ''.join([w.capitalize() for w in words]) + fields['title'] = remainder + + return score/possible, fields + class RankNotFound(LookupError): @@ -574,8 +703,19 @@ class NameContext(object): """ def __init__(self, container, fields=None, namepath_segment=(), ): - self.schemas = [] + self.clear() + if container or fields or namepath_segment: + self.update(container, fields, namepath_segment) + + def clear(self): self.fields = {} + self.schemas = ['project'] + self.rank = 0 + self.code = 'untitled' + self.container = None + self.namepath_segment = [] + + def update(self, container=None, fields=None, namepath_segment=()): self.container = container if namepath_segment: @@ -584,12 +724,9 @@ class NameContext(object): 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 @@ -606,12 +743,6 @@ class NameContext(object): 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) @@ -684,7 +815,7 @@ class NameContext(object): return None @rank.setter - def set_rank(self, rank): + def rank(self, rank): self.fields['rank'] = rank @property @@ -699,7 +830,7 @@ class NameContext(object): return '' @name.setter - def set_name(self, name): + def name(self, name): self.fields['name'] = name @property @@ -710,7 +841,7 @@ class NameContext(object): return self.fields['code'] @code.setter - def code_setter(self, code): + def code(self, code): if self.rank: self.fields[self.rank] = {'code': code} else: @@ -724,7 +855,7 @@ class NameContext(object): return '' @description.setter - def set_description(self, description): + def description(self, description): self.fields['description'] = str(description) def _get_name_components(self): @@ -797,6 +928,7 @@ class FileContext(NameContext): # hierarchy = None #schema = None + # IMMUTABLE DEFAULTS: filepath = None root = None folders = () @@ -820,97 +952,151 @@ class FileContext(NameContext): 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?) - + self.clear() + self.clear_notes() if path: self.update(path) + + def clear(self): + NameContext.clear(self) + + # Identity + self.root = os.path.abspath(os.environ['HOME']) + self.render_root = os.path.join(self.root, 'Renders') + self.filetype = '' + self.role = '' + self.title = '' + self.comment = '' + # Containers + #self.notes = [] + self.name_contexts = [] + + # Status / Settings + self.filepath = None + self.filename = None + self.file_exists = False + self.folder_exists = False + self.omit_ranks = { + 'edit': 0, + 'render': 0, + 'filename': 0, + 'scene': 0} + + # Defaults + self.provided_data = RecursiveDict(DEFAULT_YAML) + self.abx_fields = DEFAULT_YAML['abx'] + + def clear_notes(self): + # We use this for logging, so it doesn't get cleared by the + # normal clear process. + self.notes = [] def update(self, path): - # Basic File Path Info - self.filepath = os.path.abspath(path) - self.filename = os.path.basename(path) + # Basic File Path Info + self.filepath = os.path.abspath(path) + self.filename = os.path.basename(path) + + # Does the file path exist? + if os.path.exists(path): + self.file_exists = True + self.folder_exists = True + else: + self.file_exists = False + if os.path.exists(os.path.dirname(path)): + self.folder_exists = True + else: + self.folder_exists = False - # 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 - # + # - 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() + self.provided_data = RecursiveDict(DEFAULT_YAML) + + 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 + # 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 + # + try: 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'])] + except: + print("Errors finding Name Path (is there a 'project_schema' or 'project_unit' defined?") + pass + # print("\n(899) filename = ", self.filename) + # if 'project_schema' in self.provided_data: + # print("(899) project_schema: ", self.provided_data['project_schema']) + # else: + # print("(899) project schema NOT DEFINED") + # + # print("(904) self.namepath_segment = ", self.namepath_segment) - self.parser_chosen, self.parser_score = self._parse_filename() + + # 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.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 = [] + parser_chosen, parser_score = self._parse_filename() + self.log(log_level.INFO, "Parsed with %s, score: %d" % + (parser_chosen, parser_score)) + + + + # TODO: + # We don't currently consider the information from the folder names, + # though we could get some additional information this way + def __repr__(self): @@ -919,7 +1105,18 @@ class FileContext(NameContext): s = s + str(self.code) + '(' + str(self.rank) + ')' s = s + ')' return s - + + def log(self, level, msg): + if type(level) is str: + level = log_level.index(level) + self.notes.append((level, msg)) + + def get_log_text(self, level=log_level.INFO): + level = log_level.number(level) + return '\n'.join([ + ': '.join((log_level.name(note[0]), note[1])) + for note in self.notes + if log_level.number(note[0]) >= level]) def _parse_filename(self): """ @@ -928,6 +1125,7 @@ class FileContext(NameContext): """ fields = {} best_score = 0.0 + best_parser_name = None for parser in self.parsers: score, fielddata = parser(self.filename, self.namepath) if score > best_score: @@ -946,18 +1144,51 @@ class FileContext(NameContext): 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 +# def _collect_yaml_data(self): + @property + def filetype(self): + if 'filetype' in self.fields: + return self.fields['filetype'] + else: + return '' + + @filetype.setter + def filetype(self, filetype): + self.fields['filetype'] = filetype + + @property + def role(self): + if 'role' in self.fields: + return self.fields['role'] + else: + return '' + + @role.setter + def role(self, role): + self.fields['role'] = role + + @property + def title(self): + if 'title' in self.fields: + return self.fields['title'] + else: + return '' + + @title.setter + def title(self, title): + self.fields['title'] = title + + @property + def comment(self): + if 'comment' in self.fields: + return self.fields['comment'] + else: + return '' + + @comment.setter + def comment(self, comment): + self.fields['comment'] = comment @classmethod def deref_implications(cls, values, matchfields): @@ -993,12 +1224,9 @@ class FileContext(NameContext): fields = {} fields.update(self.fields) - namepath_segment = [] - - ranks = [s.rank for s in self.schemas] - - i_rank = len(self.namepath) - + 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 diff --git a/abx/std_lunatics_ink.py b/abx/ink_paint.py similarity index 100% rename from abx/std_lunatics_ink.py rename to abx/ink_paint.py diff --git a/abx/render_profile.py b/abx/render_profile.py index a2665df..17ce542 100644 --- a/abx/render_profile.py +++ b/abx/render_profile.py @@ -3,81 +3,201 @@ Blender Python code to set parameters based on render profiles. """ +import bpy import bpy, bpy.types, bpy.utils, bpy.props -from . import std_lunatics_ink +from abx import ink_paint -render_formats = { - # VERY simplified and limited list of formats from Blender that we need: - # : (, ), - 'PNG': ('PNG', 'png'), - 'JPG': ('JPEG', 'jpg'), - 'EXR': ('OPEN_EXR_MULTILAYER', 'exr'), - 'AVI': ('AVI_JPEG', 'avi'), - 'MKV': ('FFMPEG', 'mkv') - } +from . import file_context -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' +class RenderProfile(object): + render_formats = { + # VERY simplified and limited list of formats from Blender that we need: + # : (, ), + 'PNG': ('PNG', 'png'), + 'JPG': ('JPEG', 'jpg'), + 'EXR': ('OPEN_EXR_MULTILAYER', 'exr'), + 'AVI': ('AVI_JPEG', 'avi'), + 'MKV': ('FFMPEG', 'mkv') + } + + engines = { + 'bi': 'BLENDER_RENDER', + 'BLENDER_RENDER': 'BLENDER_RENDER', + 'BI': 'BLENDER_RENDER', + + 'cycles': 'CYCLES', + 'CYCLES': 'CYCLES', + + 'bge': 'BLENDER_GAME', + 'BLENDER_GAME': 'BLENDER_GAME', + 'BGE': 'BLENDER_GAME', + + 'gl': None, + 'GL': None + } + + + def __init__(self, fields): + + # Note: Settings w/ value *None* are left unaltered + # That is, they remain whatever they were before + # If a setting isn't included in the fields, then + # the attribute will be *None*. + + if 'engine' not in fields: + fields['engine'] = None - 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']) + if fields['engine']=='gl': + self.viewport_render = True + self.engine = None 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'] + self.viewport_render = False + + if fields['engine'] in self.engines: + self.engine = self.engines[fields['engine']] else: - scene.render.use_motion_blur = False - - # Use Lunatics naming scheme for render target: - if 'framedigits' in profile: - framedigits = profile['framedigits'] - else: - framedigits = 5 + self.engine = None + + # Parameters which are stored as-is, without modification: + self.fps = 'fps' in fields and int(fields['fps']) or None + self.fps_skip = 'fps_skip' in fields and int(fields['fps_skip']) or None + self.fps_divisor = 'fps_divisor' in fields and float(fields['fps_divisor']) or None + self.rendersize = 'rendersize' in fields and int(fields['rendersize']) or None + self.compress = 'compress' in fields and int(fields['compress']) or None - if 'suffix' in profile: - suffix = profile['suffix'] - else: - suffix = '' + self.format = 'format' in fields and str(fields['format']) or None - 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 + self.freestyle = 'freestyle' in fields and bool(fields['freestyle']) or None + + self.antialiasing_samples = None + self.use_antialiasing = None + if 'antialias' in fields: + if fields['antialias']: + self.use_antialiasing = True + if fields['antialias'] in (5,8,11,16): + self.antialiasing_samples = str(fields['antialias']) + else: + self.use_antialiasing = False + + self.use_motion_blur = None + self.motion_blur_samples = None + if 'motionblur' in fields: + if fields['motionblur']: + self.use_motion_blur = True + if type(fields['motionblur'])==int: + self.motion_blur_samples = int(fields['motionblur']) + else: + self.use_motion_blur = False + + if 'framedigits' in fields: + self.framedigits = fields['framedigits'] + else: + self.framedigits = 5 + + if 'suffix' in fields: + self.suffix = fields['suffix'] + else: + self.suffix = '' + + def apply(self, scene): + """ + Apply the profile settings to the given scene. + """ + if self.engine: scene.render.engine = self.engine + if self.fps: scene.render.fps = self.fps + if self.fps_skip: scene.frame_step = self.fps_skip + if self.fps_divisor: scene.render.fps_base = self.fps_divisor + if self.rendersize: scene.render.resolution_percentage = self.rendersize + if self.compress: scene.render.image_settings.compression = self.compress + + if self.format: + scene.render.image_settings.file_format = self.render_formats[self.format][0] + + if self.freestyle: scene.render.use_freestyle = self.freestyle + if self.use_antialiasing: + scene.render.use_antialiasing = self.use_antialiasing + + if self.antialiasing_samples: + scene.render.antialiasing_samples = self.antialiasing_samples + if self.use_motion_blur: + scene.render.use_motion_blur = self.use_motion_blur + + if self.motion_blur_samples: + scene.render.motion_blur_samples = self.motion_blur_samples + + if self.format: + # prefix = scene.name_context.render_path + # prefix = BlendfileContext.name_contexts[scene.name_context].render_path + prefix = 'path_to_render' # We actually need to get this from NameContext + if self.suffix: + scene.render.filepath = (prefix + '-' + self.suffix + '-' + + 'f'+('#'*self.framedigits) + '.' + + self.render_formats[self.format][1]) + + + +# 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 = ink_paint.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/pkg/abx-0.2.6a.zip b/pkg/abx-0.2.6a.zip new file mode 100644 index 0000000000000000000000000000000000000000..552e9c344cc4cfde9598819f145a92498af0a53b GIT binary patch literal 33541 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 literal 0 HcmV?d00001 diff --git a/pkg/abx/__init__.py b/pkg/abx/__init__.py new file mode 100644 index 0000000..82990a7 --- /dev/null +++ b/pkg/abx/__init__.py @@ -0,0 +1,39 @@ + +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.") + +if blender_present: + from . import abx_ui + + def register(): + abx_ui.register() + #bpy.utils.register_module(__name__) + + def unregister(): + abx_ui.unregister() + #bpy.utils.unregister_module(__name__) + + +if __name__ == "__main__": + register() + \ No newline at end of file diff --git a/pkg/abx/abx.yaml b/pkg/abx/abx.yaml new file mode 100644 index 0000000..364a862 --- /dev/null +++ b/pkg/abx/abx.yaml @@ -0,0 +1,113 @@ +# DEFAULT ABX SETTINGS +--- +abx_default: True + +project_unit: [] + +project_schema: [] + +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 + render: 0 + filename: 0 + scene: 0 + +abx: + 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 + motionblur: 2 + rendersize: 100 + compress: 50 diff --git a/pkg/abx/abx_ui.py b/pkg/abx/abx_ui.py new file mode 100644 index 0000000..5380e1d --- /dev/null +++ b/pkg/abx/abx_ui.py @@ -0,0 +1,560 @@ +# 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 file_context + +# if bpy.data.filepath: +# BlendfileContext = file_context.FileContext(bpy.data.filepath) +# else: +# BlendfileContext = file_context.FileContext() +# +# abx_data = BlendfileContext.abx_data + +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 + ) + + + +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') + + + +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) + + + +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) + + +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_class(LunaticsSceneProperties) + bpy.types.Scene.lunaprops = bpy.props.PointerProperty(type=LunaticsSceneProperties) + bpy.utils.register_class(LunaticsScenePanel) + + bpy.utils.register_class(RenderProfileSettings) + bpy.types.Scene.render_profile_settings = bpy.props.PointerProperty( + type=RenderProfileSettings) + bpy.utils.register_class(RenderProfilesOperator) + bpy.utils.register_class(RenderProfilesPanel) + + bpy.utils.register_class(copy_animation) + bpy.utils.register_class(copy_animation_settings) + bpy.types.Scene.copy_anim_settings = bpy.props.PointerProperty(type=copy_animation_settings) + bpy.utils.register_class(CharacterPanel) + + bpy.utils.register_class(lunatics_compositing_settings) + bpy.types.Scene.lx_compos_settings = bpy.props.PointerProperty(type=lunatics_compositing_settings) + bpy.utils.register_class(lunatics_compositing) + bpy.utils.register_class(LunaticsPanel) + +def unregister(): + bpy.utils.unregister_class(LunaticsSceneProperties) + bpy.utils.unregister_class(LunaticsScenePanel) + + bpy.utils.unregister_class(RenderProfileSettings) + bpy.utils.unregister_class(RenderProfilesOperator) + bpy.utils.unregister_class(RenderProfilesPanel) + + bpy.utils.unregister_class(copy_animation) + bpy.utils.unregister_class(copy_animation_settings) + bpy.utils.unregister_class(CharacterPanel) + + bpy.utils.unregister_class(lunatics_compositing_settings) + bpy.utils.unregister_class(lunatics_compositing) + bpy.utils.unregister_class(LunaticsPanel) diff --git a/pkg/abx/accumulate.py b/pkg/abx/accumulate.py new file mode 100644 index 0000000..b592be3 --- /dev/null +++ b/pkg/abx/accumulate.py @@ -0,0 +1,342 @@ +# 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 + +ABX_YAML = os.path.join(os.path.dirname( + os.path.abspath(os.path.join(__file__))), + 'abx.yaml') + + +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([ABX_YAML])['abx'] + + abx_data.update(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/pkg/abx/blender_context.py b/pkg/abx/blender_context.py new file mode 100644 index 0000000..1a7d217 --- /dev/null +++ b/pkg/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/pkg/abx/context.py b/pkg/abx/context.py new file mode 100644 index 0000000..0ea3c65 --- /dev/null +++ b/pkg/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/pkg/abx/copy_anim.py b/pkg/abx/copy_anim.py new file mode 100644 index 0000000..b3a3c59 --- /dev/null +++ b/pkg/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/pkg/abx/file_context.py b/pkg/abx/file_context.py new file mode 100644 index 0000000..4dec9b9 --- /dev/null +++ b/pkg/abx/file_context.py @@ -0,0 +1,1266 @@ +# 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 + +DEFAULT_YAML = {} +with open(os.path.join(os.path.dirname(__file__), 'abx.yaml')) as def_yaml_file: + DEFAULT_YAML.update(yaml.safe_load(def_yaml_file)) + + +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]+)') + +class Enum(dict): + def __init__(self, *options): + for i, option in enumerate(options): + if isinstance(option, list) or isinstance(option, tuple): + name = option[0] + self[i] = tuple(option) + else: + name = str(option) + self[i] = (option, option, option) + self[name] = i + if name not in ('name', 'number', 'options'): + setattr(self, name, i) + + @property + def options(self): + """ + This gives the options in a Blender-friendly format, with + tuples of three strings for initializing bpy.props.Enum(). + + If the Enum was initialized with strings, the options will + contain the same string three times. If initialized with + tuples of strings, they will be used unaltered. + """ + options = [] + number_keys = sorted([k for k in self.keys() if type(k) is int]) + return [self[i] for i in number_keys] + + def name(self, n): + if type(n) is int: + return self[n][0] + elif type(n) is str: + return n + else: + return None + + def number(self, n): + if type(n) is str: + return self[n] + elif type(n) is int: + return n + else: + return None + + +log_level = Enum('DEBUG', 'INFO', 'WARNING', 'ERROR') + + +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) + try: + if field.lower() == schema.format.format(name).lower(): + score += 1.0 + break + except ValueError: + print(' (365) field, format', field, schema.format) + + 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 + try: + if ( type(field) == str and + field.lower() == schema.format.format(name).lower()): + fields[schema.rank]={'code':field} + fields['rank'] = schema.rank + score += 1.0 + except ValueError: + print(' (384) field, format', field, schema.format) + 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 + +@registered_parser +class Parser_ABX_Fallback(object): + """ + Highly-tolerant parser to fall back to if the others fail + or can't be used. + """ + name = 'abx_fallback' + + filetypes = DEFAULT_YAML['definitions']['filetypes'] + roles = DEFAULT_YAML['definitions']['roles'] + roles_by_filetype = ( + DEFAULT_YAML['definitions']['roles_by_filetype']) + + main_sep_re = re.compile(r'\W+') # Any single non-word char + comment_sep_re = re.compile(r'[\W_][\W_]+|[~#$!=+&]+') + + + def __init__(self, **kwargs): + pass + + 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 __call__(self, filename, namepath): + fields = {} + score = 1.0 + possible = 4.5 + + split = filename.rsplit('.', 1) + if len(split)<2 or split[1] not in self.filetypes: + fields['filetype'] = None + remainder = filename + score += 1.0 + else: + fields['filetype'] = split[1] + remainder = split[0] + + comment_match = self.comment_sep_re.search(remainder) + if comment_match: + fields['comment'] = remainder[comment_match.end():] + remainder = remainder[:comment_match.start()] + else: + fields['comment'] = None + + role = self.main_sep_re.split(remainder)[-1] + if role in self.roles: + fields['role'] = role + remainder = remainder[:-1-len(role)] + score += 1.0 + else: + fields['role'] = None + + # Implied role + if fields['filetype'] in self.roles_by_filetype: + fields['role'] = self.roles_by_filetype[fields['filetype']] + score += 1.0 + + words = self.main_sep_re.split(remainder) + fields['code'] = ''.join([w.capitalize() for w in words]) + fields['title'] = remainder + + 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.clear() + if container or fields or namepath_segment: + self.update(container, fields, namepath_segment) + + def clear(self): + self.fields = {} + self.schemas = ['project'] + self.rank = 0 + self.code = 'untitled' + self.container = None + self.namepath_segment = [] + + def update(self, container=None, fields=None, namepath_segment=()): + self.container = container + + if namepath_segment: + self.namepath_segment = namepath_segment + else: + self.namepath_segment = [] + + try: + self.schemas = self.container.schemas + except AttributeError: + self.schemas = [] + + 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)) + + 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 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 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(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 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 + + # IMMUTABLE DEFAULTS: + 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). + """ + NameContext.__init__(self, None, {}) + self.clear() + self.clear_notes() + if path: + self.update(path) + + def clear(self): + NameContext.clear(self) + + # Identity + self.root = os.path.abspath(os.environ['HOME']) + self.render_root = os.path.join(self.root, 'Renders') + self.filetype = '' + self.role = '' + self.title = '' + self.comment = '' + + # Containers + #self.notes = [] + self.name_contexts = [] + + # Status / Settings + self.filepath = None + self.filename = None + self.file_exists = False + self.folder_exists = False + self.omit_ranks = { + 'edit': 0, + 'render': 0, + 'filename': 0, + 'scene': 0} + + # Defaults + self.provided_data = RecursiveDict(DEFAULT_YAML) + self.abx_fields = DEFAULT_YAML['abx'] + + def clear_notes(self): + # We use this for logging, so it doesn't get cleared by the + # normal clear process. + self.notes = [] + + 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? + if os.path.exists(path): + self.file_exists = True + self.folder_exists = True + else: + self.file_exists = False + if os.path.exists(os.path.dirname(path)): + self.folder_exists = True + else: + self.folder_exists = False + + # - 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() + self.provided_data = RecursiveDict(DEFAULT_YAML) + + 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 + # 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 + # + try: + 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] + except: + print("Errors finding Name Path (is there a 'project_schema' or 'project_unit' defined?") + pass + # print("\n(899) filename = ", self.filename) + # if 'project_schema' in self.provided_data: + # print("(899) project_schema: ", self.provided_data['project_schema']) + # else: + # print("(899) project schema NOT DEFINED") + # + # print("(904) self.namepath_segment = ", self.namepath_segment) + + + # 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'])] + + parser_chosen, parser_score = self._parse_filename() + self.log(log_level.INFO, "Parsed with %s, score: %d" % + (parser_chosen, parser_score)) + + + + # TODO: + # We don't currently consider the information from the folder names, + # though we could get some additional information this way + + + + 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 log(self, level, msg): + if type(level) is str: + level = log_level.index(level) + self.notes.append((level, msg)) + + def get_log_text(self, level=log_level.INFO): + level = log_level.number(level) + return '\n'.join([ + ': '.join((log_level.name(note[0]), note[1])) + for note in self.notes + if log_level.number(note[0]) >= level]) + + 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 + best_parser_name = None + 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): + + @property + def filetype(self): + if 'filetype' in self.fields: + return self.fields['filetype'] + else: + return '' + + @filetype.setter + def filetype(self, filetype): + self.fields['filetype'] = filetype + + @property + def role(self): + if 'role' in self.fields: + return self.fields['role'] + else: + return '' + + @role.setter + def role(self, role): + self.fields['role'] = role + + @property + def title(self): + if 'title' in self.fields: + return self.fields['title'] + else: + return '' + + @title.setter + def title(self, title): + self.fields['title'] = title + + @property + def comment(self): + if 'comment' in self.fields: + return self.fields['comment'] + else: + return '' + + @comment.setter + def comment(self, comment): + self.fields['comment'] = comment + + @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/pkg/abx/render_profile.py b/pkg/abx/render_profile.py new file mode 100644 index 0000000..6a19430 --- /dev/null +++ b/pkg/abx/render_profile.py @@ -0,0 +1,203 @@ +# render_profile.py +""" +Blender Python code to set parameters based on render profiles. +""" + +import bpy +import bpy, bpy.types, bpy.utils, bpy.props + +from . import std_lunatics_ink + +from . import file_context + + +class RenderProfile(object): + 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') + } + + engines = { + 'bi': 'BLENDER_RENDER', + 'BLENDER_RENDER': 'BLENDER_RENDER', + 'BI': 'BLENDER_RENDER', + + 'cycles': 'CYCLES', + 'CYCLES': 'CYCLES', + + 'bge': 'BLENDER_GAME', + 'BLENDER_GAME': 'BLENDER_GAME', + 'BGE': 'BLENDER_GAME', + + 'gl': None, + 'GL': None + } + + + def __init__(self, fields): + + # Note: Settings w/ value *None* are left unaltered + # That is, they remain whatever they were before + # If a setting isn't included in the fields, then + # the attribute will be *None*. + + if 'engine' not in fields: + fields['engine'] = None + + if fields['engine']=='gl': + self.viewport_render = True + self.engine = None + else: + self.viewport_render = False + + if fields['engine'] in self.engines: + self.engine = self.engines[fields['engine']] + else: + self.engine = None + + # Parameters which are stored as-is, without modification: + self.fps = 'fps' in fields and int(fields['fps']) or None + self.fps_skip = 'fps_skip' in fields and int(fields['fps_skip']) or None + self.fps_divisor = 'fps_divisor' in fields and float(fields['fps_divisor']) or None + self.rendersize = 'rendersize' in fields and int(fields['rendersize']) or None + self.compress = 'compress' in fields and int(fields['compress']) or None + + self.format = 'format' in fields and str(fields['format']) or None + + self.freestyle = 'freestyle' in fields and bool(fields['freestyle']) or None + + self.antialiasing_samples = None + self.use_antialiasing = None + if 'antialias' in fields: + if fields['antialias']: + self.use_antialiasing = True + if fields['antialias'] in (5,8,11,16): + self.antialiasing_samples = str(fields['antialias']) + else: + self.use_antialiasing = False + + self.use_motion_blur = None + self.motion_blur_samples = None + if 'motionblur' in fields: + if fields['motionblur']: + self.use_motion_blur = True + if type(fields['motionblur'])==int: + self.motion_blur_samples = int(fields['motionblur']) + else: + self.use_motion_blur = False + + if 'framedigits' in fields: + self.framedigits = fields['framedigits'] + else: + self.framedigits = 5 + + if 'suffix' in fields: + self.suffix = fields['suffix'] + else: + self.suffix = '' + + def apply(self, scene): + """ + Apply the profile settings to the given scene. + """ + if self.engine: scene.render.engine = self.engine + if self.fps: scene.render.fps = self.fps + if self.fps_skip: scene.frame_step = self.fps_skip + if self.fps_divisor: scene.render.fps_base = self.fps_divisor + if self.rendersize: scene.render.resolution_percentage = self.rendersize + if self.compress: scene.render.image_settings.compression = self.compress + + if self.format: + scene.render.image_settings.file_format = self.render_formats[self.format][0] + + if self.freestyle: scene.render.use_freestyle = self.freestyle + if self.use_antialiasing: + scene.render.use_antialiasing = self.use_antialiasing + + if self.antialiasing_samples: + scene.render.antialiasing_samples = self.antialiasing_samples + if self.use_motion_blur: + scene.render.use_motion_blur = self.use_motion_blur + + if self.motion_blur_samples: + scene.render.motion_blur_samples = self.motion_blur_samples + + if self.format: + # prefix = scene.name_context.render_path + # prefix = BlendfileContext.name_contexts[scene.name_context].render_path + prefix = 'path_to_render' # We actually need to get this from NameContext + if self.suffix: + scene.render.filepath = (prefix + '-' + self.suffix + '-' + + 'f'+('#'*self.framedigits) + '.' + + self.render_formats[self.format][1]) + + + +# 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/pkg/abx/std_lunatics_ink.py b/pkg/abx/std_lunatics_ink.py new file mode 100644 index 0000000..1e81df9 --- /dev/null +++ b/pkg/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/scripts/BlenderRemoteDebug.py b/scripts/BlenderRemoteDebug.py new file mode 100644 index 0000000..0eeb456 --- /dev/null +++ b/scripts/BlenderRemoteDebug.py @@ -0,0 +1,13 @@ +#script to run: +SCRIPT="/project/terry/Dev/eclipse-workspace/ABX/abx/abx_ui.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/.eclipse/360744286_linux_gtk_x86_64/plugins/org.python.pydev.core_8.3.0.202104101217/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/scripts/TestInBlender_bpy.py b/scripts/TestInBlender_bpy.py new file mode 100644 index 0000000..76d8b7f --- /dev/null +++ b/scripts/TestInBlender_bpy.py @@ -0,0 +1,15 @@ +# +# This testrunner is based on a Stack Overflow answer: +# https://stackoverflow.com/questions/1732438/how-do-i-run-all-python-unit-tests-in-a-directory +# +ABX_PATH = '/project/terry/Dev/Git/abx' + +import os, unittest + +loader = unittest.TestLoader() +start_dir = os.path.join(ABX_PATH, 'tests') +suite = loader.discover(start_dir) + +runner = unittest.TextTestRunner(verbosity=2) +runner.run(suite) + diff --git a/tests/__pycache__/test_accumulate.cpython-35.pyc b/tests/__pycache__/test_accumulate.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ab3a36e3b9f37fd8db22fb4cb9da7ebc84d78b5 GIT binary patch literal 10527 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_file_context.cpython-35.pyc b/tests/__pycache__/test_file_context.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d84587bcf4d60e479ed31967b4056e0801ad5dfb GIT binary patch literal 20599 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_render_profile.cpython-35.pyc b/tests/__pycache__/test_render_profile.cpython-35.pyc new file mode 100644 index 0000000000000000000000000000000000000000..723484e65cda7bb60bb8bcaf5694f4973db8837c GIT binary patch literal 3392 zcmbVOOLH4V5T4o9%X&n9C#C`{!B8laWd|q{LIN>~6H=s%3p*EGmbG?luWGfc%#4y) z1t;?p_!FG?9UQ6RG*z59nG<JDd_5~UQsO+wO55A>>Y48Tx~I?9YUSVl{`LFcrx^Q- zjXfpsH}I;DIb$JXEl|!vhsB(=z+}9`TF!`fS<4;qIo8UJ_&jUnnJBQ(WunNA9L7bS zbxN!=#e`#-@`$O>j1Vhx?8s$Doc??`31ivpsuhF%F)vT^TD8#-=G0h-tsteu!YLM( zSy*95Mb<hqI!>Ln>LY%dwWcj!W$`rY&al=D<D^>6wr5#umhn3w*4EiiFhHFN&N_2; z(|L9$#Nrt{Ff%r=Kvvpr%ntr2?+B$meIPt3l2Ay$C)2$s7G5_E`>|-2%R2{=@(u#! z>D1fBw0`34$+YV|iu6I0cwfbKU9-F`#MQEgha}!jM*Y1*>Z9j_DCvl{PNk>XGU{n> z#cKy~jLoqDgp*`Ch{k%=cvh6Z+dFJ#;)bF`XeX~=Wg<pm8|7od$mXEp`=p8Qd)K{z z(`=d&Ne%-Yn5pg|^rX|6(nc>*X(-g7vff-<yR@<u#i=&#ws<h8-rHKa7bv}zMu{F= z$i~rkY2_=iA0<hY?0>trk%TKj5_OyUv2JivwR^VHL<`$KGild0oo)0uU~b@5=RpJj z47Y-g;0kvVTwQo7XU>vYzq7X9%3S2%*HNt6z`lLxO9F4=Rr4S}FxFvA0}YM^DMW8K z_-4a3<v=MR^^WWd;|9BrO?@}>BD^n@NJx*I67k+IOx7Uj-^;5*bFFG2<>Bfr@o@D{ zq*t*PJ#d$${-mn#m6@Yaa-1Kjk?^<afOJyE2OmC1tL7=|HerR5?<YYQJ~d_E&maoE z>iZA+L7a`qIwad;kC8LfNBQC{yms>{x9tgkoFtT=F>Bl(!eyNt>mWyDOu;4rn_sdd z&?=1$A*|sx8~P`KPVtRcx8Y8HwLu?NDKc9X3T8ww@l}do1TFFCoMGXt<q_X=aNoSK z;WdeayWL(ax^P>dqckC?OERx?iR3^gpe^&%GYReGmq6RX<8-9mfHV!Hai)>`ta*?T zfjvJz>?a%+2!YgDw9{;6r6%k#L}ghPAjwp2BV=!8(PLZy$K-6pmME-xfj%(qUVF`y zb~d(mw$^vPrsgG69LAf;tF}l)gUc#}J>`E43VpwK5hIHBV*DIm;Dh&{h2W$?n|9fR z_GDQbXKP?czl_>YgF<wHJn4l^Y(!J9)z;Y@ypq9z_evJ28w7884x1Tk2tvAi1I_1X zx|oFz1@(A3TE&W?>WjS22bW)}`ss2`w*{Ri(;C+kF<It$qP3|{CJ2R8RLT^*n`GLR zkx`JfMI5T<sAe<1Ptyn&NI6<Y;-LurQ0xW$SgTJ+$zgg~e>DYPAni3(mG6*qTzLT> z>nQmyb)1gN^VZ6z9D;mzc}$(_udNPAI#Gp;bAsSM(a7SLTxLTU?ps2Auyn-PkMJbi zn6v&wC4*XrrWJ<mPLYZuWV;dK#|IGGsF8-DP##->YG){CytjJNJ1zt{5Zexgj1M3o zBB`7*P-h*~JXsZI!>zPwy<u{D{Ww0R3t%5Fo`1eH{K_%ru;Y(F8Z)wpkEu;$SBp#Q zJ(CZ5z4%a`$0SK&Od-QFa!^kpFnh}14REodPGzRvR{}Q_Wn_Oh?n^UG9YLa_AdUis z(!tD+w|o^)8dqikS8y8l?ZTdr$Y(m(7v@aV#m7f3(palZrJZ)^wpQ4n;n;lFs@#T# z!|LGa<acorWW#9j$tI(k&u^zvq9(eE^V<r7@d7`~r=2Cf#24|S@8FyN3BnicwArZ3 zX)@;wky)~vlPZ(5iM|-@D$C?LsK4myx4F#ElK-vsoprfH%ibj75m_ek9+6LpQ0X#8 zYz;7nJ_<@j+g6=rXMxw<Y92qg>Pi}IESjS4hiMyyMX8@enhYl?45@&8tRKmsXL5%@ zH#V+1R3_h(s1D`lm>{pvtO8(y#A`}PD!ZhBB!kM&h+HFLPrX1~nF!rQ8S+YG+M5I+ qszmH*#?EimAtg-dT2}geK}fU3W5HeKRj0~pyqW3bP-~iem-`P}vqZH3 literal 0 HcmV?d00001 diff --git a/tests/test_file_context.py b/tests/test_file_context.py index f9c88ac..7231faf 100644 --- a/tests/test_file_context.py +++ b/tests/test_file_context.py @@ -16,6 +16,51 @@ sys.path.append(os.path.normpath(os.path.join(__file__, '..', '..'))) from abx import file_context +class FileContext_Utilities_Tests(unittest.TestCase): + """ + Test utility functions and classes that FileContext features depend on. + """ + + def test_enum_class_basics(self): + my_enum = file_context.Enum('ZERO', 'ONE', 'TWO', 'THREE') + + self.assertEqual(my_enum.number(my_enum.ZERO), 0) + self.assertEqual(my_enum.number(0), 0) + self.assertEqual(my_enum.number('ZERO'), 0) + + self.assertEqual(my_enum.name(my_enum.ZERO), 'ZERO') + self.assertEqual(my_enum.name(0), 'ZERO') + self.assertEqual(my_enum.name('ZERO'), 'ZERO') + + self.assertEqual(my_enum.ONE, 1) + self.assertEqual(my_enum.name(my_enum.TWO), 'TWO') + self.assertEqual(my_enum.name(2), 'TWO') + self.assertEqual(my_enum.number('THREE'), 3) + + def test_enum_class_blender_enum_options(self): + my_options = file_context.Enum( + ('ZP', 'ZeroPoint', 'Zero Point'), + ('FP', 'FirstPoint', 'First Point'), + ('LP', 'LastPoint', 'Last Point')) + + #print("dir(my_options) = ", dir(my_options)) + + self.assertEqual(my_options.number(my_options.ZP), 0) + self.assertEqual(my_options.number(my_options.FP), 1) + + self.assertEqual(my_options.name(my_options.ZP), 'ZP') + self.assertEqual(my_options.name(1), 'FP') + self.assertEqual(my_options.name('LP'), 'LP') + + self.assertEqual(my_options[my_options.number('FP')], + ('FP', 'FirstPoint', 'First Point')) + + self.assertListEqual(my_options.options, + [('ZP', 'ZeroPoint', 'Zero Point'), + ('FP', 'FirstPoint', 'First Point'), + ('LP', 'LastPoint', 'Last Point')]) + + class FileContext_NameSchema_Interface_Tests(unittest.TestCase): """ Test the interfaces presented by NameSchema. @@ -279,6 +324,46 @@ class FileContext_Parser_UnitTests(unittest.TestCase): 'rank': 'sequence'} ) + def test_parsing_filenames_w_fallback_parser(self): + abx_fallback_parser = file_context.NameParsers['abx_fallback']() + + data = abx_fallback_parser('S1E01-SF-4-SoyuzDMInt-cam.blend', None) + self.assertDictEqual(data[1], + {'filetype': 'blend', + 'role': 'cam', + 'comment': None, + 'title': 'S1E01-SF-4-SoyuzDMInt', + 'code': 'S1e01Sf4Soyuzdmint' + }) + + data = abx_fallback_parser('S1E01-SF-4-SoyuzDMInt-cam~~2021-01.blend', None) + self.assertDictEqual(data[1], + {'filetype': 'blend', + 'role': 'cam', + 'comment': '2021-01', + 'title': 'S1E01-SF-4-SoyuzDMInt', + 'code': 'S1e01Sf4Soyuzdmint' + }) + + + data = abx_fallback_parser('S1E02-MM-MediaMontage-compos.blend', None) + self.assertDictEqual(data[1], + {'filetype':'blend', + 'role':'compos', + 'comment': None, + 'title': 'S1E02-MM-MediaMontage', + 'code': 'S1e02MmMediamontage' + }) + + data = abx_fallback_parser('S1E01-PC-PressConference', None) + self.assertDictEqual(data[1], + {'filetype': None, + 'role': None, + 'comment': None, + 'title': 'S1E01-PC-PressConference', + 'code': 'S1e01PcPressconference' + }) + class FileContext_Implementation_UnitTests(unittest.TestCase): TESTDATA = os.path.abspath( @@ -290,6 +375,11 @@ class FileContext_Implementation_UnitTests(unittest.TestCase): def test_filecontext_finds_and_loads_file(self): fc = file_context.FileContext(self.TESTPATH) + +# print('\ntest_filecontext_finds_and_loads_file') +# print(fc.get_log_text('INFO')) +# print(dir(self)) + 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, @@ -311,6 +401,15 @@ class FileContext_Implementation_UnitTests(unittest.TestCase): self.assertEqual(fc.role, 'anim') self.assertEqual(fc.title, 'BeginningOfEnd') self.assertEqual(fc.comment, None) + + def test_filecontext_abx_fields_include_default(self): + fc0 = file_context.FileContext() + fc1 = file_context.FileContext('') + fc2 = file_context.FileContext(self.TESTPATH) + + for fc in (fc0, fc1, fc2): + self.assertIn('render_profiles', fc.abx_fields) + class FileContext_API_UnitTests(unittest.TestCase): @@ -416,21 +515,36 @@ class FileContext_FailOver_Tests(unittest.TestCase): 'yaminimal', 'Episodes', 'Ae1-Void', 'Seq', 'VN-VagueName', 'Ae1-VN-1-VoidOfData-anim.txt') + def test_filecontext_finds_default_yaml(self): + self.assertIn('abx_default', file_context.DEFAULT_YAML) + def test_filecontext_no_project_path(self): fc = file_context.FileContext() + self.assertFalse(fc.file_exists) + self.assertFalse(fc.folder_exists) + self.assertIn('abx_default', fc.provided_data) # 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) + self.assertFalse(fc.file_exists) + self.assertTrue(fc.folder_exists) + self.assertIn('abx_default', fc.provided_data) def test_filecontext_failover_nonexisting_file(self): fc = file_context.FileContext(self.TEST_NONEXISTENT_PATH) + self.assertFalse(fc.file_exists) + self.assertFalse(fc.folder_exists) + self.assertIn('abx_default', fc.provided_data) def test_filecontext_failover_no_yaml(self): fc = file_context.FileContext(self.TEST_NO_YAML) + self.assertIn('abx_default', fc.provided_data) + # It finds the backstop root YAML in the testdata: + self.assertEqual(fc.root, self.TESTDATA) def test_filecontext_failover_minimal_yaml(self): fc = file_context.FileContext(self.TEST_MINIMAL_YAML) - + self.assertIn('abx_default', fc.provided_data) diff --git a/tests/test_render_profile.py b/tests/test_render_profile.py new file mode 100644 index 0000000..ee9c794 --- /dev/null +++ b/tests/test_render_profile.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Test the render_profile module. + +This has to be run from within Blender. +See: + TestInBlender.py (injector script - call this to run the tests) + TestInBlender_bpy.py (injected test-runner script) +""" + + +import unittest, os, textwrap +import yaml + +import sys +print("__file__ = ", __file__) +sys.path.append(os.path.normpath(os.path.join(__file__, '..', '..'))) + +TESTDATA = os.path.join(os.path.abspath(__file__), '..', 'testdata') + +TESTPATH = os.path.join(TESTDATA, 'myproject', 'Episodes', 'A.001-Pilot', + 'Seq', 'LP-LastPoint', 'A.001-LP-1-BeginningOfEnd-anim.txt') + +import bpy + +import abx +from abx import file_context +from abx import render_profile + +class TestRenderProfile_Utils(unittest.TestCase): + def test_bpy_is_present(self): + self.assertTrue(abx.blender_present) + +class TestRenderProfile_Implementation(unittest.TestCase): + + TESTDATA = os.path.abspath(os.path.join(__file__, '..', 'testdata')) + + TESTPATH = os.path.join(TESTDATA, 'myproject', 'Episodes', 'A.001-Pilot', + 'Seq', 'LP-LastPoint', 'A.001-LP-1-BeginningOfEnd-anim.blend') + + + def setUp(self): + self.fc0 = file_context.FileContext(bpy.data.filepath) + self.fc1 = file_context.FileContext(self.TESTPATH) + self.scene = bpy.context.scene + + def test_blendfile_context(self): + self.assertEqual(self.fc0.filename, None) + self.assertEqual(self.fc1.filename, + 'A.001-LP-1-BeginningOfEnd-anim.blend') + + def test_abx_data_retrieved_defaults(self): + self.assertIn('render_profiles', self.fc0.abx_fields) + + def test_abx_data_retrieved_file(self): + self.assertIn('render_profiles', self.fc1.abx_fields) + + def test_abx_data_default_full_profile_correct(self): + FullProfile = render_profile.RenderProfile( + self.fc0.abx_fields['render_profiles']['full']) + FullProfile.apply(self.scene) + + self.assertEqual(self.scene.render.fps, 30) + self.assertEqual(self.scene.render.fps_base, 1.0) + self.assertTrue(self.scene.render.use_motion_blur) + self.assertTrue(self.scene.render.use_antialiasing) + self.assertEqual(self.scene.render.antialiasing_samples, '8') + self.assertEqual(self.scene.render.resolution_percentage, 100) + self.assertEqual(self.scene.render.image_settings.compression, 50) + + + + + \ No newline at end of file