From 37f14a2a19e6efcf906e495cd8c3819b787e3eaa Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 15 Apr 2022 16:50:58 -0700 Subject: [PATCH] * 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 --- io_scene_psk_psa/helpers.py | 4 +- io_scene_psk_psa/psa/builder.py | 35 ++++- io_scene_psk_psa/psa/exporter.py | 27 +++- io_scene_psk_psa/psa/importer.py | 214 ++++++++++++++++++++----------- io_scene_psk_psa/psa/reader.py | 2 +- 5 files changed, 192 insertions(+), 90 deletions(-) diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index be62dea..f5864b2 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -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 diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index c0a84de..db7dbcd 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -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: diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 9fd9736..77fcfc7 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -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 diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 6ed3ac4..f4a6158 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -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,75 +122,87 @@ class PsaImporter(object): # Add the action. sequence_name = sequence.name.decode('windows-1252') action_name = options.action_name_prefix + sequence_name - action = bpy.data.actions.new(name=action_name) - action.use_fake_user = options.should_use_fake_user - # 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(): - import_bone = import_bones[psa_bone_index] - pose_bone = import_bone.pose_bone - 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 - ] + 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) - # Read the sequence data matrix from the PSA. - sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name) - keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8) + 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]) - # Convert the sequence's data from world-space to local-space. - for bone_index, import_bone in enumerate(import_bones): - if import_bone is None: - continue - for frame_index in range(sequence.frame_count): - # This bone has writeable keyframes for this frame. - key_data = sequence_data_matrix[frame_index, bone_index] - # Calculate the local-space key data for the bone. - sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data) + # 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(): + import_bone = import_bones[psa_bone_index] + pose_bone = import_bone.pose_bone + 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, 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 + ] - # Clean the keyframe data. This is accomplished by writing zeroes to the write matrix when there is an - # insufficiently large change in the data from the last written frame. - if options.should_clean_keys: - threshold = 0.001 + # Read the sequence data matrix from the PSA. + sequence_data_matrix = psa_reader.read_sequence_data_matrix(sequence_name) + keyframe_write_matrix = np.ones(sequence_data_matrix.shape, dtype=np.int8) + + # Convert the sequence's data from world-space to local-space. for bone_index, import_bone in enumerate(import_bones): if import_bone is None: continue - for fcurve_index in range(len(import_bone.fcurves)): - # Get all the keyframe data for the bone's f-curve data from the sequence data matrix. - fcurve_frame_data = sequence_data_matrix[:, bone_index, fcurve_index] - last_written_datum = 0 - for frame_index, datum in enumerate(fcurve_frame_data): - # If the f-curve data is not different enough to the last written frame, un-mark this data for writing. - if frame_index > 0 and abs(datum - last_written_datum) < threshold: - keyframe_write_matrix[frame_index, bone_index, fcurve_index] = 0 - else: - last_written_datum = datum - - # Write the keyframes out! - for frame_index in range(sequence.frame_count): - for bone_index, import_bone in enumerate(import_bones): - if import_bone is None: - continue - bone_has_writeable_keyframes = any(keyframe_write_matrix[frame_index, bone_index]) - if bone_has_writeable_keyframes: + for frame_index in range(sequence.frame_count): # This bone has writeable keyframes for this frame. key_data = sequence_data_matrix[frame_index, bone_index] - for fcurve, should_write, datum in zip(import_bone.fcurves, keyframe_write_matrix[frame_index, bone_index], key_data): - if should_write: - fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'}) + # Calculate the local-space key data for the bone. + sequence_data_matrix[frame_index, bone_index] = calculate_fcurve_data(import_bone, key_data) - # Store the original sequence name for use when exporting this same action using the PSA exporter. - action['original_sequence_name'] = sequence_name + # Clean the keyframe data. This is accomplished by writing zeroes to the write matrix when there is an + # insufficiently large change in the data from the last written frame. + if options.should_clean_keys: + threshold = 0.001 + for bone_index, import_bone in enumerate(import_bones): + if import_bone is None: + continue + for fcurve_index in range(len(import_bone.fcurves)): + # Get all the keyframe data for the bone's f-curve data from the sequence data matrix. + fcurve_frame_data = sequence_data_matrix[:, bone_index, fcurve_index] + last_written_datum = 0 + for frame_index, datum in enumerate(fcurve_frame_data): + # If the f-curve data is not different enough to the last written frame, un-mark this data for writing. + if frame_index > 0 and abs(datum - last_written_datum) < threshold: + keyframe_write_matrix[frame_index, bone_index, fcurve_index] = 0 + else: + last_written_datum = datum + + # Write the keyframes out! + for frame_index in range(sequence.frame_count): + for bone_index, import_bone in enumerate(import_bones): + if import_bone is None: + continue + bone_has_writeable_keyframes = any(keyframe_write_matrix[frame_index, bone_index]) + if bone_has_writeable_keyframes: + # This bone has writeable keyframes for this frame. + key_data = sequence_data_matrix[frame_index, bone_index] + for fcurve, should_write, datum in zip(import_bone.fcurves, keyframe_write_matrix[frame_index, bone_index], key_data): + if should_write: + fcurve.keyframe_points.insert(frame_index, datum, options={'FAST'}) + + # 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) diff --git a/io_scene_psk_psa/psa/reader.py b/io_scene_psk_psa/psa/reader.py index b9246cd..5f4acd5 100644 --- a/io_scene_psk_psa/psa/reader.py +++ b/io_scene_psk_psa/psa/reader.py @@ -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