New features for handling FPS

* An action's export FPS is now stored in a property group instead of a loose custom property value.
* New options for handling FPS when importing sequences.
This commit is contained in:
Colin Basnett
2023-08-16 02:38:07 -07:00
parent c4c00ca49e
commit 5bbb1512e0
7 changed files with 67 additions and 18 deletions

View File

@@ -1,3 +1,5 @@
from bpy.app.handlers import persistent
bl_info = { bl_info = {
"name": "PSK/PSA Importer/Exporter", "name": "PSK/PSA Importer/Exporter",
"author": "Colin Basnett, Yurii Ti", "author": "Colin Basnett, Yurii Ti",
@@ -124,3 +126,17 @@ def unregister():
if __name__ == '__main__': if __name__ == '__main__':
register() 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)

View File

@@ -95,16 +95,7 @@ def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actio
return fps_custom return fps_custom
elif fps_source == 'ACTION_METADATA': elif fps_source == 'ACTION_METADATA':
# Get the minimum value of action metadata FPS values. # Get the minimum value of action metadata FPS values.
fps_list = [] return min([action.psa_export.fps for action in actions])
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
else: else:
raise RuntimeError(f'Invalid FPS source "{fps_source}"') raise RuntimeError(f'Invalid FPS source "{fps_source}"')

View File

@@ -124,9 +124,7 @@ class PSA_PG_export(PropertyGroup):
description='', description='',
items=( items=(
('SCENE', 'Scene', '', 'SCENE_DATA', 0), ('SCENE', 'Scene', '', 'SCENE_DATA', 0),
('ACTION_METADATA', 'Action Metadata', ('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),
'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),
('CUSTOM', 'Custom', '', 2) ('CUSTOM', 'Custom', '', 2)
) )
) )

View File

@@ -166,6 +166,8 @@ class PSA_OT_import(Operator, ImportHelper):
options.should_write_keyframes = pg.should_write_keyframes options.should_write_keyframes = pg.should_write_keyframes
options.should_convert_to_samples = pg.should_convert_to_samples options.should_convert_to_samples = pg.should_convert_to_samples
options.bone_mapping_mode = pg.bone_mapping_mode options.bone_mapping_mode = pg.bone_mapping_mode
options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom
result = import_psa(context, psa_reader, context.view_layer.objects.active, options) result = import_psa(context, psa_reader, context.view_layer.objects.active, options)
@@ -234,6 +236,10 @@ class PSA_OT_import(Operator, ImportHelper):
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'should_convert_to_samples') col.prop(pg, 'should_convert_to_samples')
col.separator() col.separator()
# FPS
col.prop(pg, 'fps_source')
if pg.fps_source == 'CUSTOM':
col.prop(pg, 'fps_custom')
col = layout.column(heading='Options') col = layout.column(heading='Options')
col.use_property_split = True col.use_property_split = True

View File

@@ -2,7 +2,8 @@ import re
from fnmatch import fnmatch from fnmatch import fnmatch
from typing import List 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 from bpy.types import PropertyGroup, Text
empty_set = set() empty_set = set()
@@ -66,6 +67,21 @@ class PSA_PG_import(PropertyGroup):
'\'root\' can be mapped to the armature bone \'Root\')', 'CASE_INSENSITIVE', 1), '\'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]: def filter_sequences(pg: PSA_PG_import, sequences) -> List[int]:

View File

@@ -21,6 +21,8 @@ class PsaImportOptions(object):
self.action_name_prefix = '' self.action_name_prefix = ''
self.should_convert_to_samples = False self.should_convert_to_samples = False
self.bone_mapping_mode = 'CASE_INSENSITIVE' self.bone_mapping_mode = 'CASE_INSENSITIVE'
self.fps_source = 'SEQUENCE'
self.fps_custom: float = 30.0
class ImportBone(object): class ImportBone(object):
@@ -172,6 +174,19 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
else: else:
action = bpy.data.actions.new(name=action_name) 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: if options.should_write_keyframes:
# Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2) # Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2)
while len(action.fcurves) > 0: while len(action.fcurves) > 0:
@@ -208,7 +223,7 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
# Write the keyframes out. # Write the keyframes out.
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) 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): for bone_index, import_bone in enumerate(import_bones):
if import_bone is None: if import_bone is None:
continue 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_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index]
fcurve.keyframe_points.add(sequence.frame_count) fcurve.keyframe_points.add(sequence.frame_count)
fcurve.keyframe_points.foreach_set('co', fcurve_data) 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: if options.should_convert_to_samples:
# Bake the curve 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. # Write meta-data.
if options.should_write_metadata: 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 action.use_fake_user = options.should_use_fake_user

View File

@@ -21,6 +21,7 @@ class PSX_PG_bone_group_list_item(PropertyGroup):
class PSX_PG_action_export(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') 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') 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): class PSX_PT_action(Panel):
@@ -38,8 +39,12 @@ class PSX_PT_action(Panel):
def draw(self, context: 'Context'): def draw(self, context: 'Context'):
action = context.active_action action = context.active_action
layout = self.layout layout = self.layout
layout.prop(action.psa_export, 'compression_ratio') flow = layout.grid_flow(columns=1)
layout.prop(action.psa_export, 'key_quota') 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 = ( classes = (