diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index c8bcd3c..3180b13 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -1,3 +1,5 @@ +from bpy.app.handlers import persistent + bl_info = { "name": "PSK/PSA Importer/Exporter", "author": "Colin Basnett, Yurii Ti", @@ -124,3 +126,17 @@ def unregister(): if __name__ == '__main__': register() + + +@persistent +def load_handler(dummy): + print('RUNNING LOAD HANDLER') + # Convert old `psa_sequence_fps` property to new `psa_export.fps` property. + # This is only needed for backwards compatibility with older versions of the addon. + for action in bpy.data.actions: + if 'psa_sequence_fps' in action: + action.psa_export.fps = action['psa_sequence_fps'] + del action['psa_sequence_fps'] + + +bpy.app.handlers.load_post.append(load_handler) diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index d692f36..115451f 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -97,16 +97,7 @@ def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actio return fps_custom elif fps_source == 'ACTION_METADATA': # Get the minimum value of action metadata FPS values. - fps_list = [] - for action in filter(lambda x: 'psa_sequence_fps' in x, actions): - fps = action['psa_sequence_fps'] - if type(fps) == int or type(fps) == float: - fps_list.append(fps) - if len(fps_list) > 0: - return min(fps_list) - else: - # No valid action metadata to use, fallback to scene FPS - return context.scene.render.fps + return min([action.psa_export.fps for action in actions]) else: raise RuntimeError(f'Invalid FPS source "{fps_source}"') diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index 91e6480..8adc137 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -124,9 +124,7 @@ class PSA_PG_export(PropertyGroup): description='', items=( ('SCENE', 'Scene', '', 'SCENE_DATA', 0), - ('ACTION_METADATA', 'Action Metadata', - 'The frame rate will be determined by action\'s "psa_sequence_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), + ('ACTION_METADATA', 'Action Metadata', 'The frame rate will be determined by action\'s FPS property found in the PSA Export panel.\n\nIf the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used', 'PROPERTIES', 1), ('CUSTOM', 'Custom', '', 2) ) ) diff --git a/io_scene_psk_psa/psa/import_/operators.py b/io_scene_psk_psa/psa/import_/operators.py index 880a81e..08af962 100644 --- a/io_scene_psk_psa/psa/import_/operators.py +++ b/io_scene_psk_psa/psa/import_/operators.py @@ -166,6 +166,8 @@ class PSA_OT_import(Operator, ImportHelper): options.should_write_keyframes = pg.should_write_keyframes options.should_convert_to_samples = pg.should_convert_to_samples options.bone_mapping_mode = pg.bone_mapping_mode + options.fps_source = pg.fps_source + options.fps_custom = pg.fps_custom if len(sequence_names) == 0: self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected') @@ -239,6 +241,10 @@ class PSA_OT_import(Operator, ImportHelper): col.use_property_decorate = False col.prop(pg, 'should_convert_to_samples') col.separator() + # FPS + col.prop(pg, 'fps_source') + if pg.fps_source == 'CUSTOM': + col.prop(pg, 'fps_custom') col = layout.column(heading='Options') col.use_property_split = True diff --git a/io_scene_psk_psa/psa/import_/properties.py b/io_scene_psk_psa/psa/import_/properties.py index b90ade6..f8bc446 100644 --- a/io_scene_psk_psa/psa/import_/properties.py +++ b/io_scene_psk_psa/psa/import_/properties.py @@ -2,7 +2,8 @@ import re from fnmatch import fnmatch from typing import List -from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty +from bpy.props import StringProperty, BoolProperty, CollectionProperty, IntProperty, PointerProperty, EnumProperty, \ + FloatProperty from bpy.types import PropertyGroup, Text empty_set = set() @@ -66,6 +67,21 @@ class PSA_PG_import(PropertyGroup): '\'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1), ) ) + fps_source: EnumProperty(name='FPS Source', items=( + ('SEQUENCE', 'Sequence', 'The sequence frame rate matches the original frame rate', 'ACTION', 0), + ('SCENE', 'Scene', 'The sequence frame rate dilates to match that of the scene', 'SCENE_DATA', 1), + ('CUSTOM', 'Custom', 'The sequence frame rate dilates to match a custom frame rate', 2), + )) + fps_custom: FloatProperty( + default=30.0, + name='Custom FPS', + description='The frame rate to which the imported actions will be converted', + options=empty_set, + min=1.0, + soft_min=1.0, + soft_max=60.0, + step=100, + ) def filter_sequences(pg: PSA_PG_import, sequences) -> List[int]: diff --git a/io_scene_psk_psa/psa/importer.py b/io_scene_psk_psa/psa/importer.py index 99cd301..31a902c 100644 --- a/io_scene_psk_psa/psa/importer.py +++ b/io_scene_psk_psa/psa/importer.py @@ -21,6 +21,8 @@ class PsaImportOptions(object): self.action_name_prefix = '' self.should_convert_to_samples = False self.bone_mapping_mode = 'CASE_INSENSITIVE' + self.fps_source = 'SEQUENCE' + self.fps_custom: float = 30.0 class ImportBone(object): @@ -172,6 +174,19 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, else: action = bpy.data.actions.new(name=action_name) + # Calculate the target FPS. + target_fps = sequence.fps + if options.fps_source == 'CUSTOM': + target_fps = options.fps_custom + elif options.fps_source == 'SCENE': + target_fps = context.scene.render.fps + elif options.fps_source == 'SEQUENCE': + target_fps = sequence.fps + else: + raise ValueError(f'Unknown FPS source: {options.fps_source}') + + keyframe_time_dilation = target_fps / sequence.fps + if options.should_write_keyframes: # Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2) while len(action.fcurves) > 0: @@ -208,7 +223,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, # Write the keyframes out. fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) - fcurve_data[0::2] = range(sequence.frame_count) + fcurve_data[0::2] = [x * keyframe_time_dilation for x in range(sequence.frame_count)] for bone_index, import_bone in enumerate(import_bones): if import_bone is None: continue @@ -216,6 +231,8 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index] fcurve.keyframe_points.add(sequence.frame_count) fcurve.keyframe_points.foreach_set('co', fcurve_data) + for fcurve_keyframe in fcurve.keyframe_points: + fcurve_keyframe.interpolation = 'LINEAR' if options.should_convert_to_samples: # Bake the curve to samples. @@ -224,7 +241,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, # Write meta-data. if options.should_write_metadata: - action['psa_sequence_fps'] = sequence.fps + action.psa_export.fps = target_fps action.use_fake_user = options.should_use_fake_user diff --git a/io_scene_psk_psa/types.py b/io_scene_psk_psa/types.py index fa16a15..ae3e9a9 100644 --- a/io_scene_psk_psa/types.py +++ b/io_scene_psk_psa/types.py @@ -21,6 +21,7 @@ class PSX_PG_bone_collection_list_item(PropertyGroup): class PSX_PG_action_export(PropertyGroup): compression_ratio: FloatProperty(name='Compression Ratio', default=1.0, min=0.0, max=1.0, subtype='FACTOR', description='The key sampling ratio of the exported sequence.\n\nA compression ratio of 1.0 will export all frames, while a compression ratio of 0.5 will export half of the frames') key_quota: IntProperty(name='Key Quota', default=0, min=1, description='The minimum number of frames to be exported') + fps: FloatProperty(name='FPS', default=30.0, min=0.0, description='The frame rate of the exported sequence') class PSX_PT_action(Panel): @@ -38,8 +39,12 @@ class PSX_PT_action(Panel): def draw(self, context: 'Context'): action = context.active_action layout = self.layout - layout.prop(action.psa_export, 'compression_ratio') - layout.prop(action.psa_export, 'key_quota') + flow = layout.grid_flow(columns=1) + flow.use_property_split = True + flow.use_property_decorate = False + flow.prop(action.psa_export, 'compression_ratio') + flow.prop(action.psa_export, 'key_quota') + flow.prop(action.psa_export, 'fps') classes = (