From c9d49588005e429517e35a3804ef8e0cbbd50bb3 Mon Sep 17 00:00:00 2001 From: Colin Basnett Date: Fri, 17 Feb 2023 22:18:46 -0800 Subject: [PATCH] Sequences can now be exported from action pose markers --- io_scene_psk_psa/psa/builder.py | 168 +++++------------------------ io_scene_psk_psa/psa/exporter.py | 175 ++++++++++++++++++++++++++++--- 2 files changed, 185 insertions(+), 158 deletions(-) diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index fdd09fd..d23e55c 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -1,115 +1,40 @@ -from typing import Dict +from typing import Optional -from bpy.types import Action, Armature, Bone +from bpy.types import Armature, Bone, Action from .data import * from ..helpers import * -class PsaBuildOptions(object): +class PsaExportSequence: + class NlaState: + def __init__(self): + self.action: Optional[Action] = None + self.frame_start: int = 0 + self.frame_end: int = 0 + def __init__(self): - self.should_override_animation_data = False - self.animation_data_override = None - self.fps_source = 'SCENE' - self.fps_custom = 30.0 - self.sequence_source = 'ACTIONS' - self.actions = [] - self.marker_names = [] + self.name: str = '' + self.nla_state: PsaExportSequence.NlaState = PsaExportSequence.NlaState() + self.fps: float = 30.0 + + +class PsaBuildOptions: + def __init__(self): + self.animation_data: AnimData + self.sequences: List[PsaExportSequence] = [] self.bone_filter_mode = 'ALL' - self.bone_group_indices = [] + self.bone_group_indices: List[int] = [] self.should_use_original_sequence_names = False - self.should_trim_timeline_marker_sequences = True self.should_ignore_bone_name_restrictions = False self.sequence_name_prefix = '' self.sequence_name_suffix = '' self.root_motion = False -def get_sequence_fps(context, options: PsaBuildOptions, 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. - 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 - else: - raise RuntimeError(f'Invalid FPS source "{options.fps_source}"') - - -def get_timeline_marker_sequence_frame_ranges(animation_data, context, options: PsaBuildOptions) -> Dict: - # Timeline markers need to be sorted so that we can determine the sequence start and end positions. - sequence_frame_ranges = dict() - sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame)) - sorted_timeline_marker_names = list(map(lambda x: x.name, sorted_timeline_markers)) - - for marker_name in options.marker_names: - marker = context.scene.timeline_markers[marker_name] - frame_min = marker.frame - # Determine the final frame of the sequence based on the next marker. - # If no subsequent marker exists, use the maximum frame_end from all NLA strips. - marker_index = sorted_timeline_marker_names.index(marker_name) - next_marker_index = marker_index + 1 - frame_max = 0 - if next_marker_index < len(sorted_timeline_markers): - # There is a next marker. Use that next marker's frame position as the last frame of this sequence. - frame_max = sorted_timeline_markers[next_marker_index].frame - if options.should_trim_timeline_marker_sequences: - nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_max) - if len(nla_strips) > 0: - frame_max = min(frame_max, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips))) - frame_min = max(frame_min, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips))) - else: - # No strips in between this marker and the next, just export this as a one-frame animation. - frame_max = frame_min - else: - # There is no next marker. - # Find the final frame of all the NLA strips and use that as the last frame of this sequence. - for nla_track in animation_data.nla_tracks: - if nla_track.mute: - continue - for strip in nla_track.strips: - frame_max = max(frame_max, strip.frame_end) - - if frame_min > frame_max: - continue - - sequence_frame_ranges[marker_name] = int(frame_min), int(frame_max) - - return sequence_frame_ranges - - def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: active_object = context.view_layer.objects.active - if active_object.type != 'ARMATURE': - raise RuntimeError('Selected object must be an Armature') - - if options.should_override_animation_data: - animation_data_object = options.animation_data_override - else: - animation_data_object = active_object - - animation_data = animation_data_object.animation_data - - if animation_data is None: - raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'') - - # Ensure that we actually have items that we are going to be exporting. - if options.sequence_source == 'ACTIONS' and len(options.actions) == 0: - raise RuntimeError('No actions were selected for export') - elif options.sequence_source == 'TIMELINE_MARKERS' and len(options.marker_names) == 0: - raise RuntimeError('No timeline markers were selected for export') - psa = Psa() armature_object = active_object @@ -177,67 +102,22 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: psa.bones.append(psa_bone) - # Populate the export sequence list. - class NlaState: - def __init__(self): - self.frame_min = 0 - self.frame_max = 0 - self.action = None - - class ExportSequence: - def __init__(self): - self.name = '' - self.nla_state = NlaState() - self.fps = 30.0 - - export_sequences = [] - - if options.sequence_source == 'ACTIONS': - for action in options.actions: - if len(action.fcurves) == 0: - continue - export_sequence = ExportSequence() - export_sequence.nla_state.action = action - export_sequence.name = get_psa_sequence_name(action, options.should_use_original_sequence_names) - 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': - sequence_frame_ranges = get_timeline_marker_sequence_frame_ranges(animation_data, context, options) - - for name, (frame_min, frame_max) in sequence_frame_ranges.items(): - export_sequence = ExportSequence() - export_sequence.name = name - 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(animation_data, 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}') - # Add prefixes and suffices to the names of the export sequences and strip whitespace. - for export_sequence in export_sequences: + for export_sequence in options.sequences: export_sequence.name = f'{options.sequence_name_prefix}{export_sequence.name}{options.sequence_name_suffix}' export_sequence.name = export_sequence.name.strip() # Save the current action and frame so that we can restore the state once we are done. saved_frame_current = context.scene.frame_current - saved_action = animation_data.action + saved_action = options.animation_data.action # Now build the PSA sequences. # We actually alter the timeline frame and simply record the resultant pose bone matrices. frame_start_index = 0 - for export_sequence in export_sequences: + for export_sequence in options.sequences: # Link the action to the animation data and update view layer. - animation_data.action = export_sequence.nla_state.action + options.animation_data.action = export_sequence.nla_state.action context.view_layer.update() frame_min = export_sequence.nla_state.frame_min @@ -292,7 +172,7 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa: psa.sequences[export_sequence.name] = psa_sequence # Restore the previous action & frame. - animation_data.action = saved_action + options.animation_data.action = saved_action context.scene.frame_set(saved_frame_current) return psa diff --git a/io_scene_psk_psa/psa/exporter.py b/io_scene_psk_psa/psa/exporter.py index 23292a9..cc515dc 100644 --- a/io_scene_psk_psa/psa/exporter.py +++ b/io_scene_psk_psa/psa/exporter.py @@ -1,14 +1,14 @@ import fnmatch import sys -from typing import Type +from typing import Type, Dict import bpy from bpy.props import BoolProperty, CollectionProperty, EnumProperty, FloatProperty, IntProperty, PointerProperty, \ StringProperty -from bpy.types import Action, Operator, PropertyGroup, UIList +from bpy.types import Action, Operator, PropertyGroup, UIList, Context from bpy_extras.io_utils import ExportHelper -from .builder import PsaBuildOptions, build_psa +from .builder import PsaBuildOptions, PsaExportSequence, build_psa from .data import * from ..helpers import * from ..types import BoneGroupListItem @@ -38,6 +38,9 @@ class PsaExportActionListItem(PropertyGroup): action: PointerProperty(type=Action) name: StringProperty() is_selected: BoolProperty(default=False) + frame_start: IntProperty(options={'HIDDEN'}) + frame_end: IntProperty(options={'HIDDEN'}) + is_pose_marker: BoolProperty(options={'HIDDEN'}) class PsaExportTimelineMarkerListItem(PropertyGroup): @@ -159,6 +162,10 @@ class PsaExportPropertyGroup(PropertyGroup): name='Show assets', options=empty_set, description='Show actions that belong to an asset library') + sequence_filter_pose_marker: BoolProperty( + default=False, + name='Show pose markers', + options=empty_set) sequence_use_filter_sort_reverse: BoolProperty(default=True, options=empty_set) @@ -170,6 +177,69 @@ def is_bone_filter_mode_item_available(context, identifier): return True +def get_timeline_marker_sequence_frame_ranges(animation_data: AnimData, context: Context, marker_names: List[str], should_trim_timeline_marker_sequences: bool) -> Dict: + # Timeline markers need to be sorted so that we can determine the sequence start and end positions. + sequence_frame_ranges = dict() + sorted_timeline_markers = list(sorted(context.scene.timeline_markers, key=lambda x: x.frame)) + sorted_timeline_marker_names = list(map(lambda x: x.name, sorted_timeline_markers)) + + for marker_name in marker_names: + marker = context.scene.timeline_markers[marker_name] + frame_min = marker.frame + # Determine the final frame of the sequence based on the next marker. + # If no subsequent marker exists, use the maximum frame_end from all NLA strips. + marker_index = sorted_timeline_marker_names.index(marker_name) + next_marker_index = marker_index + 1 + frame_max = 0 + if next_marker_index < len(sorted_timeline_markers): + # There is a next marker. Use that next marker's frame position as the last frame of this sequence. + frame_max = sorted_timeline_markers[next_marker_index].frame + if should_trim_timeline_marker_sequences: + nla_strips = get_nla_strips_in_timeframe(animation_data, marker.frame, frame_max) + if len(nla_strips) > 0: + frame_max = min(frame_max, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips))) + frame_min = max(frame_min, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips))) + else: + # No strips in between this marker and the next, just export this as a one-frame animation. + frame_max = frame_min + else: + # There is no next marker. + # Find the final frame of all the NLA strips and use that as the last frame of this sequence. + for nla_track in animation_data.nla_tracks: + if nla_track.mute: + continue + for strip in nla_track.strips: + frame_max = max(frame_max, strip.frame_end) + + if frame_min > frame_max: + continue + + sequence_frame_ranges[marker_name] = int(frame_min), int(frame_max) + + return sequence_frame_ranges + + +def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float: + if fps_source == 'SCENE': + return context.scene.render.fps + if fps_source == 'CUSTOM': + 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 + else: + raise RuntimeError(f'Invalid FPS source "{fps_source}"') + + class PsaExportOperator(Operator, ExportHelper): bl_idname = 'psa_export.operator' bl_label = 'Export' @@ -313,7 +383,24 @@ class PsaExportOperator(Operator, ExportHelper): item = pg.action_list.add() item.action = action item.name = action.name + item.frame_start = int(action.frame_range[0]) + item.frame_end = int(action.frame_range[1]) item.is_selected = False + item.is_pose_marker = False + # Pose markers are not guaranteed to be in frame-order, so make sure that they are. + pose_markers = sorted(action.pose_markers, key=lambda x: x.frame) + print([x.name for x in pose_markers]) + for pose_marker_index, pose_marker in enumerate(pose_markers): + item = pg.action_list.add() + item.action = action + item.name = pose_marker.name + item.is_selected = False + item.is_pose_marker = True + item.frame_start = pose_marker.frame + if pose_marker_index + 1 < len(pose_markers): + item.frame_end = pose_markers[pose_marker_index + 1].frame + else: + item.frame_end = int(action.frame_range[1]) update_action_names(context) @@ -339,21 +426,69 @@ class PsaExportOperator(Operator, ExportHelper): def execute(self, context): pg = getattr(context.scene, 'psa_export') - actions = [x.action for x in pg.action_list if x.is_selected] - marker_names = [x.name for x in pg.marker_list if x.is_selected] + # TODO: move this up the call chain + # Populate the export sequence list. + active_object = context.view_layer.objects.active + + # Ensure that we actually have items that we are going to be exporting. + if pg.sequence_source == 'ACTIONS' and len(pg.action_list) == 0: + raise RuntimeError('No actions were selected for export') + elif pg.sequence_source == 'TIMELINE_MARKERS' and len(pg.marker_names) == 0: + raise RuntimeError('No timeline markers were selected for export') + + if active_object.type != 'ARMATURE': + raise RuntimeError('Selected object must be an Armature') + + if pg.should_override_animation_data: + animation_data_object = pg.animation_data_override + else: + animation_data_object = active_object + + animation_data = animation_data_object.animation_data + + if animation_data is None: + raise RuntimeError(f'No animation data for object \'{animation_data_object.name}\'') + + export_sequences: List[PsaExportSequence] = [] + + # actions = [x.action for x in pg.action_list if x.is_selected] + # marker_names = + + if pg.sequence_source == 'ACTIONS': + for action in filter(lambda x: x.is_selected, pg.action_list): + if len(action.action.fcurves) == 0: + continue + export_sequence = PsaExportSequence() + export_sequence.nla_state.action = action.action + export_sequence.name = action.name + export_sequence.nla_state.frame_min = action.frame_start + export_sequence.nla_state.frame_max = action.frame_end + export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action.action]) + export_sequences.append(export_sequence) + elif pg.sequence_source == 'TIMELINE_MARKERS': + marker_names = [x.name for x in pg.marker_list if x.is_selected] + sequence_frame_ranges = get_timeline_marker_sequence_frame_ranges(animation_data, context, marker_names, pg.should_trim_timeline_marker_sequences) + + for name, (frame_min, frame_max) in sequence_frame_ranges.items(): + export_sequence = PsaExportSequence() + export_sequence.name = name + 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(animation_data, frame_min, frame_max))) + export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions) + export_sequences.append(export_sequence) + else: + raise ValueError(f'Unhandled sequence source: {pg.sequence_source}') options = PsaBuildOptions() - options.should_override_animation_data = pg.should_override_animation_data - options.animation_data_override = pg.animation_data_override - 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 + options.animation_data = animation_data + options.sequences = export_sequences options.bone_filter_mode = pg.bone_filter_mode options.bone_group_indices = [x.index for x in pg.bone_group_list if x.is_selected] options.should_use_original_sequence_names = pg.should_use_original_sequence_names - options.should_trim_timeline_marker_sequences = pg.should_trim_timeline_marker_sequences options.should_ignore_bone_name_restrictions = pg.should_ignore_bone_name_restrictions options.sequence_name_prefix = pg.sequence_name_prefix options.sequence_name_suffix = pg.sequence_name_suffix @@ -391,6 +526,11 @@ def filter_sequences(pg: PsaExportPropertyGroup, sequences) -> List[int]: if hasattr(sequence, 'action') and sequence.action.asset_data is not None: flt_flags[i] &= ~bitflag_filter_item + if not pg.sequence_filter_pose_marker: + for i, sequence in enumerate(sequences): + if hasattr(sequence, 'is_pose_marker') and sequence.is_pose_marker: + flt_flags[i] &= ~bitflag_filter_item + return flt_flags @@ -410,9 +550,14 @@ class PSA_UL_ExportSequenceList(UIList): self.use_filter_show = True def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): + is_pose_marker = hasattr(item, 'is_pose_marker') and item.is_pose_marker layout.prop(item, 'is_selected', icon_only=True, text=item.name) if hasattr(item, 'action') and item.action.asset_data is not None: layout.label(text='', icon='ASSET_MANAGER') + if is_pose_marker: + row = layout.row(align=True) + row.alignment = 'RIGHT' + row.label(text=item.action.name, icon='PMARKER') def draw_filter(self, context, layout): pg = getattr(context.scene, 'psa_export') @@ -425,12 +570,14 @@ class PSA_UL_ExportSequenceList(UIList): if pg.sequence_source == 'ACTIONS': subrow = row.row(align=True) subrow.prop(pg, 'sequence_filter_asset', icon_only=True, icon='ASSET_MANAGER') + subrow.prop(pg, 'sequence_filter_pose_marker', icon_only=True, icon='PMARKER') def filter_items(self, context, data, prop): pg = getattr(context.scene, 'psa_export') actions = getattr(data, prop) flt_flags = filter_sequences(pg, actions) - flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name') + # flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(actions, 'name') + flt_neworder = list(range(len(actions))) return flt_flags, flt_neworder