* Added the ability to import the sequence FPS as a custom property to the Action (psa_fps)

* Added additional options for exporting sequence FPS values (scene, action metadata (custom data), custom,)
* The user can now choose to reuse existing action data blocks when importing sequence data.
* The user can choose whether or not to import keyframe data and metadata
This commit is contained in:
Colin Basnett
2022-04-15 16:50:58 -07:00
parent 7af97d53bd
commit 37f14a2a19
5 changed files with 192 additions and 90 deletions

View File

@@ -76,8 +76,8 @@ def populate_bone_group_list(armature_object, bone_group_list):
def get_psa_sequence_name(action, should_use_original_sequence_name):
if should_use_original_sequence_name and 'original_sequence_name' in action:
return action['original_sequence_name']
if should_use_original_sequence_name and 'psa_sequence_name' in action:
return action['psa_sequence_name']
else:
return action.name

View File

@@ -1,10 +1,13 @@
from .data import *
from ..helpers import *
from typing import Dict
from typing import Dict, Iterable
from bpy.types import Action
class PsaBuilderOptions(object):
def __init__(self):
self.fps_source = 'SCENE'
self.fps_custom = 30.0
self.sequence_source = 'ACTIONS'
self.actions = []
self.marker_names = []
@@ -117,9 +120,30 @@ class PsaBuilder(object):
def __init__(self):
self.name = ''
self.nla_state = NlaState()
self.fps = 30.0
export_sequences = []
def get_sequence_fps(context, options: PsaBuilderOptions, actions: Iterable[Action]) -> float:
if options.fps_source == 'SCENE':
return context.scene.render.fps
if options.fps_source == 'CUSTOM':
return options.fps_custom
elif options.fps_source == 'ACTION_METADATA':
# Get the minimum value of action metadata FPS values.
psa_fps_list = []
for action in filter(lambda x: 'psa_fps' in x, actions):
psa_fps = action['psa_fps']
if type(psa_fps) == int or type(psa_fps) == float:
psa_fps_list.append(psa_fps)
if len(psa_fps_list) > 0:
return min(psa_fps_list)
else:
# No valid action metadata to use, fallback to scene FPS
return context.scene.render.fps
else:
raise RuntimeError(f'Invalid FPS source "{options.fps_source}"')
if options.sequence_source == 'ACTIONS':
for action in options.actions:
if len(action.fcurves) == 0:
@@ -130,6 +154,7 @@ class PsaBuilder(object):
frame_min, frame_max = [int(x) for x in action.frame_range]
export_sequence.nla_state.frame_min = frame_min
export_sequence.nla_state.frame_max = frame_max
export_sequence.fps = get_sequence_fps(context, options, [action])
export_sequences.append(export_sequence)
pass
elif options.sequence_source == 'TIMELINE_MARKERS':
@@ -141,6 +166,8 @@ class PsaBuilder(object):
export_sequence.nla_state.action = None
export_sequence.nla_state.frame_min = frame_min
export_sequence.nla_state.frame_max = frame_max
nla_strips_actions = set(map(lambda x: x.action, get_nla_strips_in_timeframe(active_object, frame_min, frame_max)))
export_sequence.fps = get_sequence_fps(context, options, nla_strips_actions)
export_sequences.append(export_sequence)
else:
raise ValueError(f'Unhandled sequence source: {options.sequence_source}')
@@ -166,7 +193,7 @@ class PsaBuilder(object):
psa_sequence.name = bytes(export_sequence.name, encoding='windows-1252')
psa_sequence.frame_count = frame_count
psa_sequence.frame_start_index = frame_start_index
psa_sequence.fps = context.scene.render.fps
psa_sequence.fps = export_sequence.fps
frame_count = frame_max - frame_min + 1
@@ -213,10 +240,6 @@ class PsaBuilder(object):
psa.sequences[export_sequence.name] = psa_sequence
print(f'frame set duration: {performance.frame_set_duration}')
print(f'key build duration: {performance.key_build_duration}')
print(f'key add duration: {performance.key_add_duration}')
return psa
def get_timeline_marker_sequence_frame_ranges(self, object, context, options: PsaBuilderOptions) -> Dict:

View File

@@ -1,6 +1,6 @@
import bpy
from bpy.types import Operator, PropertyGroup, Action, UIList, BoneGroup, Panel, TimelineMarker
from bpy.props import CollectionProperty, IntProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty
from bpy.props import CollectionProperty, IntProperty, FloatProperty, PointerProperty, StringProperty, BoolProperty, EnumProperty
from bpy_extras.io_utils import ExportHelper
from typing import Type
from .builder import PsaBuilder, PsaBuilderOptions
@@ -9,6 +9,7 @@ from ..types import BoneGroupListItem
from ..helpers import *
from collections import Counter
import re
import sys
class PsaExporter(object):
@@ -65,10 +66,21 @@ class PsaExportPropertyGroup(PropertyGroup):
options=set(),
description='',
items=(
('ACTIONS', 'Actions', 'Sequences will be exported using actions'),
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers'),
('ACTIONS', 'Actions', 'Sequences will be exported using actions', 'ACTION', 0),
('TIMELINE_MARKERS', 'Timeline Markers', 'Sequences will be exported using timeline markers', 'MARKER_HLT', 1),
)
)
fps_source: EnumProperty(
name='FPS Source',
options=set(),
description='',
items=(
('SCENE', 'Scene', '', 'SCENE_DATA', 0),
('ACTION_METADATA', 'Action Metadata', 'The frame rate will be determined by action\'s "psa_fps" custom property, if it exists. If the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used. If no metadata is available, the scene\'s frame rate will be used.', 'PROPERTIES', 1),
('CUSTOM', 'Custom', '', 2)
)
)
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=set(), step=100, soft_max=60.0)
action_list: CollectionProperty(type=PsaExportActionListItem)
action_list_index: IntProperty(default=0)
marker_list: CollectionProperty(type=PsaExportTimelineMarkerListItem)
@@ -136,8 +148,13 @@ class PsaExportOperator(Operator, ExportHelper):
layout = self.layout
pg = context.scene.psa_export
# FPS
layout.prop(pg, 'fps_source', text='FPS')
if pg.fps_source == 'CUSTOM':
layout.prop(pg, 'fps_custom', text='Custom')
# SOURCE
layout.prop(pg, 'sequence_source', text='Source', icon='ACTION' if pg.sequence_source == 'ACTIONS' else 'MARKER')
layout.prop(pg, 'sequence_source', text='Source')
# SELECT ALL/NONE
row = layout.row(align=True)
@@ -258,6 +275,8 @@ class PsaExportOperator(Operator, ExportHelper):
marker_names = [x.name for x in pg.marker_list if x.is_selected]
options = PsaBuilderOptions()
options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom
options.sequence_source = pg.sequence_source
options.actions = actions
options.marker_names = marker_names

View File

@@ -17,6 +17,9 @@ class PsaImportOptions(object):
self.should_stash = False
self.sequence_names = []
self.should_use_action_name_prefix = False
self.should_overwrite = False
self.should_write_keyframes = True
self.should_write_metadata = True
self.action_name_prefix = ''
@@ -119,8 +122,16 @@ class PsaImporter(object):
# Add the action.
sequence_name = sequence.name.decode('windows-1252')
action_name = options.action_name_prefix + sequence_name
if options.should_overwrite and action_name in bpy.data.actions:
action = bpy.data.actions[action_name]
else:
action = bpy.data.actions.new(name=action_name)
action.use_fake_user = options.should_use_fake_user
if options.should_write_keyframes:
# Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2)
while len(action.fcurves) > 0:
action.fcurves.remove(action.fcurves[-1])
# Create f-curves for the rotation and location of each bone.
for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items():
@@ -129,13 +140,13 @@ class PsaImporter(object):
rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
location_data_path = pose_bone.path_from_id('location')
import_bone.fcurves = [
action.fcurves.new(rotation_data_path, index=0), # Qw
action.fcurves.new(rotation_data_path, index=1), # Qx
action.fcurves.new(rotation_data_path, index=2), # Qy
action.fcurves.new(rotation_data_path, index=3), # Qz
action.fcurves.new(location_data_path, index=0), # Lx
action.fcurves.new(location_data_path, index=1), # Ly
action.fcurves.new(location_data_path, index=2), # Lz
action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name), # Qw
action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name), # Qx
action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name), # Qy
action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name), # Qz
action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name), # Lx
action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name), # Ly
action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name), # Lz
]
# Read the sequence data matrix from the PSA.
@@ -183,11 +194,15 @@ class PsaImporter(object):
if should_write:
fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'})
# Store the original sequence name for use when exporting this same action using the PSA exporter.
action['original_sequence_name'] = sequence_name
# Write
if options.should_write_metadata:
action['psa_sequence_name'] = sequence_name
action['psa_fps'] = sequence.fps
actions.append(action)
action.use_fake_user = options.should_use_fake_user
# If the user specifies, store the new animations as strips on a non-contributing NLA track.
if options.should_stash:
if armature_object.animation_data is None:
@@ -246,11 +261,41 @@ class PsaImportPropertyGroup(PropertyGroup):
should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.', options=set())
should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track', options=set())
should_use_action_name_prefix: BoolProperty(default=False, name='Prefix Action Name', options=set())
should_overwrite: BoolProperty(default=False, name='Reuse Existing Datablocks', options=set())
should_write_keyframes: BoolProperty(default=True, name='Keyframes', options=set())
should_write_metadata: BoolProperty(default=True, name='Metadata', options=set(), description='Additional data will be written to the custom properties of the Action (e.g., frame rate)')
action_name_prefix: StringProperty(default='', name='Prefix', options=set())
sequence_filter_name: StringProperty(default='', options={'TEXTEDIT_UPDATE'})
sequence_use_filter_invert: BoolProperty(default=False, options=set())
def filter_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[int]:
bitflag_filter_item = 1 << 30
flt_flags = [bitflag_filter_item] * len(sequences)
if pg.sequence_filter_name is not None:
# Filter name is non-empty.
import fnmatch
for i, sequence in enumerate(sequences):
if not fnmatch.fnmatch(sequence.action_name, f'*{pg.sequence_filter_name}*'):
flt_flags[i] &= ~bitflag_filter_item
if pg.sequence_use_filter_invert:
# Invert filter flags for all items.
for i, sequence in enumerate(sequences):
flt_flags[i] ^= ~bitflag_filter_item
return flt_flags
def get_visible_sequences(pg: PsaImportPropertyGroup, sequences: bpy.types.bpy_prop_collection) -> List[PsaImportActionListItem]:
visible_sequences = []
for i, flag in enumerate(filter_sequences(pg, sequences)):
if bool(flag & (1 << 30)):
visible_sequences.append(sequences[i])
return visible_sequences
class PSA_UL_SequenceList(UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
@@ -265,7 +310,6 @@ class PSA_UL_SequenceList(UIList):
pg = context.scene.psa_import
row = layout.row()
subrow = row.row(align=True)
# TODO: current used for both, not good!
subrow.prop(pg, 'sequence_filter_name', text="")
subrow.prop(pg, 'sequence_use_filter_invert', text="", icon='ARROW_LEFTRIGHT')
@@ -274,13 +318,7 @@ class PSA_UL_SequenceList(UIList):
sequences = getattr(data, property)
flt_flags = []
if pg.sequence_filter_name:
flt_flags = bpy.types.UI_UL_list.filter_items_by_name(
pg.sequence_filter_name,
self.bitflag_filter_item,
sequences,
'action_name',
reverse=pg.sequence_use_filter_invert
)
flt_flags = filter_sequences(pg, sequences)
flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(sequences, 'action_name')
return flt_flags, flt_neworder
@@ -302,14 +340,15 @@ class PsaImportSequencesSelectAll(Operator):
@classmethod
def poll(cls, context):
pg = context.scene.psa_import
sequence_list = pg.sequence_list
has_unselected_actions = any(map(lambda action: not action.is_selected, sequence_list))
return len(sequence_list) > 0 and has_unselected_actions
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
has_unselected_actions = any(map(lambda action: not action.is_selected, visible_sequences))
return len(visible_sequences) > 0 and has_unselected_actions
def execute(self, context):
pg = context.scene.psa_import
for action in pg.sequence_list:
action.is_selected = True
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
for sequence in visible_sequences:
sequence.is_selected = True
return {'FINISHED'}
@@ -322,14 +361,15 @@ class PsaImportSequencesDeselectAll(Operator):
@classmethod
def poll(cls, context):
pg = context.scene.psa_import
sequence_list = pg.sequence_list
has_selected_sequences = any(map(lambda action: action.is_selected, sequence_list))
return len(sequence_list) > 0 and has_selected_sequences
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
has_selected_sequences = any(map(lambda sequence: sequence.is_selected, visible_sequences))
return len(visible_sequences) > 0 and has_selected_sequences
def execute(self, context):
pg = context.scene.psa_import
for action in pg.sequence_list:
action.is_selected = False
visible_sequences = get_visible_sequences(pg, pg.sequence_list)
for sequence in visible_sequences:
sequence.is_selected = False
return {'FINISHED'}
@@ -351,7 +391,6 @@ class PSA_PT_ImportPanel_Advanced(Panel):
col.separator()
col.prop(pg, 'should_use_fake_user')
col.prop(pg, 'should_stash')
col.separator()
col.prop(pg, 'should_use_action_name_prefix')
if pg.should_use_action_name_prefix:
@@ -416,8 +455,26 @@ class PSA_PT_ImportPanel(Panel):
col = col.row()
col.template_list('PSA_UL_ImportSequenceList', '', pg, 'sequence_list', pg, 'sequence_list_index', rows=rows)
row = box.row()
row.operator(PsaImportOperator.bl_idname, text=f'Import')
col = layout.column(heading='')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_overwrite')
col = layout.column(heading='Write')
col.use_property_split = True
col.use_property_decorate = False
col.prop(pg, 'should_write_keyframes')
col.prop(pg, 'should_write_metadata')
selected_sequence_count = sum(map(lambda x: x.is_selected, pg.sequence_list))
row = layout.row()
import_button_text = 'Import'
if selected_sequence_count > 0:
import_button_text = f'Import ({selected_sequence_count})'
row.operator(PsaImportOperator.bl_idname, text=import_button_text)
class PsaImportFileReload(Operator):
@@ -473,6 +530,9 @@ class PsaImportOperator(Operator):
options.should_use_fake_user = pg.should_use_fake_user
options.should_stash = pg.should_stash
options.action_name_prefix = pg.action_name_prefix
options.should_overwrite = pg.should_overwrite
options.should_write_metadata = pg.should_write_metadata
options.should_write_keyframes = pg.should_write_keyframes
PsaImporter().import_psa(psa_reader, context.view_layer.objects.active, options)

View File

@@ -19,7 +19,7 @@ class PsaReader(object):
return self.psa.bones
@property
def sequences(self):
def sequences(self) -> OrderedDict[Psa.Sequence]:
return self.psa.sequences
@staticmethod