diff options
author | Nathan Lovato <nathan@gdquest.com> | 2019-09-05 18:22:37 +0300 |
---|---|---|
committer | Nathan Lovato <nathan@gdquest.com> | 2019-09-05 18:22:52 +0300 |
commit | 61d48c0a4be0ab8f71e6e1d35f0aa99c77fcfd33 (patch) | |
tree | f64d0ea88789184f45112b489598990d549788f2 /power_sequencer | |
parent | da5a1175e30c347fbce05e49e2f5f895be30bd5b (diff) |
Add the VSE addon Power Sequencer
Diffstat (limited to 'power_sequencer')
116 files changed, 10767 insertions, 0 deletions
diff --git a/power_sequencer/__init__.py b/power_sequencer/__init__.py new file mode 100644 index 00000000..7bc1c6ca --- /dev/null +++ b/power_sequencer/__init__.py @@ -0,0 +1,84 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .addon_preferences import register_preferences, unregister_preferences +from .addon_properties import register_properties, unregister_properties +from .operators import classes +from .utils.register_shortcuts import register_shortcuts +from .handlers import register_handlers, unregister_handlers +from .utils import addon_auto_imports +from .ui import register_ui, unregister_ui + + +# load and reload submodules +################################## +modules = addon_auto_imports.setup_addon_modules( + __path__, __name__, ignore_packages=[".utils", ".audiosync"], ignore_modules=[] +) + + +bl_info = { + "name": "Power Sequencer", + "description": "Video editing tools for content creators", + "author": "Nathan Lovato", + "version": (1, 4, 0), + "blender": (2, 80, 0), + "location": "Sequencer", + "tracker_url": "https://github.com/GDquest/Blender-power-sequencer/issues", + "wiki_url": "https://www.youtube.com/playlist?list=PLhqJJNjsQ7KFjp88Cu57Zb9_wFt7nlkEI", + "support": "COMMUNITY", + "category": "Sequencer", +} + + +addon_keymaps = [] + + +def register(): + global addon_keymaps + + register_preferences() + register_properties() + register_handlers() + register_ui() + + for c in classes: + bpy.utils.register_class(c) + + keymaps = register_shortcuts() + addon_keymaps += keymaps + + print("Registered {} with {} modules".format(bl_info["name"], len(modules))) + + +def unregister(): + global addon_keymaps + + for km, kmi in addon_keymaps: + km.keymap_items.remove(kmi) + addon_keymaps.clear() + + for c in classes: + bpy.utils.unregister_class(c) + + unregister_ui() + unregister_preferences() + unregister_properties() + unregister_handlers() + + print("Unregistered {}".format(bl_info["name"])) diff --git a/power_sequencer/addon_preferences.py b/power_sequencer/addon_preferences.py new file mode 100644 index 00000000..0e8e7d71 --- /dev/null +++ b/power_sequencer/addon_preferences.py @@ -0,0 +1,48 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +""" +Add-on preferences and interface in the Blender preferences window. +""" +import bpy + + +def get_preferences(context): + return context.preferences.addons[__package__].preferences + +class PowerSequencerPreferences(bpy.types.AddonPreferences): + bl_idname = __package__ + + proxy_25: bpy.props.BoolProperty(name="25%", default=False) + proxy_50: bpy.props.BoolProperty(name="50%", default=False) + proxy_75: bpy.props.BoolProperty(name="75%", default=False) + proxy_100: bpy.props.BoolProperty(name="100%", default=False) + + def draw(self, context): + layout = self.layout + + layout.label(text="Proxy") + + row = layout.row() + row.prop(self, "proxy_25") + row.prop(self, "proxy_50") + row.prop(self, "proxy_75") + row.prop(self, "proxy_100") + + +register_preferences, unregister_preferences = bpy.utils.register_classes_factory( + [PowerSequencerPreferences] +) diff --git a/power_sequencer/addon_properties.py b/power_sequencer/addon_properties.py new file mode 100644 index 00000000..06cbe1da --- /dev/null +++ b/power_sequencer/addon_properties.py @@ -0,0 +1,46 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + + +class PowerSequencerProperties(bpy.types.PropertyGroup): + playback_speed: bpy.props.EnumProperty( + items=[ + ("NORMAL", "Normal (1x)", ""), + ("FAST", "Fast (1.33x)", ""), + ("FASTER", "Faster (1.66x)", ""), + ("DOUBLE", "Double (2x)", ""), + ("TRIPLE", "Triple (3x)", ""), + ], + name="Playback", + default="NORMAL", + ) + + frame_pre: bpy.props.IntProperty(name="Frame before frame_change", default=0, min=0) + + active_tab: bpy.props.StringProperty( + name="Active Tab", description="The name of the active tab in the UI", default="Sequencer" + ) + + +def register_properties(): + bpy.utils.register_class(PowerSequencerProperties) + bpy.types.Scene.power_sequencer = bpy.props.PointerProperty(type=PowerSequencerProperties) + + +def unregister_properties(): + bpy.utils.unregister_class(PowerSequencerProperties) diff --git a/power_sequencer/handlers.py b/power_sequencer/handlers.py new file mode 100644 index 00000000..8e93bb27 --- /dev/null +++ b/power_sequencer/handlers.py @@ -0,0 +1,89 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from bpy.app.handlers import persistent + + +@persistent +def power_sequencer_load_file_post(arg): + """ + Called after loading the blend file + """ + for scene in bpy.data.scenes: + scene.power_sequencer.frame_pre = bpy.context.scene.frame_current + + +@persistent +def power_sequencer_playback_speed_post(scene): + """ + Handler function for faster playback + Skips keyframes after a frame change based on the playback_speed value + It steps over frame rather than increase the playback speed smoothly, + but it's still useful for faster editing + """ + if bpy.context.screen and not bpy.context.screen.is_animation_playing: + return + + playback_speed = scene.power_sequencer.playback_speed + + frame_start = scene.frame_current + frame_post = scene.frame_current + + if playback_speed == "FAST" and frame_start % 3 == 0: + frame_post += 1 + elif playback_speed == "FASTER" and frame_start % 2 == 0: + frame_post += 1 + elif playback_speed == "DOUBLE": + # 2.5x -> skip 5 frames for 2. 2 then 3 then 2 etc. + frame_post += 1 + elif playback_speed == "TRIPLE": + frame_post += 2 + + if frame_start != frame_post: + bpy.ops.screen.frame_offset(delta=frame_post - frame_start) + scene.power_sequencer.frame_pre = scene.frame_current + + +def draw_playback_speed(self, context): + layout = self.layout + scene = context.scene + layout.prop(scene.power_sequencer, "playback_speed") + + +def draw_ui_menu(self, context): + layout = self.layout + layout.menu("POWER_SEQUENCER_MT_main") + + +def register_handlers(): + # Menus + bpy.types.SEQUENCER_HT_header.append(draw_ui_menu) + bpy.types.SEQUENCER_HT_header.append(draw_playback_speed) + + # Handlers + bpy.app.handlers.load_post.append(power_sequencer_load_file_post) + bpy.app.handlers.frame_change_post.append(power_sequencer_playback_speed_post) + + +def unregister_handlers(): + # Menus + bpy.types.SEQUENCER_HT_header.remove(draw_ui_menu) + bpy.types.SEQUENCER_HT_header.remove(draw_playback_speed) + + # Handlers + bpy.app.handlers.load_post.remove(power_sequencer_load_file_post) + bpy.app.handlers.frame_change_post.remove(power_sequencer_playback_speed_post) diff --git a/power_sequencer/operators/__init__.py b/power_sequencer/operators/__init__.py new file mode 100644 index 00000000..7c1e0ca0 --- /dev/null +++ b/power_sequencer/operators/__init__.py @@ -0,0 +1,165 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +from .speed_up_movie_strip import POWER_SEQUENCER_OT_speed_up_movie_strip +from .align_audios import POWER_SEQUENCER_OT_align_audios +from .playback_speed_set import POWER_SEQUENCER_OT_playback_speed_set +from .channel_offset import POWER_SEQUENCER_OT_channel_offset +from .concatenate_strips import POWER_SEQUENCER_OT_concatenate_strips +from .copy_selected_sequences import POWER_SEQUENCER_OT_copy_selected_sequences +from .crossfade_add import POWER_SEQUENCER_OT_crossfade_add +from .crossfade_edit import POWER_SEQUENCER_OT_crossfade_edit +from .transitions_remove import POWER_SEQUENCER_OT_transitions_remove +from .cut_strips_under_cursor import POWER_SEQUENCER_OT_split_strips_under_cursor +from .playback_speed_decrease import POWER_SEQUENCER_OT_playback_speed_decrease +from .delete_direct import POWER_SEQUENCER_OT_delete_direct +from .deselect_all_left_or_right import POWER_SEQUENCER_OT_deselect_all_strips_left_or_right +from .deselect_handles_and_grab import POWER_SEQUENCER_OT_deselect_handles_and_grab +from .duplicate_move import POWER_SEQUENCER_OT_duplicate_move +from .expand_to_surrounding_cuts import POWER_SEQUENCER_OT_expand_to_surrounding_cuts +from .fade_add import POWER_SEQUENCER_OT_fade_add +from .fade_clear import POWER_SEQUENCER_OT_fade_clear +from .grab_closest_handle_or_cut import POWER_SEQUENCER_OT_grab_closest_cut +from .grab import POWER_SEQUENCER_OT_grab +from .grab_sequence_handles import POWER_SEQUENCER_OT_grab_sequence_handles +from .import_local_footage import POWER_SEQUENCER_OT_import_local_footage +from .playback_speed_increase import POWER_SEQUENCER_OT_playback_speed_increase +from .jump_time_offset import POWER_SEQUENCER_OT_jump_time_offset +from .jump_to_cut import POWER_SEQUENCER_OT_jump_to_cut +from .make_still_image import POWER_SEQUENCER_OT_make_still_image +from .marker_delete_closest import POWER_SEQUENCER_OT_marker_delete_closest +from .marker_delete_direct import POWER_SEQUENCER_OT_marker_delete_direct +from .marker_go_to_next import POWER_SEQUENCER_OT_marker_go_to_next +from .markers_as_timecodes import POWER_SEQUENCER_OT_copy_markers_as_timecodes +from .markers_create_from_selected import POWER_SEQUENCER_OT_markers_create_from_selected_strips +from .marker_snap_to_cursor import POWER_SEQUENCER_OT_marker_snap_to_cursor +from .markers_snap_matching_strips import POWER_SEQUENCER_OT_markers_snap_matching_strips +from .meta_resize_to_content import POWER_SEQUENCER_OT_meta_resize_to_content +from .meta_ungroup_and_trim import POWER_SEQUENCER_OT_meta_ungroup_and_trim +from .meta_trim_content_to_bounds import POWER_SEQUENCER_OT_meta_trim_content_to_bounds +from .mouse_trim_modal import POWER_SEQUENCER_OT_mouse_trim +from .space_sequences import POWER_SEQUENCER_OT_space_sequences +from .mouse_toggle_mute import POWER_SEQUENCER_OT_mouse_toggle_mute +from .mouse_trim_instantly import POWER_SEQUENCER_OT_mouse_trim_instantly +from .open_project_directory import POWER_SEQUENCER_OT_open_project_directory +from .preview_closest_cut import POWER_SEQUENCER_OT_preview_closest_cut +from .preview_to_selection import POWER_SEQUENCER_OT_preview_to_selection +from .gap_remove import POWER_SEQUENCER_OT_gap_remove +from .scene_rename_with_strip import POWER_SEQUENCER_OT_scene_rename_with_strip +from .render_apply_preset import POWER_SEQUENCER_OT_render_apply_preset +from .ripple_delete import POWER_SEQUENCER_OT_ripple_delete +from .save_direct import POWER_SEQUENCER_OT_save_direct +from .scene_create_from_selection import POWER_SEQUENCER_OT_scene_create_from_selection +from .scene_cycle import POWER_SEQUENCER_OT_scene_cycle +from .select_closest_to_mouse import POWER_SEQUENCER_OT_select_closest_to_mouse +from .select_linked_strips import POWER_SEQUENCER_OT_select_linked_strips +from .select_linked_effect import POWER_SEQUENCER_OT_select_linked_effect +from .select_related_strips import POWER_SEQUENCER_OT_select_related_strips +from .select_strips_under_cursor import POWER_SEQUENCER_OT_select_strips_under_cursor +from .markers_set_preview_in_between import POWER_SEQUENCER_OT_set_preview_between_markers +from .set_timeline_range import POWER_SEQUENCER_OT_set_timeline_range +from .trim_left_or_right_handles import POWER_SEQUENCER_OT_trim_left_or_right_handles +from .snap import POWER_SEQUENCER_OT_snap +from .snap_selection import POWER_SEQUENCER_OT_snap_selection +from .speed_remove_effect import POWER_SEQUENCER_OT_speed_remove_effect +from .swap_strips import POWER_SEQUENCER_OT_swap_strips +from .select_all_left_or_right import POWER_SEQUENCER_OT_select_all_left_or_right +from .synchronize_titles import POWER_SEQUENCER_OT_synchronize_titles +from .toggle_selected_mute import POWER_SEQUENCER_OT_toggle_selected_mute +from .toggle_waveforms import POWER_SEQUENCER_OT_toggle_waveforms +from .trim_three_point_edit import POWER_SEQUENCER_OT_trim_three_point_edit +from .trim_to_surrounding_cuts import POWER_SEQUENCER_OT_trim_to_surrounding_cuts + +classes = [ + POWER_SEQUENCER_OT_speed_up_movie_strip, + POWER_SEQUENCER_OT_align_audios, + POWER_SEQUENCER_OT_playback_speed_set, + POWER_SEQUENCER_OT_channel_offset, + POWER_SEQUENCER_OT_concatenate_strips, + POWER_SEQUENCER_OT_copy_selected_sequences, + POWER_SEQUENCER_OT_crossfade_add, + POWER_SEQUENCER_OT_crossfade_edit, + POWER_SEQUENCER_OT_transitions_remove, + POWER_SEQUENCER_OT_split_strips_under_cursor, + POWER_SEQUENCER_OT_playback_speed_decrease, + POWER_SEQUENCER_OT_delete_direct, + POWER_SEQUENCER_OT_deselect_all_strips_left_or_right, + POWER_SEQUENCER_OT_deselect_handles_and_grab, + POWER_SEQUENCER_OT_duplicate_move, + POWER_SEQUENCER_OT_expand_to_surrounding_cuts, + POWER_SEQUENCER_OT_fade_add, + POWER_SEQUENCER_OT_fade_clear, + POWER_SEQUENCER_OT_grab_closest_cut, + POWER_SEQUENCER_OT_grab, + POWER_SEQUENCER_OT_grab_sequence_handles, + POWER_SEQUENCER_OT_import_local_footage, + POWER_SEQUENCER_OT_playback_speed_increase, + POWER_SEQUENCER_OT_jump_time_offset, + POWER_SEQUENCER_OT_jump_to_cut, + POWER_SEQUENCER_OT_make_still_image, + POWER_SEQUENCER_OT_marker_delete_closest, + POWER_SEQUENCER_OT_marker_delete_direct, + POWER_SEQUENCER_OT_marker_go_to_next, + POWER_SEQUENCER_OT_copy_markers_as_timecodes, + POWER_SEQUENCER_OT_markers_create_from_selected_strips, + POWER_SEQUENCER_OT_marker_snap_to_cursor, + POWER_SEQUENCER_OT_markers_snap_matching_strips, + POWER_SEQUENCER_OT_meta_resize_to_content, + POWER_SEQUENCER_OT_meta_ungroup_and_trim, + POWER_SEQUENCER_OT_meta_trim_content_to_bounds, + POWER_SEQUENCER_OT_mouse_trim, + POWER_SEQUENCER_OT_space_sequences, + POWER_SEQUENCER_OT_mouse_toggle_mute, + POWER_SEQUENCER_OT_mouse_trim_instantly, + POWER_SEQUENCER_OT_open_project_directory, + POWER_SEQUENCER_OT_preview_closest_cut, + POWER_SEQUENCER_OT_preview_to_selection, + POWER_SEQUENCER_OT_gap_remove, + POWER_SEQUENCER_OT_scene_rename_with_strip, + POWER_SEQUENCER_OT_render_apply_preset, + POWER_SEQUENCER_OT_ripple_delete, + POWER_SEQUENCER_OT_save_direct, + POWER_SEQUENCER_OT_scene_create_from_selection, + POWER_SEQUENCER_OT_scene_cycle, + POWER_SEQUENCER_OT_select_closest_to_mouse, + POWER_SEQUENCER_OT_select_linked_strips, + POWER_SEQUENCER_OT_select_linked_effect, + POWER_SEQUENCER_OT_select_related_strips, + POWER_SEQUENCER_OT_select_strips_under_cursor, + POWER_SEQUENCER_OT_set_preview_between_markers, + POWER_SEQUENCER_OT_set_timeline_range, + POWER_SEQUENCER_OT_trim_left_or_right_handles, + POWER_SEQUENCER_OT_snap, + POWER_SEQUENCER_OT_snap_selection, + POWER_SEQUENCER_OT_speed_remove_effect, + POWER_SEQUENCER_OT_swap_strips, + POWER_SEQUENCER_OT_synchronize_titles, + POWER_SEQUENCER_OT_toggle_selected_mute, + POWER_SEQUENCER_OT_toggle_waveforms, + POWER_SEQUENCER_OT_trim_three_point_edit, + POWER_SEQUENCER_OT_select_all_left_or_right, + POWER_SEQUENCER_OT_trim_to_surrounding_cuts, +] + +doc = { + "sequencer.refresh_all": { + "name": "Refresh All", + "description": "", + "shortcuts": [({"type": "R", "value": "PRESS", "shift": True}, {}, "Refresh All")], + "demo": "", + "keymap": "Sequencer", + } +} diff --git a/power_sequencer/operators/align_audios.py b/power_sequencer/operators/align_audios.py new file mode 100644 index 00000000..a280f1fd --- /dev/null +++ b/power_sequencer/operators/align_audios.py @@ -0,0 +1,109 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_align_audios(bpy.types.Operator): + """*brief* Align two audio strips + + + Tries to synchronize the selected audio strip to the active audio strip by comparing the sound. + Useful to synchronize audio of the same event recorded with different microphones. + + To use this feature, you must have [ffmpeg](https://www.ffmpeg.org/download.html) and + [scipy](https://www.scipy.org/install.html) installed on your computer and available on the PATH (command line) to work. + + The longer the audio files, the longer the tool can take to run, as it has to convert, analyze, + and compare the audio sources to work. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/xkBUzDj.gif", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if not context.scene: + return False + + active = context.scene.sequence_editor.active_strip + selected = context.selected_sequences + ok = ( + len(selected) == 2 + and active in selected + and all(map(lambda s: s.type == "SOUND", selected)) + ) + return ok + + def execute(self, context): + try: + import scipy + except ImportError: + self.report({"ERROR"}, "Scipy must be installed to align audios") + return {"FINISHED"} + + if not is_ffmpeg_available(): + self.report({"ERROR"}, "ffmpeg must be installed to align audios") + return {"FINISHED"} + + # This import is here because otherwise, it slows down blender startup + from .audiosync import find_offset + + scene = context.scene + + active = scene.sequence_editor.active_strip + active_filepath = bpy.path.abspath(active.sound.filepath) + + selected = context.selected_sequences + selected.pop(selected.index(active)) + + align_strip = selected[0] + align_strip_filepath = bpy.path.abspath(align_strip.sound.filepath) + + offset, score = find_offset(align_strip_filepath, active_filepath) + + initial_offset = active.frame_start - align_strip.frame_start + + fps = scene.render.fps / scene.render.fps_base + frames = int(offset * fps) + + align_strip.frame_start -= frames - initial_offset + + self.report({"INFO"}, "Alignment score: " + str(round(score, 1))) + + return {"FINISHED"} + + +def is_ffmpeg_available(): + """ + Returns true if ffmpeg is installed and available from the PATH + """ + try: + subprocess.call(["ffmpeg", "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return True + except OSError: + return False diff --git a/power_sequencer/operators/audiosync/__init__.py b/power_sequencer/operators/audiosync/__init__.py new file mode 100644 index 00000000..9fc19da4 --- /dev/null +++ b/power_sequencer/operators/audiosync/__init__.py @@ -0,0 +1,17 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +from .find_offset import find_offset diff --git a/power_sequencer/operators/audiosync/convert_and_trim.py b/power_sequencer/operators/audiosync/convert_and_trim.py new file mode 100644 index 00000000..1ce1a4c1 --- /dev/null +++ b/power_sequencer/operators/audiosync/convert_and_trim.py @@ -0,0 +1,61 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import os +import subprocess +import tempfile + + +def convert_and_trim(audio_filepath, freq, dur): + """ + Uses ffmpeg to convert an audio file to a temporary wav file for use + in finding offset. + + Args + :audio: path to the audiofile to convert (string) + :freq: Samples / second in the output wav (int) + :dur: Max duration of the output wav in seconds (float) + + Returns + :outpath: path to the output wav file + """ + + tmp = tempfile.NamedTemporaryFile(mode="r+b", prefix="offset_", suffix=".wav") + outpath = tmp.name + tmp.close() + + channel_count = "1" + + subprocess.call( + [ + "ffmpeg", + "-loglevel", + "panic", + "-i", + audio_filepath, + "-ac", + channel_count, + "-ar", + str(freq), + "-t", + str(dur), + "-acodec", + "pcm_s16le", + outpath, + ] + ) + + return outpath diff --git a/power_sequencer/operators/audiosync/cross_correlation.py b/power_sequencer/operators/audiosync/cross_correlation.py new file mode 100644 index 00000000..b18ab35e --- /dev/null +++ b/power_sequencer/operators/audiosync/cross_correlation.py @@ -0,0 +1,33 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import numpy as np + + +def cross_correlation(mfcc1, mfcc2, nframes): + n1, mdim1 = mfcc1.shape + # n2, mdim2 = mfcc2.shape + + n = n1 - nframes + 1 + + if n < 0: + return None + + c = np.zeros(n) + for k in range(n): + cc = np.sum(np.multiply(mfcc1[k : k + nframes], mfcc2[:nframes]), axis=0) + c[k] = np.linalg.norm(cc) + return c diff --git a/power_sequencer/operators/audiosync/ensure_non_zero.py b/power_sequencer/operators/audiosync/ensure_non_zero.py new file mode 100644 index 00000000..911ab91b --- /dev/null +++ b/power_sequencer/operators/audiosync/ensure_non_zero.py @@ -0,0 +1,28 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import numpy as np + + +def ensure_non_zero(signal): + """ + Adds a little bit of static to avoid + 'divide by zero encountered in log' during MFCC computation. + """ + + signal += np.random.random(len(signal)) * 10 ** -10 + + return signal diff --git a/power_sequencer/operators/audiosync/find_offset.py b/power_sequencer/operators/audiosync/find_offset.py new file mode 100644 index 00000000..a25bffde --- /dev/null +++ b/power_sequencer/operators/audiosync/find_offset.py @@ -0,0 +1,87 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +""" +This code is an adaptation of 'audio-offset-finder' by BBC +""" +import os +import warnings +import numpy as np +from scipy.io import wavfile + +from .mfcc import mfcc +from .convert_and_trim import convert_and_trim +from .std_mfcc import std_mfcc +from .cross_correlation import cross_correlation +from .ensure_non_zero import ensure_non_zero + + +def find_offset(file1, file2, freq=8000, trim=60 * 15, correl_nframes=1000): + """ + Determine the offset (in seconds) between 2 audio files + + Uses cross-correlation of standardised Mel-Frequency Cepstral + Coefficients + """ + file1 = os.path.abspath(file1) + file2 = os.path.abspath(file2) + + wav1_path = convert_and_trim(file1, freq, trim) + wav2_path = convert_and_trim(file2, freq, trim) + + rate1, data1 = wavfile.read(wav1_path, mmap=True) + data1 = data1 / (2.0 ** 15) + + rate2, data2 = wavfile.read(wav2_path, mmap=True) + data2 = data2 / (2.0 ** 15) + + data1 = ensure_non_zero(data1) + data2 = ensure_non_zero(data2) + + mfcc1 = mfcc(data1, nwin=256, nfft=512, fs=freq, nceps=13)[0] + mfcc2 = mfcc(data2, nwin=256, nfft=512, fs=freq, nceps=13)[0] + + mfcc1 = std_mfcc(mfcc1) + mfcc2 = std_mfcc(mfcc2) + + frames1 = mfcc1.shape[0] + frames2 = mfcc2.shape[0] + + if frames1 > frames2: + flip = 1 + + else: + flip = -1 + mfcc1, mfcc2 = mfcc2, mfcc1 + + c = cross_correlation(mfcc1, mfcc2, nframes=correl_nframes) + try: + c.any() + except AttributeError: + os.remove(wav1_path) + os.remove(wav2_path) + + return 0, 0 + + max_k_index = np.argmax(c) + + offset = max_k_index * 160.0 / float(freq) + score = (c[max_k_index] - np.mean(c)) / np.std(c) + + os.remove(wav1_path) + os.remove(wav2_path) + + return offset * flip, score diff --git a/power_sequencer/operators/audiosync/mfcc/__init__.py b/power_sequencer/operators/audiosync/mfcc/__init__.py new file mode 100644 index 00000000..2b20af43 --- /dev/null +++ b/power_sequencer/operators/audiosync/mfcc/__init__.py @@ -0,0 +1,17 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +from .mfcc import mfcc diff --git a/power_sequencer/operators/audiosync/mfcc/mfcc.py b/power_sequencer/operators/audiosync/mfcc/mfcc.py new file mode 100644 index 00000000..93709e8c --- /dev/null +++ b/power_sequencer/operators/audiosync/mfcc/mfcc.py @@ -0,0 +1,92 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import numpy as np + +from scipy.signal import hamming, lfilter +from scipy.fftpack import fft +from scipy.fftpack.realtransforms import dct + +from .trfbank import trfbank +from .segment_axis import segment_axis + + +def mfcc(input, nwin=256, nfft=512, fs=16000, nceps=13): + """Compute Mel Frequency Cepstral Coefficients. + + Parameters + ---------- + input: ndarray + input from which the coefficients are computed + + Returns + ------- + ceps: ndarray + Mel-cepstrum coefficients + mspec: ndarray + Log-spectrum in the mel-domain. + + Notes + ----- + MFCC are computed as follows: + * Pre-processing in time-domain (pre-emphasizing) + * Compute the spectrum amplitude by windowing with a Hamming window + * Filter the signal in the spectral domain with a triangular + filter-bank, whose filters are approximatively linearly spaced on the + mel scale, and have equal bandwidth in the mel scale + * Compute the DCT of the log-spectrum + + References + ---------- + .. [1] S.B. Davis and P. Mermelstein, "Comparison of parametric + representations for monosyllabic word recognition in continuously + spoken sentences", IEEE Trans. Acoustics. Speech, Signal Proc. + ASSP-28 (4): 357-366, August 1980.""" + + # MFCC parameters: taken from auditory toolbox + over = nwin - 160 + # Pre-emphasis factor (to take into account the -6dB/octave rolloff of the + # radiation at the lips level) + prefac = 0.97 + + # lowfreq = 400 / 3. + lowfreq = 133.33 + # highfreq = 6855.4976 + linsc = 200 / 3.0 + logsc = 1.0711703 + + nlinfil = 13 + nlogfil = 27 + nfil = nlinfil + nlogfil + + w = hamming(nwin, sym=0) + + fbank = trfbank(fs, nfft, lowfreq, linsc, logsc, nlinfil, nlogfil)[0] + + # ------------------ + # Compute the MFCC + # ------------------ + extract = lfilter([1.0, -prefac], 1, input) + framed = segment_axis(extract, nwin, over) * w + + # Compute the spectrum magnitude + spec = np.abs(fft(framed, nfft, axis=-1)) + # Filter the spectrum through the triangle filterbank + mspec = np.log10(np.dot(spec, fbank.T)) + # Use the DCT to 'compress' the coefficients (spectrum -> cepstrum domain) + ceps = dct(mspec, type=2, norm="ortho", axis=-1)[:, :nceps] + + return ceps, mspec, spec diff --git a/power_sequencer/operators/audiosync/mfcc/segment_axis.py b/power_sequencer/operators/audiosync/mfcc/segment_axis.py new file mode 100644 index 00000000..a8345833 --- /dev/null +++ b/power_sequencer/operators/audiosync/mfcc/segment_axis.py @@ -0,0 +1,110 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import numpy as np +import warnings + + +def segment_axis(a, length, overlap=0, axis=None, end="cut", endvalue=0): + """Generate a new array that chops the given array along the given axis + into overlapping frames. + + example: + >>> segment_axis(arange(10), 4, 2) + array([[0, 1, 2, 3], + [2, 3, 4, 5], + [4, 5, 6, 7], + [6, 7, 8, 9]]) + + arguments: + a The array to segment + length The length of each frame + overlap The number of array elements by which the frames should overlap + axis The axis to operate on; if None, act on the flattened array + end What to do with the last frame, if the array is not evenly + divisible into pieces. Options are: + + 'cut' Simply discard the extra values + 'wrap' Copy values from the beginning of the array + 'pad' Pad with a constant value + + endvalue The value to use for end='pad' + + The array is not copied unless necessary (either because it is unevenly + strided and being flattened or because end is set to 'pad' or 'wrap'). + """ + + if axis is None: + a = np.ravel(a) # may copy + axis = 0 + + l = a.shape[axis] + + if overlap >= length: + raise ValueError("frames cannot overlap by more than 100%") + if overlap < 0 or length <= 0: + raise ValueError("overlap must be nonnegative and length must " "be positive") + + if l < length or (l - length) % (length - overlap): + if l > length: + roundup = length + (1 + (l - length) // (length - overlap)) * (length - overlap) + rounddown = length + ((l - length) // (length - overlap)) * (length - overlap) + else: + roundup = length + rounddown = 0 + assert rounddown < l < roundup + assert roundup == rounddown + (length - overlap) or (roundup == length and rounddown == 0) + a = a.swapaxes(-1, axis) + + if end == "cut": + a = a[..., :rounddown] + elif end in ["pad", "wrap"]: # copying will be necessary + s = list(a.shape) + s[-1] = roundup + b = np.empty(s, dtype=a.dtype) + b[..., :l] = a + if end == "pad": + b[..., l:] = endvalue + elif end == "wrap": + b[..., l:] = a[..., : roundup - l] + a = b + + a = a.swapaxes(-1, axis) + + l = a.shape[axis] + if l == 0: + raise ValueError( + "Not enough data points to segment array in 'cut' mode; " "try 'pad' or 'wrap'" + ) + assert l >= length + assert (l - length) % (length - overlap) == 0 + n = 1 + (l - length) // (length - overlap) + s = a.strides[axis] + newshape = a.shape[:axis] + (n, length) + a.shape[axis + 1 :] + newstrides = a.strides[:axis] + ((length - overlap) * s, s) + a.strides[axis + 1 :] + + try: + return np.ndarray.__new__( + np.ndarray, strides=newstrides, shape=newshape, buffer=a, dtype=a.dtype + ) + except TypeError: + warnings.warn("Problem with ndarray creation forces copy.") + a = a.copy() + # Shape doesn't change but strides does + newstrides = a.strides[:axis] + ((length - overlap) * s, s) + a.strides[axis + 1 :] + return np.ndarray.__new__( + np.ndarray, strides=newstrides, shape=newshape, buffer=a, dtype=a.dtype + ) diff --git a/power_sequencer/operators/audiosync/mfcc/trfbank.py b/power_sequencer/operators/audiosync/mfcc/trfbank.py new file mode 100644 index 00000000..00558944 --- /dev/null +++ b/power_sequencer/operators/audiosync/mfcc/trfbank.py @@ -0,0 +1,51 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import numpy as np + + +def trfbank(fs, nfft, lowfreq, linsc, logsc, nlinfilt, nlogfilt): + """Compute triangular filterbank for MFCC computation.""" + # Total number of filters + nfilt = nlinfilt + nlogfilt + + # ------------------------ + # Compute the filter bank + # ------------------------ + # Compute start/middle/end points of the triangular filters in spectral + # domain + freqs = np.zeros(nfilt + 2) + freqs[:nlinfilt] = lowfreq + np.arange(nlinfilt) * linsc + freqs[nlinfilt:] = freqs[nlinfilt - 1] * logsc ** np.arange(1, nlogfilt + 3) + heights = 2.0 / (freqs[2:] - freqs[0:-2]) + + # Compute filterbank coeff (in fft domain, in bins) + fbank = np.zeros((nfilt, nfft)) + # FFT bins (in Hz) + nfreqs = np.arange(nfft) / (1.0 * nfft) * fs + for i in range(nfilt): + low = freqs[i] + cen = freqs[i + 1] + hi = freqs[i + 2] + + lid = np.arange(np.floor(low * nfft / fs) + 1, np.floor(cen * nfft / fs) + 1, dtype=np.int) + lslope = heights[i] / (cen - low) + rid = np.arange(np.floor(cen * nfft / fs) + 1, np.floor(hi * nfft / fs) + 1, dtype=np.int) + rslope = heights[i] / (hi - cen) + fbank[i][lid] = lslope * (nfreqs[lid] - low) + fbank[i][rid] = rslope * (hi - nfreqs[rid]) + + return fbank, freqs diff --git a/power_sequencer/operators/audiosync/std_mfcc.py b/power_sequencer/operators/audiosync/std_mfcc.py new file mode 100644 index 00000000..3fccae0c --- /dev/null +++ b/power_sequencer/operators/audiosync/std_mfcc.py @@ -0,0 +1,21 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import numpy as np + + +def std_mfcc(mfcc): + return (mfcc - np.mean(mfcc, axis=0)) / np.std(mfcc, axis=0) diff --git a/power_sequencer/operators/channel_offset.py b/power_sequencer/operators/channel_offset.py new file mode 100644 index 00000000..2779faac --- /dev/null +++ b/power_sequencer/operators/channel_offset.py @@ -0,0 +1,79 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from operator import attrgetter + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_channel_offset(bpy.types.Operator): + """ + Move selected strip to the nearest open channel above/down + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "UP_ARROW", "value": "PRESS", "alt": True}, + {"direction": "up"}, + "Move to Open Channel Above", + ), + ( + {"type": "DOWN_ARROW", "value": "PRESS", "alt": True}, + {"direction": "down"}, + "Move to Open Channel Below", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + direction: bpy.props.EnumProperty( + items=[ + ("up", "up", "Move the selection 1 channel up"), + ("down", "down", "Move the selection 1 channel down"), + ], + name="Direction", + description="Move the sequences up or down", + default="up", + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + selection = [s for s in context.selected_sequences if not s.lock] + if not selection: + return {"CANCELLED"} + + selection = sorted(selection, key=attrgetter("channel", "frame_final_start")) + + if self.direction == "up": + for s in reversed(selection): + s.channel += 1 + elif self.direction == "down": + for s in selection: + if s.channel > 1: + s.channel -= 1 + return {"FINISHED"} diff --git a/power_sequencer/operators/concatenate_strips.py b/power_sequencer/operators/concatenate_strips.py new file mode 100644 index 00000000..29d11384 --- /dev/null +++ b/power_sequencer/operators/concatenate_strips.py @@ -0,0 +1,154 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from operator import attrgetter + +from .utils.global_settings import SequenceTypes +from .utils.functions import find_sequences_after, get_mouse_frame_and_channel, sequencer_workaround_2_80_audio_bug +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +def find_sequences_before(context, strip): + """ + Returns a list of sequences that are before the strip in the current context + """ + return [s for s in context.sequences if s.frame_final_end <= strip.frame_final_start] + + +class POWER_SEQUENCER_OT_concatenate_strips(bpy.types.Operator): + """ + *brief* Remove space between strips + + Concatenates selected strips in a channel, i.e. removes the gap between them. If a single + strip is selected, either the next strip in the channel will be concatenated, or all + strips in the channel will be concatenated depending on which shortcut is used. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/YyEL8YP.gif", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "C", "value": "PRESS"}, + {"concatenate_all": False, "to_left": True}, + ("Concatenate and select the next strip in the channel"), + ), + ( + {"type": "C", "value": "PRESS", "shift": True}, + {"concatenate_all": True, "to_left": True}, + "Concatenate all strips in selected channels", + ), + ( + {"type": "C", "value": "PRESS", "alt": True}, + {"concatenate_all": False, "to_left": False}, + ("Concatenate and select the previous strip in the channel towards the right"), + ), + ( + {"type": "C", "value": "PRESS", "shift": True, "alt": True}, + {"concatenate_all": True, "to_left": False}, + "Shift Alt C; Concatenate all strips in channel towards the right", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + concatenate_all: bpy.props.BoolProperty( + name="Concatenate all strips in channel", + description=("If only one strip selected, concatenate" " the entire channel"), + default=False, + ) + to_left: bpy.props.BoolProperty( + name="To Left", + description="Concatenate strips moving them back in time (default) or forward in time", + default=True, + ) + + @classmethod + def poll(cls, context): + return context.sequences and context.selected_sequences + + def invoke(self, context, event): + if not context.selected_sequences: + frame, channel = get_mouse_frame_and_channel(context, event) + bpy.ops.power_sequencer.select_closest_to_mouse(frame=frame, channel=channel) + return self.execute(context) + + def execute(self, context): + selection = context.selected_sequences + channels = {s.channel for s in selection} + + if len(selection) == len(channels): + for s in selection: + candidates = ( + find_sequences_before(context, s) + if not self.to_left + else find_sequences_after(context, s) + ) + to_concatenate = [ + strip + for strip in candidates + if strip.channel == s.channel + and not strip.lock + and strip.type in SequenceTypes.CUTABLE + ] + self.concatenate(s, to_concatenate) + + else: + for channel in channels: + to_concatenate = [s for s in selection if s.channel == channel] + strip_target = ( + min(to_concatenate, key=lambda s: s.frame_final_start) + if self.to_left + else max(to_concatenate, key=lambda s: s.frame_final_start) + ) + to_concatenate.remove(strip_target) + self.concatenate(strip_target, to_concatenate, force_all=True) + + sequencer_workaround_2_80_audio_bug(context) + return {"FINISHED"} + + def concatenate(self, strip_target, sequences, force_all=False): + to_concatenate = sorted(sequences, key=attrgetter("frame_final_start")) + to_concatenate = list(reversed(to_concatenate)) if not self.to_left else to_concatenate + to_concatenate = ( + [to_concatenate[0]] if not (self.concatenate_all or force_all) else to_concatenate + ) + + attribute_target = "frame_final_end" if self.to_left else "frame_final_start" + attribute_concat = "frame_final_start" if self.to_left else "frame_final_end" + concatenate_start = getattr(strip_target, attribute_target) + last_gap = 0 + for s in to_concatenate: + if isinstance(s, bpy.types.EffectSequence): + concatenate_start = ( + s.frame_final_end - last_gap if self.to_left else s.frame_final_start - last_gap + ) + continue + concat_strip_frame = getattr(s, attribute_concat) + gap = concat_strip_frame - concatenate_start + s.frame_start -= gap + concatenate_start = s.frame_final_end if self.to_left else s.frame_final_start + last_gap = gap + + if not self.concatenate_all: + strip_target.select = False + to_concatenate[0].select = True diff --git a/power_sequencer/operators/copy_selected_sequences.py b/power_sequencer/operators/copy_selected_sequences.py new file mode 100644 index 00000000..69f66714 --- /dev/null +++ b/power_sequencer/operators/copy_selected_sequences.py @@ -0,0 +1,95 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from operator import attrgetter + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_copy_selected_sequences(bpy.types.Operator): + """ + *brief* Copy/cut strips without offset from current time indicator + + + Copies the selected sequences without frame offset and optionally + deletes the selection to give a cut to clipboard effect. This + operator overrides the default Blender copy method which includes + cursor offset when pasting, which is atypical of copy/paste methods. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/w6z1Jb1.gif", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "C", "value": "PRESS", "ctrl": True}, + {"delete_selection": False}, + "Copy Selected Strips", + ), + ( + {"type": "X", "value": "PRESS", "ctrl": True}, + {"delete_selection": True}, + "Cut Selected Strips", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + delete_selection: bpy.props.BoolProperty( + name="Delete selection", + description="Delete selected strips: acts like cut and paste", + default=False, + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + cursor_start_frame = context.scene.frame_current + sequencer = bpy.ops.sequencer + + # Deactivate audio playback and video preview + scene = context.scene + initial_audio_setting = scene.use_audio_scrub + initial_proxy_size = context.space_data.proxy_render_size + scene.use_audio_scrub = False + context.space_data.proxy_render_size = "NONE" + + first_sequence = min(context.selected_sequences, key=attrgetter("frame_final_start")) + context.scene.frame_current = first_sequence.frame_final_start + sequencer.copy() + context.scene.frame_current = cursor_start_frame + + scene.use_audio_scrub = initial_audio_setting + context.space_data.proxy_render_size = initial_proxy_size + + if self.delete_selection: + sequencer.delete() + + plural_string = "s" if len(context.selected_sequences) != 1 else "" + action_verb = "Cut" if self.delete_selection else "Copied" + report_message = "{!s} {!s} sequence{!s} to the clipboard.".format( + action_verb, str(len(context.selected_sequences)), plural_string + ) + self.report({"INFO"}, report_message) + return {"FINISHED"} diff --git a/power_sequencer/operators/crossfade_add.py b/power_sequencer/operators/crossfade_add.py new file mode 100644 index 00000000..31f97af2 --- /dev/null +++ b/power_sequencer/operators/crossfade_add.py @@ -0,0 +1,119 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import find_sequences_after +from .utils.functions import convert_duration_to_frames +from .utils.global_settings import SequenceTypes +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_crossfade_add(bpy.types.Operator): + """ + *brief* Adds cross fade between selected sequence and the closest sequence to its right + + + Based on the active strip, finds the closest next sequence of a similar type, moves it + so it overlaps the active strip, and adds a gamma cross effect between them. Works with + MOVIE, IMAGE and META strips + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/ZyEd0jD.gif", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "C", "value": "PRESS", "ctrl": True, "alt": True}, {}, "Add Crossfade") + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + crossfade_duration: bpy.props.FloatProperty( + name="Crossfade Duration", description="The duration of the crossfade", default=0.5, min=0 + ) + auto_move_strip: bpy.props.BoolProperty( + name="Auto Move Strip", + description=( + "When true, moves the second strip so the crossfade" + " is of the length set in 'Crossfade Length'" + ), + default=True, + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + sorted_selection = sorted(context.selected_sequences, key=lambda s: s.frame_final_start) + for s in sorted_selection: + s_next = self.get_next_sequence_after(context, s) + s_to_offset = s_next if s_next.type not in SequenceTypes.EFFECT else s_next.input_1 + + if self.auto_move_strip: + offset = s_to_offset.frame_final_start - s.frame_final_end + s_to_offset.frame_start -= offset + + if s_to_offset.frame_final_start == s.frame_final_end: + self.offset_sequence_handles(context, s, s_to_offset) + + self.apply_crossfade(context, s, s_next) + return {"FINISHED"} + + def get_next_sequence_after(self, context, sequence): + """ + Returns the first sequence after `sequence` by frame_final_start + """ + next_sequence = None + next_in_channel = [ + s for s in find_sequences_after(context, sequence) if s.channel == sequence.channel + ] + next_transitionable = (s for s in next_in_channel if s.type in SequenceTypes.TRANSITIONABLE) + try: + next_sequence = min(next_transitionable, key=lambda s: s.frame_final_start) + except ValueError: + pass + return next_sequence + + def apply_crossfade(self, context, strip_from, strip_to): + for s in bpy.context.selected_sequences: + s.select = False + strip_from.select = True + strip_to.select = True + context.scene.sequence_editor.active_strip = strip_to + bpy.ops.sequencer.effect_strip_add(type="GAMMA_CROSS") + + def offset_sequence_handles(self, context, sequence_1, sequence_2): + """ + Moves the handles of the two sequences before adding the crossfade + """ + fade_duration = convert_duration_to_frames(context, self.crossfade_duration) + fade_offset = fade_duration / 2 + + if hasattr(sequence_1, "input_1"): + sequence_1.input_1.frame_final_end -= fade_offset + else: + sequence_1.frame_final_end -= fade_offset + + if hasattr(sequence_2, "input_1"): + sequence_2.input_1.frame_final_start += fade_offset + else: + sequence_2.frame_final_start += fade_offset diff --git a/power_sequencer/operators/crossfade_edit.py b/power_sequencer/operators/crossfade_edit.py new file mode 100644 index 00000000..127ab603 --- /dev/null +++ b/power_sequencer/operators/crossfade_edit.py @@ -0,0 +1,89 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.global_settings import SequenceTypes +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_crossfade_edit(bpy.types.Operator): + """ + *brief* Adjust the location of the crossfade between 2 strips + + + Selects the handles of both inputs of a crossfade strip's input and + calls the grab operator. Allows you to quickly change the location + of a fade transition between two strips. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/rCmLhg6.gif", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + crossfade_types = ["CROSS", "GAMMA_CROSS"] + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor.active_strip and context.selected_sequences + + + def execute(self, context): + active = context.scene.sequence_editor.active_strip + if active.type not in self.crossfade_types: + effect = self.find_cross_effect(context, active) + if not effect: + return {"CANCELLED"} + active = context.scene.sequence_editor.active_strip = effect + + bpy.ops.sequencer.select_all(action="DESELECT") + active.select = True + active.input_1.select_right_handle = True + active.input_2.select_left_handle = True + active.input_1.select = True + active.input_2.select = True + bpy.ops.transform.seq_slide("INVOKE_DEFAULT") + return {"FINISHED"} + + def find_cross_effect(self, sequence): + """ + Takes a single strip and finds effect strips that use it as input + Returns the effect strip(s) found as a list, ordered by starting frame + Returns None if no effect was found + """ + if sequence.type not in SequenceTypes.VIDEO + SequenceTypes.IMAGE: + return + + effect_sequences = (s for s in context.sequences if s.type in SequenceTypes.EFFECT) + found_effect_strips = [] + for s in effect_sequences: + if s.input_1.name == sequence.name: + found_effect_strips.append(s) + if s.input_count == 2: + if s.input_2.name == sequence.name: + found_effect_strips.append(s) + for e in found_effect_strips: + if e.type not in self.crossfade_types: + continue + return e diff --git a/power_sequencer/operators/cut_strips_under_cursor.py b/power_sequencer/operators/cut_strips_under_cursor.py new file mode 100644 index 00000000..de1fff3e --- /dev/null +++ b/power_sequencer/operators/cut_strips_under_cursor.py @@ -0,0 +1,65 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description +from .utils.functions import get_mouse_frame_and_channel + + +class POWER_SEQUENCER_OT_split_strips_under_cursor(bpy.types.Operator): + """ + Splits all strips under cursor including muted strips, but excluding locked strips. + Auto selects sequences under the time cursor when you don't have a selection. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/ZyEd0jD.gif", + "description": doc_description(__doc__), + "shortcuts": [({"type": "K", "value": "PRESS"}, {}, "Cut All Strips Under Cursor")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + side: bpy.props.EnumProperty( + items=[("LEFT", "", ""), ("RIGHT", "", "")], name="Side", default="LEFT", options={"HIDDEN"} + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + frame, channel = get_mouse_frame_and_channel(context, event) + self.side = "LEFT" if frame < context.scene.frame_current else "RIGHT" + return self.execute(context) + + def execute(self, context): + # Deselect to trigger a call to select_strips_under_cursor below if the + # time cursor doesn't overlap any of the selected strip: if so, it + # can't cut anything! + deselect = True + for s in bpy.context.selected_sequences: + if s.frame_final_start <= context.scene.frame_current <= s.frame_final_end: + deselect = False + if deselect: + bpy.ops.sequencer.select_all(action="DESELECT") + (context.selected_sequences or bpy.ops.power_sequencer.select_strips_under_cursor()) + return bpy.ops.sequencer.cut(frame=context.scene.frame_current, side=self.side) diff --git a/power_sequencer/operators/delete_direct.py b/power_sequencer/operators/delete_direct.py new file mode 100644 index 00000000..2650ddb1 --- /dev/null +++ b/power_sequencer/operators/delete_direct.py @@ -0,0 +1,74 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_mouse_frame_and_channel +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_delete_direct(bpy.types.Operator): + """ + Deletes strips without confirmation, and cleans up crossfades nicely. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "X", "value": "PRESS"}, {}, "Delete Direct"), + ( + {"type": "X", "alt": True, "value": "PRESS"}, + {"is_removing_transitions": True}, + "Delete Direct with Transitions", + ), + ({"type": "DEL", "value": "PRESS"}, {}, "Delete Direct"), + ( + {"type": "DEL", "alt": True, "value": "PRESS"}, + {"is_removing_transitions": True}, + "Delete Direct with Transitions", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + is_removing_transitions: bpy.props.BoolProperty(name="Remove Transitions", default=False) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def invoke(self, context, event): + frame, channel = get_mouse_frame_and_channel(context, event) + if not context.selected_sequences: + bpy.ops.power_sequencer.select_closest_to_mouse(frame=frame, channel=channel) + return self.execute(context) + + def execute(self, context): + selection = context.selected_sequences + if self.is_removing_transitions and bpy.ops.power_sequencer.transitions_remove.poll(): + bpy.ops.power_sequencer.transitions_remove() + bpy.ops.sequencer.delete() + + report_message = "Deleted " + str(len(selection)) + " sequence" + report_message += "s" if len(selection) > 1 else "" + self.report({"INFO"}, report_message) + return {"FINISHED"} diff --git a/power_sequencer/operators/deselect_all_left_or_right.py b/power_sequencer/operators/deselect_all_left_or_right.py new file mode 100644 index 00000000..c250095d --- /dev/null +++ b/power_sequencer/operators/deselect_all_left_or_right.py @@ -0,0 +1,86 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_deselect_all_strips_left_or_right(bpy.types.Operator): + """ + Deselects all the strips at the left or right of the time cursor, based on the position + of the mouse + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "Q", "value": "PRESS", "alt": True}, + {"side": "left"}, + "Deselect all strips to the left of the time cursor", + ), + ( + {"type": "E", "value": "PRESS", "alt": True}, + {"side": "right"}, + "Deselect all strips to the right of the time cursor", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + side: bpy.props.EnumProperty( + name="Side", + description="The side to deselect", + items=[ + ( + "mouse", + "Mouse position", + ("Deselect based on the mouse position relative to the" " time cursor"), + ), + ("left", "Left", "Left of the time cursor"), + ("right", "Right", "Right of the time cursor"), + ], + default="mouse", + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def invoke(self, context, event): + frame_current = context.scene.frame_current + frame_mouse = context.region.view2d.region_to_view(event.mouse_region_x, 1)[0] + + for s in context.sequences: + if self.side == "left" or frame_mouse < frame_current and self.side == "mouse": + if s.frame_final_end < frame_current: + self.deselect(s) + elif self.side == "right" or frame_mouse >= frame_current and self.side == "mouse": + if s.frame_final_start >= frame_current: + self.deselect(s) + return {"FINISHED"} + + def deselect(self, strip): + strip.select = False + strip.select_left_handle = False + strip.select_right_handle = False diff --git a/power_sequencer/operators/deselect_handles_and_grab.py b/power_sequencer/operators/deselect_handles_and_grab.py new file mode 100644 index 00000000..78365906 --- /dev/null +++ b/power_sequencer/operators/deselect_handles_and_grab.py @@ -0,0 +1,50 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_deselect_handles_and_grab(bpy.types.Operator): + """ + Deselect the handles of all selected strips and call the Sequence Slide operator + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + for s in context.selected_sequences: + s.select_left_handle = False + s.select_right_handle = False + s.select = True + + bpy.ops.transform.seq_slide("INVOKE_DEFAULT") + return {"FINISHED"} diff --git a/power_sequencer/operators/duplicate_move.py b/power_sequencer/operators/duplicate_move.py new file mode 100644 index 00000000..8a72e28e --- /dev/null +++ b/power_sequencer/operators/duplicate_move.py @@ -0,0 +1,56 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_mouse_frame_and_channel +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_duplicate_move(bpy.types.Operator): + """ + Auto selects the strip under the mouse if nothing is selected, and calls Blender's + Duplicate Move function + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "D", "value": "PRESS"}, {}, "Duplicate Move"), + ({"type": "D", "value": "PRESS", "shift": True}, {}, "Duplicate Move"), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + frame, channel = get_mouse_frame_and_channel(context, event) + if not context.selected_sequences: + bpy.ops.power_sequencer.select_closest_to_mouse(frame=frame, channel=channel) + return self.execute(context) + + def execute(self, context): + bpy.ops.sequencer.duplicate_move("INVOKE_DEFAULT") + return {"FINISHED"} diff --git a/power_sequencer/operators/expand_to_surrounding_cuts.py b/power_sequencer/operators/expand_to_surrounding_cuts.py new file mode 100644 index 00000000..34507da8 --- /dev/null +++ b/power_sequencer/operators/expand_to_surrounding_cuts.py @@ -0,0 +1,104 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from math import floor + +from .utils.functions import convert_duration_to_frames +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description +from .utils.functions import slice_selection + + +class POWER_SEQUENCER_OT_expand_to_surrounding_cuts(bpy.types.Operator): + """ + *Brief* Expand selected strips to surrounding cuts + + Finds potential gaps surrounding each block of selected sequences and extends the corresponding + sequence handle to it. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "E", "value": "PRESS", "ctrl": True}, {}, "Expand to Surrounding Cuts") + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + margin: bpy.props.FloatProperty( + name="Trim margin", + description="Margin to leave on either sides of the trim in seconds", + default=0.2, + min=0, + ) + gap_remove: bpy.props.BoolProperty( + name="Remove gaps", + description="When trimming the sequences, remove gaps automatically", + default=True, + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def invoke(self, context, event): + sequence_blocks = slice_selection(context, context.selected_sequences) + for sequences in sequence_blocks: + sequences_frame_start = min(sequences, key=lambda s: s.frame_final_start).frame_final_start + sequences_frame_end = max(sequences, key=lambda s: s.frame_final_end).frame_final_end + + frame_left, frame_right = find_closest_cuts( + context, sequences_frame_start, sequences_frame_end + ) + if ( + sequences_frame_start == frame_left + and sequences_frame_end == frame_right + ): + continue + + to_extend_left = [s for s in sequences if s.frame_final_start == sequences_frame_start] + to_extend_right = [s for s in sequences if s.frame_final_end == sequences_frame_end] + + for s in to_extend_left: + s.frame_final_start = ( + frame_left + if frame_left < sequences_frame_start + else sequences_frame_start + ) + for s in to_extend_right: + s.frame_final_end = ( + frame_right + if frame_right > sequences_frame_end + else sequences_frame_end + ) + return {"FINISHED"} + + +def find_closest_cuts(context, frame_min, frame_max): + frame_left = max( + context.sequences, key=lambda s: s.frame_final_end if s.frame_final_end <= frame_min else -1 + ).frame_final_end + frame_right = min( + context.sequences, + key=lambda s: s.frame_final_start if s.frame_final_start >= frame_max else 1000000, + ).frame_final_start + return frame_left, frame_right diff --git a/power_sequencer/operators/fade_add.py b/power_sequencer/operators/fade_add.py new file mode 100644 index 00000000..d72f034d --- /dev/null +++ b/power_sequencer/operators/fade_add.py @@ -0,0 +1,254 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from mathutils import Vector +from math import floor + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_fade_add(bpy.types.Operator): + """*brief* Adds or updates a fade animation for either visual or audio strips. + + Fade options: + + - In, Out, In and Out create a fade animation of the given duration from + the start of the sequence, to the end of the sequence, or on boths sides + - From playhead: the fade animation goes from the start of sequences under the playhead to the playhead + - To playhead: the fade animation goes from the playhead to the end of sequences under the playhead + + By default, the duration of the fade is 1 second. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/XoUM2vw.gif", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "F", "value": "PRESS", "alt": True}, {"type": "OUT"}, "Fade Out"), + ({"type": "F", "value": "PRESS", "ctrl": True}, {"type": "IN"}, "Fade In"), + ({"type": "F", "value": "PRESS"}, {"type": "IN_OUT"}, "Fade In and Out"), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + duration_seconds: bpy.props.FloatProperty( + name="Fade Duration", description="Duration of the fade in seconds", default=1.0, min=0.01 + ) + type: bpy.props.EnumProperty( + items=[ + ("IN_OUT", "Fade in and out", "Fade selected strips in and out"), + ("IN", "Fade in", "Fade in selected strips"), + ("OUT", "Fade out", "Fade out selected strips"), + ( + "CURSOR_FROM", + "From playhead", + "Fade from the time cursor to the end of overlapping sequences", + ), + ( + "CURSOR_TO", + "To playhead", + "Fade from the start of sequences under the time cursor to the current frame", + ), + ], + name="Fade type", + description="Fade in, out, or both in and out. Default is both", + default="IN_OUT", + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + # We must create a scene action first if there's none + scene = context.scene + if not scene.animation_data: + scene.animation_data_create() + if not scene.animation_data.action: + action = bpy.data.actions.new(scene.name + "Action") + scene.animation_data.action = action + + sequences = context.selected_sequences + if self.type in ["CURSOR_TO", "CURSOR_FROM"]: + sequences = [ + s + for s in sequences + if s.frame_final_start < context.scene.frame_current < s.frame_final_end + ] + + max_duration = min(sequences, key=lambda s: s.frame_final_duration).frame_final_duration + max_duration = floor(max_duration / 2.0) if self.type == "IN_OUT" else max_duration + + faded_sequences = [] + for sequence in sequences: + duration = self.calculate_fade_duration(context, sequence) + duration = min(duration, max_duration) + + if not self.is_long_enough(sequence, duration): + continue + + animated_property = "volume" if hasattr(sequence, "volume") else "blend_alpha" + fade_fcurve = fade_find_or_create_fcurve(context, sequence, animated_property) + fades = self.calculate_fades(sequence, fade_fcurve, animated_property, duration) + fade_animation_clear(context, fade_fcurve, fades) + fade_animation_create(fade_fcurve, fades) + faded_sequences.append(sequence) + + sequence_string = "sequence" if len(faded_sequences) == 1 else "sequences" + self.report( + {"INFO"}, "Added fade animation to {} {}.".format(len(faded_sequences), sequence_string) + ) + return {"FINISHED"} + + def calculate_fade_duration(self, context, sequence): + frame_current = context.scene.frame_current + duration = 0.0 + if self.type == "CURSOR_TO": + duration = abs(frame_current - sequence.frame_final_start) + elif self.type == "CURSOR_FROM": + duration = abs(sequence.frame_final_end - frame_current) + else: + duration = calculate_duration_frames(context, self.duration_seconds) + return max(1, duration) + + def is_long_enough(self, sequence, duration=0.0): + minimum_duration = duration * 2 if self.type == "IN_OUT" else duration + return sequence.frame_final_duration >= minimum_duration + + def calculate_fades(self, sequence, fade_fcurve, animated_property, duration): + """ + Returns a list of Fade objects + """ + fades = [] + if self.type in ["IN", "IN_OUT", "CURSOR_TO"]: + fade = Fade(sequence, fade_fcurve, "IN", animated_property, duration) + fades.append(fade) + if self.type in ["OUT", "IN_OUT", "CURSOR_FROM"]: + fade = Fade(sequence, fade_fcurve, "OUT", animated_property, duration) + fades.append(fade) + return fades + + +def fade_find_or_create_fcurve(context, sequence, animated_property): + """ + Iterates over all the fcurves until it finds an fcurve with a data path + that corresponds to the sequence. + Returns the matching FCurve or creates a new one if the function can't find a match. + """ + fade_fcurve = None + fcurves = context.scene.animation_data.action.fcurves + searched_data_path = sequence.path_from_id(animated_property) + for fcurve in fcurves: + if fcurve.data_path == searched_data_path: + fade_fcurve = fcurve + break + if not fade_fcurve: + fade_fcurve = fcurves.new(data_path=searched_data_path) + return fade_fcurve + + +def fade_animation_clear(context, fade_fcurve, fades): + """ + Removes existing keyframes in the fades' time range, in fast mode, without + updating the fcurve + """ + keyframe_points = fade_fcurve.keyframe_points + for keyframe in keyframe_points: + for fade in fades: + # The keyframe points list doesn't seem to always update as the + # operator re-runs Leading to trying to remove nonexistent keyframes + try: + if fade.start.x < keyframe.co[0] < fade.end.x: + keyframe_points.remove(keyframe, fast=True) + except ReferenceError: + pass + + +def fade_animation_create(fade_fcurve, fades): + """ + Inserts keyframes in the fade_fcurve in fast mode using the Fade objects. + Updates the fcurve after having inserted all keyframes to finish the animation. + """ + keyframe_points = fade_fcurve.keyframe_points + for fade in fades: + for point in (fade.start, fade.end): + keyframe_points.insert(frame=point.x, value=point.y, options={"FAST"}) + fade_fcurve.update() + # The graph editor and the audio waveforms only redraw upon "moving" a keyframe + keyframe_points[-1].co = keyframe_points[-1].co + + +class Fade: + """ + Data structure to represent fades + """ + + type = "" + animated_property = "" + duration = -1 + max_value = 1.0 + start, end = Vector((0, 0)), Vector((0, 0)) + + def __init__(self, sequence, fade_fcurve, type, animated_property, duration): + self.type = type + self.animated_property = animated_property + self.duration = duration + self.max_value = self.calculate_max_value(sequence, fade_fcurve) + + if type == "IN": + self.start = Vector((sequence.frame_final_start, 0.0)) + self.end = Vector((sequence.frame_final_start + self.duration, self.max_value)) + elif type == "OUT": + self.start = Vector((sequence.frame_final_end - self.duration, self.max_value)) + self.end = Vector((sequence.frame_final_end, 0.0)) + + def calculate_max_value(self, sequence, fade_fcurve): + """ + Returns the maximum Y coordinate the fade animation should use for a given sequence + Uses either the sequence's value for the animated property, or the next keyframe after the fade + """ + max_value = 0.0 + + if not fade_fcurve.keyframe_points: + max_value = getattr(sequence, self.animated_property, 1.0) + else: + if self.type == "IN": + fade_end = sequence.frame_final_start + self.duration + keyframes = (k for k in fade_fcurve.keyframe_points if k.co[0] >= fade_end) + if self.type == "OUT": + fade_start = sequence.frame_final_end - self.duration + keyframes = ( + k for k in reversed(fade_fcurve.keyframe_points) if k.co[0] <= fade_start + ) + try: + max_value = next(keyframes).co[1] + except StopIteration: + pass + + return max_value if max_value > 0.0 else 1.0 + + def __repr__(self): + return "Fade {}: {} to {}".format(self.type, self.start, self.end) + + +def calculate_duration_frames(context, duration_seconds): + return round(duration_seconds * context.scene.render.fps / context.scene.render.fps_base) diff --git a/power_sequencer/operators/fade_clear.py b/power_sequencer/operators/fade_clear.py new file mode 100644 index 00000000..d7abb4d7 --- /dev/null +++ b/power_sequencer/operators/fade_clear.py @@ -0,0 +1,64 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description +from .utils.global_settings import SequenceTypes + + +class POWER_SEQUENCER_OT_fade_clear(bpy.types.Operator): + """ + *brief* Removes fade animation from selected sequences. + + Removes opacity or volume animation on selected sequences and resets the + property to a value of 1.0. Works on all types of sequences. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "F", "value": "PRESS", "alt": True, "ctrl": True}, {}, "Clear Fades") + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + fcurves = context.scene.animation_data.action.fcurves + + for sequence in context.selected_sequences: + animated_property = "volume" if hasattr(sequence, "volume") else "blend_alpha" + for curve in fcurves: + if not curve.data_path.endswith(animated_property): + continue + # Ensure the fcurve corresponds to the selected sequence + if sequence == eval( + "bpy.context.scene." + curve.data_path.replace("." + animated_property, "") + ): + fcurves.remove(curve) + setattr(sequence, animated_property, 1.0) + + return {"FINISHED"} diff --git a/power_sequencer/operators/gap_remove.py b/power_sequencer/operators/gap_remove.py new file mode 100644 index 00000000..8ef00b6b --- /dev/null +++ b/power_sequencer/operators/gap_remove.py @@ -0,0 +1,135 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from operator import attrgetter + +from .utils.functions import slice_selection, sequencer_workaround_2_80_audio_bug +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_gap_remove(bpy.types.Operator): + """ + Remove gaps, starting from the first frame, with the ability to ignore locked strips + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + ignore_locked: bpy.props.BoolProperty( + name="Ignore Locked Strips", + description="Remove gaps without moving locked strips", + default=True, + ) + all: bpy.props.BoolProperty( + name="Remove All", + description="Remove all gaps starting from the time cursor", + default=False, + ) + frame: bpy.props.IntProperty( + name="Frame", + description="Frame to remove gaps from, defaults at the time cursor", + default=-1, + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + frame = self.frame if self.frame >= 0 else context.scene.frame_current + sequences = ( + [s for s in context.sequences if not s.lock] + if self.ignore_locked + else context.sequences + ) + sequences = [ + s for s in sequences if s.frame_final_start >= frame or s.frame_final_end > frame + ] + sequence_blocks = slice_selection(context, sequences) + if not sequence_blocks: + return {"FINISHED"} + + gap_frame = self.find_gap_frame(context, frame, sequence_blocks[0]) + if gap_frame == -1: + return {"FINISHED"} + + first_block_start = min( + sequence_blocks[0], key=attrgetter("frame_final_start") + ).frame_final_start + blocks_after_gap = ( + sequence_blocks[1:] if first_block_start <= gap_frame else sequence_blocks + ) + + self.gaps_remove(context, blocks_after_gap, gap_frame) + sequencer_workaround_2_80_audio_bug(context) + return {"FINISHED"} + + def find_gap_frame(self, context, frame, sorted_sequences): + """ + Takes a list sequences sorted by frame_final_start + """ + strips_start = min(sorted_sequences, key=attrgetter("frame_final_start")).frame_final_start + strips_end = max(sorted_sequences, key=attrgetter("frame_final_end")).frame_final_end + + gap_frame = -1 + if strips_start > frame: + strips_before_frame_start = [s for s in context.sequences if s.frame_final_end <= frame] + frame_target = 0 + if strips_before_frame_start: + frame_target = max( + strips_before_frame_start, key=attrgetter("frame_final_end") + ).frame_final_end + gap_frame = frame_target if frame_target < strips_start else frame + else: + gap_frame = strips_end + return gap_frame + + def gaps_remove(self, context, sequence_blocks, gap_frame_start): + """ + Recursively removes gaps between blocks of sequences + """ + + gap_frame = gap_frame_start + for block in sequence_blocks: + gap_size = block[0].frame_final_start - gap_frame + if gap_size < 1: + continue + + for s in block: + try: + s.frame_start -= gap_size + except AttributeError: + continue + + self.move_markers(context, gap_frame, gap_size) + if not self.all: + break + gap_frame = block[-1].frame_final_end + + def move_markers(self, context, gap_frame, gap_size): + markers = (m for m in context.scene.timeline_markers if m.frame > gap_frame) + for m in markers: + m.frame -= min({gap_size, m.frame - gap_frame}) diff --git a/power_sequencer/operators/grab.py b/power_sequencer/operators/grab.py new file mode 100644 index 00000000..af8109ff --- /dev/null +++ b/power_sequencer/operators/grab.py @@ -0,0 +1,66 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_mouse_frame_and_channel +from .utils.global_settings import SequenceTypes +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_grab(bpy.types.Operator): + """ + *brief* Grab and move sequences. Extends Blender's built-in grab tool + + + Grab and move sequences. If you have no strips selected, it automatically + finds the strip closest to the mouse and selects it. If you only select + one or multiple crossfades, selects the handles on either side of the + crossfades before moving sequences, using POWER_SEQUENCER_OT_crossfade_edit + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "G", "value": "PRESS"}, {}, "")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def invoke(self, context, event): + frame, channel = get_mouse_frame_and_channel(context, event) + if not context.selected_sequences: + bpy.ops.power_sequencer.select_closest_to_mouse(frame=frame, channel=channel) + return self.execute(context) + + def execute(self, context): + if len(context.selected_sequences) == 0: + return {'FINISHED'} + + strip = context.selected_sequences[0] + if len(context.selected_sequences) == 1 and strip.type in SequenceTypes.TRANSITION: + context.scene.sequence_editor.active_strip = strip + return bpy.ops.power_sequencer.crossfade_edit() + else: + return bpy.ops.transform.seq_slide('INVOKE_DEFAULT') diff --git a/power_sequencer/operators/grab_closest_handle_or_cut.py b/power_sequencer/operators/grab_closest_handle_or_cut.py new file mode 100644 index 00000000..7d772d79 --- /dev/null +++ b/power_sequencer/operators/grab_closest_handle_or_cut.py @@ -0,0 +1,110 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +""" +Selects and grabs the strip handle or cut closest to the mouse cursor. +Hover near a cut and use this operator to slide it. +""" +import bpy + +from math import floor + +from .utils.functions import calculate_distance +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_grab_closest_cut(bpy.types.Operator): + """ + *brief* Grab the handles that form the closest cut + + + Selects and grabs the strip handle or cut closest to the mouse cursor. + Hover near a cut and fire this tool to slide it. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "G", "value": "PRESS", "shift": True, "alt": True}, + {}, + "Grab closest handle or cut", + ) + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + select_linked: bpy.props.BoolProperty( + name="Select Linked", description="Select strips that are linked in time", default=True + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + sequencer = bpy.ops.sequencer + + mouse_x, mouse_y = event.mouse_region_x, event.mouse_region_y + frame, channel = self.find_cut_closest_to_mouse(context, mouse_x, mouse_y) + + matching_strips = [ + s + for s in context.sequences + if (abs(s.frame_final_start - frame) <= 1 or abs(s.frame_final_end - frame) <= 1) + ] + if not self.select_linked: + matching_strips = [s for s in matching_strips if s.channel == channel] + sequencer.select_all(action="DESELECT") + for s in matching_strips: + s.select = True + return bpy.ops.power_sequencer.grab_sequence_handles(frame=frame) + + def find_cut_closest_to_mouse(self, context, mouse_x, mouse_y): + """ + Takes the mouse's coordinates in the sequencer area and returns the two + strips who share the cut closest to the mouse. Use it to find the + handle(s) to select with the grab on the fly operator + """ + view2d = context.region.view2d + + closest_cut = (None, None) + distance_to_closest_cut = 1000000.0 + + for s in context.sequences: + channel_offset = s.channel + 0.5 + start_x, start_y = view2d.view_to_region(s.frame_final_start, channel_offset) + end_x, end_y = view2d.view_to_region(s.frame_final_start, channel_offset) + + distance_to_start = calculate_distance(start_x, start_y, mouse_x, mouse_y) + distance_to_end = calculate_distance(end_x, end_y, mouse_x, mouse_y) + + if distance_to_start < distance_to_closest_cut: + closest_cut = (start_x, start_y) + distance_to_closest_cut = distance_to_start + if distance_to_end < distance_to_closest_cut: + closest_cut = (end_x, end_y) + distance_to_closest_cut = distance_to_end + + closest_cut_local_coords = view2d.region_to_view(closest_cut[0], closest_cut[1]) + frame, channel = (round(closest_cut_local_coords[0]), floor(closest_cut_local_coords[1])) + return frame, channel diff --git a/power_sequencer/operators/grab_sequence_handles.py b/power_sequencer/operators/grab_sequence_handles.py new file mode 100644 index 00000000..e2a38ed1 --- /dev/null +++ b/power_sequencer/operators/grab_sequence_handles.py @@ -0,0 +1,88 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.global_settings import SequenceTypes +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_grab_sequence_handles(bpy.types.Operator): + """ + *brief* Grabs the sequence's handle based on the mouse position + + + Extends the sequence based on the mouse position. If the cursor is to the + right of the sequence's middle, it moves the right handle. If it's on the + left side, it moves the left handle. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "G", "value": "PRESS", "shift": True}, {}, "Grab sequence handles") + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + always_find_closest: bpy.props.BoolProperty(name="Always find closest", default=False) + frame: bpy.props.IntProperty(name="Frame", default=-1, options={"HIDDEN"}) + channel: bpy.props.IntProperty(name="Channel", default=-1, options={"HIDDEN"}) + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + self.frame, self.channel = context.region.view2d.region_to_view( + x=event.mouse_region_x, y=event.mouse_region_y + ) + return self.execute(context) + + def execute(self, context): + selection = context.selected_sequences + if self.always_find_closest or not selection: + if self.frame == -1: + return {"CANCELLED"} + bpy.ops.power_sequencer.select_closest_to_mouse(frame=self.frame, channel=self.channel) + for s in context.selected_sequences: + self.select_closest_handle(s) + else: + bpy.ops.sequencer.select_all(action="DESELECT") + for s in selection: + if s.type in SequenceTypes.EFFECT and not s.type == "COLOR": + self.select_closest_handle(s.input_1) + try: + self.select_closest_handle(s.input_2) + except AttributeError: + pass + else: + self.select_closest_handle(s) + return bpy.ops.transform.seq_slide("INVOKE_DEFAULT") + + def select_closest_handle(self, sequence): + middle = sequence.frame_final_start + sequence.frame_final_duration / 2 + if self.frame >= middle: + sequence.select_right_handle = True + else: + sequence.select_left_handle = True + sequence.select = True diff --git a/power_sequencer/operators/import_local_footage.py b/power_sequencer/operators/import_local_footage.py new file mode 100644 index 00000000..8c8ca1ec --- /dev/null +++ b/power_sequencer/operators/import_local_footage.py @@ -0,0 +1,268 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import json +import os +import re +from operator import attrgetter + +import bpy + +from .utils.functions import convert_duration_to_frames +from .utils.doc import doc_brief, doc_description, doc_idname, doc_name +from ..addon_preferences import get_preferences +from .utils.global_settings import ( + Extensions, + EXTENSIONS_ALL, + EXTENSIONS_AUDIO, + EXTENSIONS_IMG, + EXTENSIONS_VIDEO, +) + + +class POWER_SEQUENCER_OT_import_local_footage(bpy.types.Operator): + """*brief* Imports video, images, and audio from the project folder + + Finds and imports all valid video, audio files, and pictures in the blend file's folder and + sub-folders, ignoring folders named BL_proxy. + + If you set it in the add-on preferences, it also sets imported sequences to use proxies. See + `Preferences -> Add-ons -> Blender Power Sequencer -> Proxy` + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "I", "value": "PRESS", "ctrl": True, "shift": True}, + {"keep_audio": True}, + "Import Local Footage", + ) + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + keep_audio: bpy.props.BoolProperty( + name="Keep Audio from Video Files", + description=("If False, the audio that comes with video files will" " not be imported"), + default=True, + ) + img_length: bpy.props.FloatProperty( + name="Image strip Length", + description="Controls the duration of the imported image strip in seconds", + default=3.0, + min=1.0, + ) + img_padding: bpy.props.FloatProperty( + name="Image Padding", + description="Padding added between imported image strips in seconds", + default=1.0, + min=0.0, + ) + + sequencer_area = None + directory = "" + + @classmethod + def poll(cls, context): + return bpy.data.is_saved + + def execute(self, context): + self.sequencer_area = self.get_sequencer_area(context) + self.directory = os.path.split(bpy.data.filepath)[0] + + filepaths = self.find_local_footage_files() + files_to_import = [os.path.join(self.directory, f) for f in self.find_new_files_to_import(filepaths)] + print(files_to_import) + if not files_to_import: + self.report({"INFO"}, "No new files to import found") + return {"FINISHED"} + + bpy.ops.screen.animation_cancel(restore_frame=True) + + audio = self.import_audios( + context, [f for f in files_to_import if f.lower().endswith(EXTENSIONS_AUDIO)] + ) + video = self.import_videos( + context, [f for f in files_to_import if f.lower().endswith(EXTENSIONS_VIDEO)] + ) + img = self.import_imgs( + context, [f for f in files_to_import if f.lower().endswith(EXTENSIONS_IMG)] + ) + + bpy.data.texts["POWER_SEQUENCER_IMPORTS"].from_string(json.dumps(filepaths)) + + for s in audio: + s.show_waveform = True + + imported = audio + video + img + for s in imported: + s.select = True + self.set_selected_strips_proxies(context) + self.report({"INFO"}, "Imported {!s} strips from newly found files.".format(len(imported))) + return {"FINISHED"} + + def get_sequencer_area(self, context): + """ + Returns the sequencer area to use as a context override in + some operators + """ + sequencer_area = None + for window in context.window_manager.windows: + for area in window.screen.areas: + if not area.type == "SEQUENCE_EDITOR": + continue + sequencer_area = { + "window": window, + "screen": window.screen, + "area": area, + "scene": context.scene, + } + return sequencer_area + + def find_local_footage_files(self, ignored_directories=["BL_proxy"]): + """ + Returns a list of relative filepaths in all subdirectories of the `self.directory` + for all valid files that can be imported in the Sequencer + """ + files_list = [] + for root, dirs, files in os.walk(self.directory): + for directory in ignored_directories: + if directory in dirs: + dirs.remove(directory) + + files = [f for f in sorted(files) if f.lower().endswith(EXTENSIONS_ALL)] + files = map(lambda name: os.path.join(root, name), files) + files = map(lambda path: os.path.relpath(path, root), files) + files_list.extend(list(files)) + + return files_list + + def create_import_text_block(self, name): + """ + Creates a new text data block that contains an empty json list, renames it to `name` and + returns it + """ + re_text = re.compile(r"^Text.[0-9]{3}$") + + bpy.ops.text.new() + + # Find the newly created text file's identifier + ids = [text.name for text in bpy.data.texts if text.name.startswith("Text")] + id = max(ids) + + text_file = bpy.data.texts[id] + text_file.name = name + text_file.from_string("[]") + return text_file + + def find_new_files_to_import(self, filepaths): + """ + Gets and optionally creates the list of already imported files in this project + Returns a list of paths from filepaths that are not in the imported text file + """ + text_file = ( + bpy.data.texts.get("POWER_SEQUENCER_IMPORTS") + if "POWER_SEQUENCER_IMPORTS" in bpy.data.texts.keys() + else self.create_import_text_block("POWER_SEQUENCER_IMPORTS") + ) + imported_files = json.loads(text_file.as_string()) + files_to_import = [p for p in filepaths if p not in imported_files] + return files_to_import + + def import_videos(self, context, videos_filepaths): + """ + Imports a list of files using movie_strip_add + Returns the list of imported sequences + """ + frame = context.scene.frame_current + + context.window_manager.progress_begin(0, len(videos_filepaths)) + imported = [] + for index, f in enumerate(videos_filepaths): + is_first_import = index == 0 + bpy.ops.sequencer.movie_strip_add( + self.sequencer_area, + filepath=f, + frame_start=frame, + sound=self.keep_audio, + use_framerate=is_first_import, + ) + imported.extend(context.selected_sequences) + frame = context.selected_sequences[0].frame_final_end + context.window_manager.progress_update(index) + + context.window_manager.progress_end() + return imported + + def import_audios(self, context, audio_filepaths): + """ + Imports audio files as sound strips from a list of absolute file paths + Returns the list of newly imported audio files + """ + frame = context.scene.frame_current + imported = [] + for f in audio_filepaths: + bpy.ops.sequencer.sound_strip_add(self.sequencer_area, filepath=f, frame_start=frame) + imported.extend(context.selected_sequences) + frame = context.selected_sequences[0].frame_final_end + return imported + + def import_imgs(self, context, img_filepaths): + frame = context.scene.frame_current + strip_length = convert_duration_to_frames(context, self.img_length) + strip_padding = convert_duration_to_frames(context, self.img_padding) + + new_sequences = [] + for f in img_filepaths: + head, tail = os.path.split(f) + bpy.ops.sequencer.image_strip_add( + self.sequencer_area, + directory=head, + files=[{"name": tail}], + frame_start=frame, + frame_end=frame + strip_length, + ) + frame += strip_length + strip_padding + new_sequences.extend(context.selected_sequences) + + return new_sequences + + def set_selected_strips_proxies(self, context): + proxy_sizes = ["25", "50", "75", "100"] + + use_proxy = False + prefs = get_preferences(context) + for size in proxy_sizes: + if hasattr(prefs, "proxy_" + size): + use_proxy = True + break + + if not use_proxy: + return + + for s in [s for s in context.selected_sequences if s.type in ["MOVIE", "IMAGE"]]: + s.use_proxy = True + s.proxy.build_25 = prefs.proxy_25 + s.proxy.build_50 = prefs.proxy_50 + s.proxy.build_75 = prefs.proxy_75 + s.proxy.build_100 = prefs.proxy_100 diff --git a/power_sequencer/operators/jump_time_offset.py b/power_sequencer/operators/jump_time_offset.py new file mode 100644 index 00000000..bc72e98e --- /dev/null +++ b/power_sequencer/operators/jump_time_offset.py @@ -0,0 +1,82 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import convert_duration_to_frames +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_jump_time_offset(bpy.types.Operator): + """ + *brief* Jump forward or backward in time + + + Move the time cursor forward or backward, using a duration in seconds. + + The equivalent tool in Blender only works with frames, meaning the jump + will be different if your project's framerate is different. This tool + fixes that issue. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "RIGHT_ARROW", "value": "PRESS", "shift": True}, + {"direction": "forward"}, + "Jump Forward", + ), + ( + {"type": "LEFT_ARROW", "value": "PRESS", "shift": True}, + {"direction": "backward"}, + "Jump Backward", + ), + ], + "keymap": "Frames", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER"} + + duration: bpy.props.FloatProperty( + name="Duration", + description="The length of the jump in seconds (default: 1.0)", + default=1.0, + min=0, + ) + direction: bpy.props.EnumProperty( + name="Direction", + description="Jump direction, either forward or backward", + items=[ + ("forward", "Forward", "Jump forward in time"), + ("backward", "Backward", "Jump backward in time"), + ], + ) + + @classmethod + def poll(cls, context): + return context.scene + + def execute(self, context): + direction = 1 if self.direction == "forward" else -1 + context.scene.frame_current += ( + convert_duration_to_frames(context, self.duration) * direction + ) + return {"FINISHED"} diff --git a/power_sequencer/operators/jump_to_cut.py b/power_sequencer/operators/jump_to_cut.py new file mode 100644 index 00000000..8de9224a --- /dev/null +++ b/power_sequencer/operators/jump_to_cut.py @@ -0,0 +1,119 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from operator import attrgetter + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_jump_to_cut(bpy.types.Operator): + """ + *brief* Jump to next/previous cut + + + Jump to the next or the previous cut in the edit. Unlike Blender's default tool, also + works during playback. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "UP_ARROW", "value": "PRESS"}, + {"direction": "RIGHT"}, + "Jump to next cut or keyframe", + ), + ( + {"type": "DOWN_ARROW", "value": "PRESS"}, + {"direction": "LEFT"}, + "Jump to previous cut or keyframe", + ), + ], + "keymap": "Frames", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + direction: bpy.props.EnumProperty( + name="Direction", + description="Jump direction, either forward or backward", + items=[ + ("RIGHT", "Forward", "Jump forward in time"), + ("LEFT", "Backward", "Jump backward in time"), + ], + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + frame_current = context.scene.frame_current + sorted_sequences = sorted( + context.sequences, key=attrgetter("frame_final_start", "frame_final_end") + ) + + fcurves = [] + animation_data = context.scene.animation_data + if animation_data and animation_data.action: + fcurves = animation_data.action.fcurves + + frame_target = -1 + if self.direction == "RIGHT": + sequences = [s for s in sorted_sequences if s.frame_final_end > frame_current] + for s in sequences: + + frame_target = ( + s.frame_final_end + if s.frame_final_start <= frame_current + else s.frame_final_start + ) + + for f in fcurves: + for k in f.keyframe_points: + frame = k.co[0] + if frame <= context.scene.frame_current: + continue + frame_target = min(frame_target, frame) + break + + elif self.direction == "LEFT": + sequences = [ + s for s in reversed(sorted_sequences) if s.frame_final_start < frame_current + ] + for s in sequences: + + frame_target = ( + s.frame_final_start if s.frame_final_end >= frame_current else s.frame_final_end + ) + + for f in fcurves: + for k in f.keyframe_points: + frame = k.co[0] + if frame >= context.scene.frame_current: + continue + frame_target = max(frame_target, frame) + break + + if frame_target != -1: + context.scene.frame_current = max(1, frame_target) + + return {"FINISHED"} diff --git a/power_sequencer/operators/make_still_image.py b/power_sequencer/operators/make_still_image.py new file mode 100644 index 00000000..73a3ed66 --- /dev/null +++ b/power_sequencer/operators/make_still_image.py @@ -0,0 +1,121 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import operator + +from .utils.global_settings import SequenceTypes +from .utils.functions import convert_duration_to_frames +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_make_still_image(bpy.types.Operator): + """ + *brief* Make still image from active strip + + + Converts image under the cursor to a still image, to create a pause effect in the video, + using the active sequence + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + strip_duration: bpy.props.FloatProperty( + name="Strip Duration", + description="The duration in seconds of the new strip, if 0.0 it will use the gap as its duration", + default=0.0, + min=0.0, + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def invoke(self, context, event): + window_manager = context.window_manager + return window_manager.invoke_props_dialog(self) + + def execute(self, context): + scene = context.scene + active = scene.sequence_editor.active_strip + sequencer = bpy.ops.sequencer + transform = bpy.ops.transform + + start_frame = scene.frame_current + offset = convert_duration_to_frames(context, self.strip_duration) + + if active.type not in SequenceTypes.VIDEO: + self.report( + {"ERROR_INVALID_INPUT"}, + "You must select a video or meta strip. \ + You selected a strip of type" + + str(active.type) + + " instead.", + ) + return {"CANCELLED"} + + if not active.frame_final_start <= start_frame < active.frame_final_end: + self.report( + {"ERROR_INVALID_INPUT"}, + "Your time cursor must be on the frame you want \ + to convert to a still image.", + ) + return {"CANCELLED"} + + if start_frame == active.frame_final_start: + scene.frame_current = start_frame + 1 + + if self.strip_duration <= 0.0: + strips = sorted( + scene.sequence_editor.sequences, key=operator.attrgetter("frame_final_start") + ) + + for s in strips: + if s.frame_final_start > active.frame_final_start and s.channel == active.channel: + next = s + break + offset = next.frame_final_start - active.frame_final_end + + active.select = True + source_blend_type = active.blend_type + sequencer.cut(frame=scene.frame_current, type="SOFT", side="RIGHT") + transform.seq_slide(value=(offset, 0)) + sequencer.cut(frame=scene.frame_current + offset + 1, type="SOFT", side="LEFT") + transform.seq_slide(value=(-offset, 0)) + + sequencer.meta_make() + active = scene.sequence_editor.active_strip + active.name = "Still image" + active.blend_type = source_blend_type + active.select_right_handle = True + transform.seq_slide(value=(offset, 0)) + + scene.frame_current = start_frame + + active.select = True + active.select_right_handle = False + active.select_left_handle = False + return {"FINISHED"} diff --git a/power_sequencer/operators/marker_delete_closest.py b/power_sequencer/operators/marker_delete_closest.py new file mode 100644 index 00000000..e5c8d2c0 --- /dev/null +++ b/power_sequencer/operators/marker_delete_closest.py @@ -0,0 +1,49 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_marker_delete_closest(bpy.types.Operator): + """ + Deletes the marker closest to the time cursor + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Markers", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.scene.timeline_markers + + def invoke(self, context, event): + markers = context.scene.timeline_markers + frame = context.scene.frame_current + + closest_marker = min(markers, key=lambda marker: abs(frame - marker.frame)) + markers.remove(closest_marker) + return {"FINISHED"} diff --git a/power_sequencer/operators/marker_delete_direct.py b/power_sequencer/operators/marker_delete_direct.py new file mode 100644 index 00000000..153e494e --- /dev/null +++ b/power_sequencer/operators/marker_delete_direct.py @@ -0,0 +1,50 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_marker_delete_direct(bpy.types.Operator): + """ + Delete selected markers instantly skipping the default confirmation prompt + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "X", "value": "PRESS"}, {}, "Delete Markers Instantly")], + "keymap": "Markers", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.scene.timeline_markers + + def execute(self, context): + markers = context.scene.timeline_markers + + selected_markers = [m for m in markers if m.select] + for m in selected_markers: + markers.remove(m) + self.report({"INFO"}, "Deleted %s markers." % len(selected_markers)) + return {"FINISHED"} diff --git a/power_sequencer/operators/marker_go_to_next.py b/power_sequencer/operators/marker_go_to_next.py new file mode 100644 index 00000000..014091e5 --- /dev/null +++ b/power_sequencer/operators/marker_go_to_next.py @@ -0,0 +1,74 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import find_neighboring_markers +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_marker_go_to_next(bpy.types.Operator): + """ + Moves the time cursor to the next marker + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + target_marker: bpy.props.EnumProperty( + items=[("left", "left", "left"), ("right", "right", "right")], + name="Target marker", + description="Move to the closest marker to the left or to the right of the cursor", + default="left", + ) + + @classmethod + def poll(cls, context): + return context.scene + + def execute(self, context): + if not context.scene.timeline_markers: + self.report({"ERROR_INVALID_INPUT"}, "There are no markers. Operation cancelled.") + return {"CANCELLED"} + + frame = context.scene.frame_current + previous_marker, next_marker = find_neighboring_markers(context, frame) + + if ( + not previous_marker + and self.target_marker == "left" + or not next_marker + and self.target_marker == "right" + ): + self.report({"INFO"}, "No more markers to jump to on the %s side." % self.target_marker) + return {"CANCELLED"} + + previous_time = previous_marker.frame if previous_marker else None + next_time = next_marker.frame if next_marker else None + + context.scene.frame_current = ( + previous_time if self.target_marker == "left" or not next_time else next_time + ) + return {"FINISHED"} diff --git a/power_sequencer/operators/marker_snap_to_cursor.py b/power_sequencer/operators/marker_snap_to_cursor.py new file mode 100644 index 00000000..aa480761 --- /dev/null +++ b/power_sequencer/operators/marker_snap_to_cursor.py @@ -0,0 +1,61 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_marker_snap_to_cursor(bpy.types.Operator): + """ + Snap selected marker to the time cursor + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.scene.timeline_markers + + def execute(self, context): + markers = context.scene.timeline_markers + + selected_markers = [] + for marker in markers: + if marker.select: + selected_markers.append(marker) + + if not selected_markers: + return {"CANCELLED"} + if len(selected_markers) > 1: + self.report( + {"ERROR_INVALID_INPUT"}, + "You can only snap 1 marker at a time. Operation cancelled.", + ) + return {"CANCELLED"} + + selected_markers[0].frame = context.scene.frame_current + return {"FINISHED"} diff --git a/power_sequencer/operators/markers_as_timecodes.py b/power_sequencer/operators/markers_as_timecodes.py new file mode 100644 index 00000000..642d6118 --- /dev/null +++ b/power_sequencer/operators/markers_as_timecodes.py @@ -0,0 +1,67 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import datetime as dt +import operator as op + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_copy_markers_as_timecodes(bpy.types.Operator): + """ + Formats and copies all the markers as timecodes to put in a Youtube video's description + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + + @classmethod + def poll(cls, context): + return context.scene.timeline_markers + + def execute(self, context): + render = context.scene.render + + if len(context.scene.timeline_markers) == 0: + self.report({"INFO"}, "No markers found") + return {"CANCELLED"} + + sorted_markers = sorted(context.scene.timeline_markers, + key=lambda m: m.frame) + + framerate = render.fps / render.fps_base + last_marker_seconds = sorted_markers[-1].frame / framerate + seconds_in_hour = 3600.0 + time_format = "%H:%M:%S" if last_marker_seconds >= seconds_in_hour else "%M:%S" + + markers_as_timecodes = [] + for marker in sorted_markers: + time = dt.datetime(year=1, month=1, day=1) + dt.timedelta( + seconds=marker.frame / framerate) + markers_as_timecodes.append( + time.strftime(time_format) + " " + marker.name) + + bpy.context.window_manager.clipboard = "\n".join(markers_as_timecodes) + return {"FINISHED"} diff --git a/power_sequencer/operators/markers_create_from_selected.py b/power_sequencer/operators/markers_create_from_selected.py new file mode 100644 index 00000000..94ebafc9 --- /dev/null +++ b/power_sequencer/operators/markers_create_from_selected.py @@ -0,0 +1,59 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_markers_create_from_selected_strips(bpy.types.Operator): + """ + *brief* Create one marker at the start on each selected strip, based on its name + + Use it to copy markers as timecodes. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + cursor_frame_start = context.scene.frame_current + + for m in context.scene.timeline_markers: + m.select = False + + for s in context.selected_sequences: + bpy.ops.marker.add() + new_marker = context.scene.timeline_markers[-1] + + new_marker.select = True + bpy.ops.marker.rename(name=s.name) + gap = s.frame_final_start - cursor_frame_start + bpy.ops.marker.move(frames=gap) + new_marker.select = False + return {"FINISHED"} diff --git a/power_sequencer/operators/markers_set_preview_in_between.py b/power_sequencer/operators/markers_set_preview_in_between.py new file mode 100644 index 00000000..f505c278 --- /dev/null +++ b/power_sequencer/operators/markers_set_preview_in_between.py @@ -0,0 +1,69 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import find_neighboring_markers +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_set_preview_between_markers(bpy.types.Operator): + """ + Set the timeline's preview range using the 2 markers closest to the time cursor + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor + + def invoke(self, context, event): + if not context.scene.timeline_markers: + self.report({"ERROR_INVALID_INPUT"}, "There are no markers. Operation cancelled.") + return {"CANCELLED"} + + frame = context.scene.frame_current + previous_marker, next_marker = find_neighboring_markers(context, frame) + + if not (previous_marker and next_marker): + self.report({"ERROR_INVALID_INPUT"}, "There are no markers. Operation cancelled.") + return {"CANCELLED"} + + frame_start = previous_marker.frame if previous_marker else 0 + if next_marker: + frame_end = next_marker.frame + else: + from operator import attrgetter + + frame_end = max( + context.scene.sequence_editor.sequences, key=attrgetter("frame_final_end") + ).frame_final_end + + from .utils.functions import set_preview_range + + set_preview_range(context, frame_start, frame_end) + return {"FINISHED"} diff --git a/power_sequencer/operators/markers_snap_matching_strips.py b/power_sequencer/operators/markers_snap_matching_strips.py new file mode 100644 index 00000000..bdfba314 --- /dev/null +++ b/power_sequencer/operators/markers_snap_matching_strips.py @@ -0,0 +1,50 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_markers_snap_matching_strips(bpy.types.Operator): + """ + Snap selected strips to markers with the same name + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.scene.timeline_markers + + def execute(self, context): + timeline_markers = context.scene.timeline_markers + + for strip in context.selected_sequences: + for marker in timeline_markers: + if marker.name in strip.name: + strip.frame_start = marker.frame - strip.frame_offset_start + return {"FINISHED"} diff --git a/power_sequencer/operators/meta_resize_to_content.py b/power_sequencer/operators/meta_resize_to_content.py new file mode 100644 index 00000000..0697104e --- /dev/null +++ b/power_sequencer/operators/meta_resize_to_content.py @@ -0,0 +1,51 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from .utils.functions import get_frame_range + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_meta_resize_to_content(bpy.types.Operator): + """ + *brief* Moves the handles of the selected metastrip so it fits its content + + + Use it to trim a metastrip quickly + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + selected_meta_strips = (s for s in context.selected_sequences if s.type == "META") + for s in selected_meta_strips: + s.frame_final_start, s.frame_final_end = get_frame_range(context, s.sequences) + return {"FINISHED"} diff --git a/power_sequencer/operators/meta_trim_content_to_bounds.py b/power_sequencer/operators/meta_trim_content_to_bounds.py new file mode 100644 index 00000000..0e77681b --- /dev/null +++ b/power_sequencer/operators/meta_trim_content_to_bounds.py @@ -0,0 +1,63 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from .utils.global_settings import SequenceTypes + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_meta_trim_content_to_bounds(bpy.types.Operator): + """ + Deletes and trims the strips inside selected meta-strips to the meta strip's bounds + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + to_delete = [] + meta_strips = [s for s in context.selected_sequences if s.type == "META"] + for m in meta_strips: + start, end = m.frame_final_start, m.frame_final_end + sequences_to_process = (s for s in m.sequences if s.type not in SequenceTypes.EFFECT) + for s in sequences_to_process: + if s.frame_final_end < start or s.frame_final_start > m.frame_final_end: + to_delete.append(s) + continue + # trim strips on the meta's edges or longer than the meta's extents + if s.frame_final_start < start: + s.frame_final_start = start + if s.frame_final_end > end: + s.frame_final_end = end + bpy.ops.sequencer.select_all(action="DESELECT") + for s in to_delete: + s.select = True + bpy.ops.sequencer.delete() + return {"FINISHED"} diff --git a/power_sequencer/operators/meta_ungroup_and_trim.py b/power_sequencer/operators/meta_ungroup_and_trim.py new file mode 100644 index 00000000..e639570a --- /dev/null +++ b/power_sequencer/operators/meta_ungroup_and_trim.py @@ -0,0 +1,60 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_meta_ungroup_and_trim(bpy.types.Operator): + """ + UnMeta all selected meta strips and trim their content + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + trim_content: bpy.props.BoolProperty( + name="Trim Content", + description="Trim the content of the Meta Strips to their extents", + default=True, + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + meta_strips = [s for s in context.selected_sequences if s.type == "META"] + if self.trim_content: + bpy.ops.power_sequencer.meta_trim_content_to_bounds() + self.separate(context, meta_strips) + return {"FINISHED"} + + def separate(self, context, meta_strips): + bpy.ops.sequencer.select_all(action="DESELECT") + for m in meta_strips: + context.scene.sequence_editor.active_strip = m + bpy.ops.sequencer.meta_separate() diff --git a/power_sequencer/operators/mouse_toggle_mute.py b/power_sequencer/operators/mouse_toggle_mute.py new file mode 100644 index 00000000..44a1e7d5 --- /dev/null +++ b/power_sequencer/operators/mouse_toggle_mute.py @@ -0,0 +1,64 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +"""Toggle mute a sequence as you click on it""" +import bpy +from math import floor + +from .utils.functions import find_strips_mouse +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_mouse_toggle_mute(bpy.types.Operator): + """ + Toggle mute a sequence as you click on it + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "LEFTMOUSE", "value": "PRESS", "alt": True}, {}, "Mouse Toggle Mute") + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + sequencer = bpy.ops.sequencer + + # get current frame and channel the mouse hovers + x, y = context.region.view2d.region_to_view(x=event.mouse_region_x, y=event.mouse_region_y) + frame, channel = round(x), floor(y) + + # Strip selection + sequencer.select_all(action="DESELECT") + to_select = find_strips_mouse(context, frame, channel) + + if not to_select: + return {"CANCELLED"} + + for s in to_select: + s.mute = not s.mute + return {"FINISHED"} diff --git a/power_sequencer/operators/mouse_trim_instantly.py b/power_sequencer/operators/mouse_trim_instantly.py new file mode 100644 index 00000000..dd2bb1fd --- /dev/null +++ b/power_sequencer/operators/mouse_trim_instantly.py @@ -0,0 +1,113 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from math import floor + +from .utils.functions import find_strips_mouse +from .utils.functions import trim_strips +from .utils.functions import get_frame_range +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description +from .utils.functions import sequencer_workaround_2_80_audio_bug + + +class POWER_SEQUENCER_OT_mouse_trim_instantly(bpy.types.Operator): + """ + *brief* Trim strip from a start to an end frame instantly + + + Trims a frame range or a selection from a start to an end frame. + If there's no precise time range, auto trims based on the closest cut + + Args: + - frame_start and frame_end (int) define the frame range to trim + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "RIGHTMOUSE", "value": "PRESS", "ctrl": True, "alt": True}, + {"select_mode": "CONTEXT"}, + "Trim strip, keep gap", + ), + ( + {"type": "RIGHTMOUSE", "value": "PRESS", "ctrl": True, "alt": True, "shift": True}, + {"select_mode": "CURSOR"}, + "Trim strip, remove gap", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + select_mode: bpy.props.EnumProperty( + items=[ + ("CONTEXT", "Smart", "Uses the selection if possible, else uses the other modes"), + ("CURSOR", "Time cursor", "Select all of the strips the time cursor overlaps"), + ], + name="Selection mode", + description="Auto-select the strip you click on or that the time cursor overlaps", + default="CONTEXT", + ) + select_linked: bpy.props.BoolProperty( + name="Use linked time", + description="If auto-select, cut linked strips if checked", + default=False, + ) + gap_remove: bpy.props.BoolProperty( + name="Remove gaps", + description="When trimming the sequences, remove gaps automatically", + default=True, + ) + + to_select = [] + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + to_select = [] + frame, channel = -1, -1 + x, y = context.region.view2d.region_to_view( + x=event.mouse_region_x, y=event.mouse_region_y + ) + frame, channel = round(x), floor(y) + + mouse_clicked_strip = find_strips_mouse(context, frame, channel, self.select_linked) + to_select.extend(mouse_clicked_strip) + if self.select_mode == "CURSOR": + to_select.extend([s for s in context.sequences if s.frame_final_start <= frame <= s.frame_final_end and not s.lock]) + + frame_cut_closest = min(get_frame_range(context, to_select), key=lambda f: abs(frame - f)) + frame_start = min(frame, frame_cut_closest) + frame_end = max(frame, frame_cut_closest) + + trim_strips(context, frame_start, frame_end, self.select_mode, to_select) + + context.scene.frame_current = frame_start + if self.gap_remove and self.select_mode == "CURSOR": + bpy.ops.power_sequencer.gap_remove() + + # FIXME: Workaround Blender 2.80's audio bug, remove when fixed in Blender + sequencer_workaround_2_80_audio_bug(context) + return {"FINISHED"} diff --git a/power_sequencer/operators/mouse_trim_modal.py b/power_sequencer/operators/mouse_trim_modal.py new file mode 100644 index 00000000..96da5290 --- /dev/null +++ b/power_sequencer/operators/mouse_trim_modal.py @@ -0,0 +1,408 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import bgl +import gpu +from gpu_extras.batch import batch_for_shader +import math +from mathutils import Vector + +from .utils.functions import ( + find_strips_mouse, + trim_strips, + find_snap_candidate, + find_closest_surrounding_cuts, + sequencer_workaround_2_80_audio_bug, +) + +from .utils.draw import ( + draw_line, + draw_rectangle, + draw_triangle_equilateral, + draw_arrow_head, + get_color_gizmo_primary, + get_color_gizmo_secondary, +) +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + +if not bpy.app.background: + SHADER = gpu.shader.from_builtin("2D_UNIFORM_COLOR") + + +class POWER_SEQUENCER_OT_mouse_trim(bpy.types.Operator): + """ + *brief* Cut or Trim strips quickly with the mouse cursor + + + Click somehwere in the Sequencer to insert a cut, click and drag to trim + With this function you can quickly cut and remove a section of strips while keeping or + collapsing the remaining gap. + Press <kbd>Ctrl</kbd> to snap to cuts. + + A [video demo](https://youtu.be/GiLmDhmMVAM?t=1m35s) is available. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/wVvX4ex.gif", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "T", "value": "PRESS"}, + {"select_mode": "CONTEXT", "gap_remove": False}, + "Trim using the mouse cursor", + ), + ( + {"type": "T", "value": "PRESS", "shift": True}, + {"select_mode": "CURSOR", "gap_remove": True}, + "Trim in all channels", + ), + ( + {"type": "T", "value": "PRESS", "shift": True, "alt": True}, + {"select_mode": "CURSOR", "gap_remove": True}, + "Trim in all channels and remove gaps", + ), + ( + {"type": "T", "value": "PRESS", "ctrl": True}, + {"select_mode": "CONTEXT", "gap_remove": False}, + "Trim using the mouse cursor", + ), + ( + {"type": "T", "value": "PRESS", "ctrl": True, "alt": True}, + {"select_mode": "CONTEXT", "gap_remove": True}, + "Trim using the mouse cursor and remove gaps", + ), + ( + {"type": "T", "value": "PRESS", "ctrl": True, "shift": True}, + {"select_mode": "CURSOR", "gap_remove": True}, + "Trim in all channels", + ), + ( + {"type": "T", "value": "PRESS", "ctrl": True, "shift": True, "alt": True}, + {"select_mode": "CURSOR", "gap_remove": True}, + "Trim in all channels and remove gaps", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + select_mode: bpy.props.EnumProperty( + items=[ + ("CURSOR", "Time cursor", "Select all of the strips the time cursor overlaps"), + ("CONTEXT", "Smart", "Uses the selection if possible, else uses the other modes"), + ], + name="Selection mode", + description="Cut only the strip under the mouse or all strips under the time cursor", + default="CONTEXT", + ) + select_linked: bpy.props.BoolProperty( + name="Use linked time", + description="In mouse or CONTEXT mode, always cut linked strips if this is checked", + default=False, + ) + gap_remove: bpy.props.BoolProperty( + name="Remove gaps", + description="When trimming the sequences, remove gaps automatically", + default=True, + ) + + TABLET_TRIM_DISTANCE_THRESHOLD = 6 + # Don't rename these variables, we're using setattr to access them dynamically + trim_start, channel_start = 0, 0 + trim_end, channel_end = 0, 0 + is_trimming = False + trim_side = "end" + + mouse_start_y = -1.0 + + draw_handler = None + + use_audio_scrub = False + + event_shift_released = True + event_alt_released = True + + event_ripple, event_ripple_string = "LEFT_ALT", "Alt" + event_select_mode, event_select_mode_string = "LEFT_SHIFT", "Shift" + event_change_side = "O" + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + if context.screen.is_animation_playing: + bpy.ops.screen.animation_cancel(restore_frame=False) + + self.use_audio_scrub = context.scene.use_audio_scrub + context.scene.use_audio_scrub = False + + self.mouse_start_y = event.mouse_region_y + + self.trim_initialize(context, event) + self.update_frame(context, event) + self.draw_start(context, event) + self.update_header_text(context, event) + + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + + def modal(self, context, event): + + if event.type == self.event_change_side and event.value == "PRESS": + self.trim_side = "start" if self.trim_side == "end" else "end" + + if event.type == self.event_ripple and event.value == "PRESS": + self.gap_remove = False if self.gap_remove else True + + if event.type == self.event_select_mode and event.value == "PRESS": + self.select_mode = "CONTEXT" if self.select_mode == "CURSOR" else "CURSOR" + + if event.type in {"ESC"}: + self.draw_stop() + context.scene.use_audio_scrub = self.use_audio_scrub + return {"FINISHED"} + + # Start and end trim + if event.type == "LEFTMOUSE" or (event.type in ["RET", "T"] and event.value == "PRESS"): + self.trim_apply(context, event) + self.draw_stop() + context.scene.use_audio_scrub = self.use_audio_scrub + + # FIXME: Workaround Blender 2.80's audio bug, remove when fixed in Blender + sequencer_workaround_2_80_audio_bug(context) + + return {"FINISHED"} + + # Update trim + if event.type == "MOUSEMOVE": + self.draw_stop() + self.update_frame(context, event) + self.draw_start(context, event) + self.update_header_text(context, event) + return {"PASS_THROUGH"} + + return {"RUNNING_MODAL"} + + def trim_initialize(self, context, event): + frame, self.channel_start = get_frame_and_channel(event) + self.trim_start = find_snap_candidate(context, frame) if event.ctrl else frame + self.trim_end, self.channel_end = self.trim_start, self.channel_start + self.is_trimming = True + + def update_frame(self, context, event): + frame, channel = get_frame_and_channel(event) + frame_trim = find_snap_candidate(context, frame) if event.ctrl else frame + setattr(self, "channel_" + self.trim_side, channel) + setattr(self, "trim_" + self.trim_side, frame_trim) + context.scene.frame_current = getattr(self, "trim_" + self.trim_side) + + def draw_start(self, context, event): + """Initializes the drawing handler, see draw()""" + to_select, to_delete = self.find_strips_to_trim(context) + target_strips = to_select + to_delete + + draw_args = (self, context, self.trim_start, self.trim_end, target_strips, self.gap_remove) + self.draw_handler = bpy.types.SpaceSequenceEditor.draw_handler_add( + draw, draw_args, "WINDOW", "POST_PIXEL" + ) + + def draw_stop(self): + if self.draw_handler: + bpy.types.SpaceSequenceEditor.draw_handler_remove(self.draw_handler, "WINDOW") + + def update_header_text(self, context, event): + text = ( + "Trim from {} to {}".format(self.trim_start, self.trim_end) + + ", " + + "({}) Gap Remove {}".format( + self.event_ripple_string, "ON" if self.gap_remove else "OFF" + ) + + ", " + + "({}) Mode: {}".format(self.event_select_mode_string, self.select_mode.capitalize()) + + ", " + + "(Ctrl) Snap: {}".format("ON" if event.ctrl else "OFF") + + ", " + + "({}) Change Side".format(self.event_change_side) + ) + context.area.header_text_set(text) + + def trim_apply(self, context, event): + start_x = context.region.view2d.region_to_view( + x=event.mouse_region_x, y=event.mouse_region_y + )[0] + distance_to_start = abs(event.mouse_region_x - start_x) + + is_cutting = ( + self.trim_start == self.trim_end + or event.is_tablet + and distance_to_start <= self.TABLET_TRIM_DISTANCE_THRESHOLD + ) + if is_cutting: + self.cut(context) + else: + self.trim(context) + self.is_trimming = False + + def cut(self, context): + to_select = self.find_strips_to_cut(context) + bpy.ops.sequencer.select_all(action="DESELECT") + for s in to_select: + s.select = True + + if len(to_select) == 0: + bpy.ops.power_sequencer.gap_remove() + else: + frame_current = context.scene.frame_current + context.scene.frame_current = self.trim_start + bpy.ops.sequencer.cut(frame=context.scene.frame_current, type="SOFT", side="BOTH") + context.scene.frame_current = frame_current + + def find_strips_to_cut(self, context): + """ + Returns a list of strips to cut, either the strip hovered by the mouse or all strips under + the time cursor, depending on the select_mode + """ + to_cut, overlapping_strips = [], [] + if self.select_mode == "CONTEXT": + overlapping_strips = find_strips_mouse( + context, self.trim_start, self.channel_start, self.select_linked + ) + to_cut.extend(overlapping_strips) + if self.select_mode == "CURSOR" or ( + not overlapping_strips and self.select_mode == "CONTEXT" + ): + to_cut = [ + s + for s in context.sequences + if not s.lock and s.frame_final_start <= self.trim_start <= s.frame_final_end + ] + return to_cut + + def trim(self, context): + to_select, to_delete = self.find_strips_to_trim(context) + trim_strips(context, self.trim_start, self.trim_end, self.select_mode, to_select, to_delete) + if (self.gap_remove and self.select_mode == "CURSOR") or ( + self.select_mode == "CONTEXT" and to_select == [] and to_delete == [] + ): + context.scene.frame_current = min(self.trim_start, self.trim_end) + bpy.ops.power_sequencer.gap_remove() + else: + context.scene.frame_current = self.trim_end + + def find_strips_to_trim(self, context): + """ + Returns two lists of strips to trim and strips to delete + """ + to_trim, to_delete = [], [] + + trim_start = min(self.trim_start, self.trim_end) + trim_end = max(self.trim_start, self.trim_end) + + channel_min = min(self.channel_start, self.channel_end) + channel_max = max(self.channel_start, self.channel_end) + channels = set(range(channel_min, channel_max + 1)) + + for s in context.sequences: + if s.lock: + continue + if self.select_mode == "CONTEXT" and s.channel not in channels: + continue + + if trim_start <= s.frame_final_start and trim_end >= s.frame_final_end: + to_delete.append(s) + continue + if ( + s.frame_final_start <= trim_start <= s.frame_final_end + or s.frame_final_start <= trim_end <= s.frame_final_end + ): + to_trim.append(s) + + return to_trim, to_delete + + +def draw(self, context, frame_start=-1, frame_end=-1, target_strips=[], draw_arrows=False): + """ + Draws the line and arrows that represent the trim + + Params: + - start_x and end_x are Vector(), the start_x and end_x of the drawn trim line's vertices in region coordinates + """ + view_to_region = context.region.view2d.view_to_region + + # Detect and draw the gap's limits if not trimming any strips + if not target_strips: + strip_before, strip_after = find_closest_surrounding_cuts(context, frame_end) + frame_start = strip_before.frame_final_end + frame_end = strip_after.frame_final_start + channels = [strip_before.channel, strip_after.channel] + else: + channels = {s.channel for s in target_strips} + + start_x, start_y = view_to_region( + min(frame_start, frame_end), math.floor(min(channels)), clip=False + ) + end_x, end_y = view_to_region( + max(frame_start, frame_end), math.floor(max(channels) + 1), clip=False + ) + + start_x = max(start_x, context.region.x) + start_y = max(start_y, context.region.y) + + end_x = min(end_x, context.region.x + context.region.width) + end_y = min(end_y, context.region.y + context.region.height) + + # Draw + color_line = get_color_gizmo_primary(context) + color_fill = color_line.copy() + color_fill[-1] = 0.3 + + rect_origin = Vector((start_x, start_y)) + rect_size = Vector((end_x - start_x, abs(start_y - end_y))) + + bgl.glEnable(bgl.GL_BLEND) + bgl.glLineWidth(3) + draw_rectangle(SHADER, rect_origin, rect_size, color_fill) + # Vertical lines + draw_line(SHADER, Vector((start_x, start_y)), Vector((start_x, end_y)), color_line) + draw_line(SHADER, Vector((end_x, start_y)), Vector((end_x, end_y)), color_line) + + offset = 20.0 + radius = 12.0 + if draw_arrows and end_x - start_x > 2 * offset + radius: + center_y = (end_y + start_y) / 2.0 + center_1 = Vector((start_x + offset, center_y)) + center_2 = Vector((end_x - offset, center_y)) + draw_triangle_equilateral(SHADER, center_1, radius, color=color_line) + draw_triangle_equilateral(SHADER, center_2, radius, math.pi, color=color_line) + + bgl.glLineWidth(1) + bgl.glDisable(bgl.GL_BLEND) + + +def get_frame_and_channel(event): + """ + Returns a tuple of (frame, channel) + """ + frame_float, channel_float = bpy.context.region.view2d.region_to_view( + x=event.mouse_region_x, y=event.mouse_region_y + ) + return round(frame_float), math.floor(channel_float) diff --git a/power_sequencer/operators/open_project_directory.py b/power_sequencer/operators/open_project_directory.py new file mode 100644 index 00000000..4fc4cf38 --- /dev/null +++ b/power_sequencer/operators/open_project_directory.py @@ -0,0 +1,59 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import os +from platform import system +from subprocess import Popen + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_open_project_directory(bpy.types.Operator): + """ + Opens the Blender project directory in file explorer + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return bpy.data.is_saved + + def execute(self, context): + path = os.path.split(bpy.data.filepath)[0] + + if not path: + self.report({"INFO"}, "You have to save your project first.") + return {"CANCELLED"} + + if system() == "Windows": + Popen(["explorer", path]) + elif system() == "Darwin": + Popen(["open", path]) + else: + Popen(["xdg-open", path]) + return {"FINISHED"} diff --git a/power_sequencer/operators/playback_speed_decrease.py b/power_sequencer/operators/playback_speed_decrease.py new file mode 100644 index 00000000..5d5a9605 --- /dev/null +++ b/power_sequencer/operators/playback_speed_decrease.py @@ -0,0 +1,63 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_playback_speed_decrease(bpy.types.Operator): + """ + *brief* Decrease playback speed incrementally down to normal + + + Playback speed may be set to any of the following speeds: + + * Normal (1x) + * Fast (1.33x) + * Faster (1.66x) + * Double (2x) + * Triple (3x) + + Activating this operator will decrease playback speed through each + of these steps until minimum speed is reached. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "COMMA", "value": "PRESS"}, {}, "Decrease Playback Speed")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + scene = context.scene + + speeds = ["NORMAL", "FAST", "FASTER", "DOUBLE", "TRIPLE"] + playback_speed = scene.power_sequencer.playback_speed + + index = max(0, speeds.index(playback_speed) - 1) + scene.power_sequencer.playback_speed = speeds[index] + + return {"FINISHED"} diff --git a/power_sequencer/operators/playback_speed_increase.py b/power_sequencer/operators/playback_speed_increase.py new file mode 100644 index 00000000..572bd9bc --- /dev/null +++ b/power_sequencer/operators/playback_speed_increase.py @@ -0,0 +1,63 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_playback_speed_increase(bpy.types.Operator): + """ + *brief* Increase playback speed up to triple + + + Playback speed may be set to any of the following speeds: + + * Normal (1x) + * Fast (1.33x) + * Faster (1.66x) + * Double (2x) + * Triple (3x) + + Activating this operator will increase playback speed through each + of these steps until maximum speed is reached. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "PERIOD", "value": "PRESS"}, {}, "Increase playback speed")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + scene = context.scene + + speeds = ["NORMAL", "FAST", "FASTER", "DOUBLE", "TRIPLE"] + playback_speed = scene.power_sequencer.playback_speed + + index = min(speeds.index(playback_speed) + 1, len(speeds) - 1) + scene.power_sequencer.playback_speed = speeds[index] + + return {"FINISHED"} diff --git a/power_sequencer/operators/playback_speed_set.py b/power_sequencer/operators/playback_speed_set.py new file mode 100644 index 00000000..2e21f7b9 --- /dev/null +++ b/power_sequencer/operators/playback_speed_set.py @@ -0,0 +1,68 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_playback_speed_set(bpy.types.Operator): + """ + Change the playback_speed property using an operator property. Used with keymaps + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "NUMPAD_1", "ctrl": True, "value": "PRESS"}, {"speed": "NORMAL"}, "Speed to 1x"), + ({"type": "NUMPAD_2", "ctrl": True, "value": "PRESS"}, {"speed": "FAST"}, "Speed to 1.33x"), + ( + {"type": "NUMPAD_3", "ctrl": True, "value": "PRESS"}, + {"speed": "FASTER"}, + "Speed to 1.66x", + ), + ({"type": "NUMPAD_4", "ctrl": True, "value": "PRESS"}, {"speed": "DOUBLE"}, "Speed to 2x"), + ({"type": "NUMPAD_5", "ctrl": True, "value": "PRESS"}, {"speed": "TRIPLE"}, "Speed to 3x"), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER"} + + speed: bpy.props.EnumProperty( + items=[ + ("NORMAL", "Normal (1x)", ""), + ("FAST", "Fast (1.33x)", ""), + ("FASTER", "Faster (1.66x)", ""), + ("DOUBLE", "Double (2x)", ""), + ("TRIPLE", "Triple (3x)", ""), + ], + name="Speed", + description="Change the playback speed", + default="DOUBLE", + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + context.scene.power_sequencer.playback_speed = self.speed + return {"FINISHED"} diff --git a/power_sequencer/operators/preview_closest_cut.py b/power_sequencer/operators/preview_closest_cut.py new file mode 100644 index 00000000..dccf69e3 --- /dev/null +++ b/power_sequencer/operators/preview_closest_cut.py @@ -0,0 +1,94 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_frame_range +from .utils.functions import set_preview_range +from .utils.functions import convert_duration_to_frames +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_preview_closest_cut(bpy.types.Operator): + """ + *brief* Toggle preview around the closest cut, based on time cursor + + + Finds the closest cut to the time cursor and sets the preview to a small range around that + frame. If the preview matches the range, resets to the full timeline + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "P", "value": "PRESS", "shift": True}, {}, "Preview Last Cut")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + duration: bpy.props.FloatProperty( + name="Preview duration", + description="Total duration of the preview, in seconds", + default=1.0, + min=0.1, + ) + cut_frame_override: bpy.props.IntProperty( + name="Cut Frame Override", + description="Force to preview around this frame", + default=0, + min=0, + options={"HIDDEN"}, + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + scene = context.scene + + preview_center = ( + self.find_closest_cut_frame(context) + if not self.cut_frame_override + else self.cut_frame_override + ) + + duration_frame = convert_duration_to_frames(context, self.duration) + start = preview_center - duration_frame / 2 + end = preview_center + duration_frame / 2 + if not (preview_center > 1 and start > 1): + return {"CANCELLED"} + + if scene.frame_preview_start == start and scene.frame_preview_end == end: + start, end = get_frame_range(context, context.sequences) + set_preview_range(context, start, end) + return {"FINISHED"} + + def find_closest_cut_frame(self, context): + last_distance = 100000 + closest_cut_frame = 0 + for s in context.sequences: + cuts = [s.frame_final_start, s.frame_final_end] + for cut_frame in cuts: + distance_to_cut = abs(cut_frame - context.scene.frame_current) + if distance_to_cut < last_distance: + last_distance = distance_to_cut + closest_cut_frame = cut_frame + return closest_cut_frame diff --git a/power_sequencer/operators/preview_to_selection.py b/power_sequencer/operators/preview_to_selection.py new file mode 100644 index 00000000..20d168dd --- /dev/null +++ b/power_sequencer/operators/preview_to_selection.py @@ -0,0 +1,56 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_frame_range +from .utils.functions import set_preview_range +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_preview_to_selection(bpy.types.Operator): + """ + *brief* Sets the timeline preview range to match the selection + + Sets the scene frame start to the earliest frame start of selected sequences and the scene + frame end to the last frame of selected sequences. + Uses all sequences in the current context if no sequences are selected. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/EV1sUrn.gif", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "P", "value": "PRESS", "ctrl": True, "alt": True}, {}, "Preview To Selection") + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + scene = context.scene + sequences = context.selected_sequences if len(context.selected_sequences) >= 1 else context.sequences + frame_start, frame_end = get_frame_range(context, sequences) + set_preview_range(context, frame_start, frame_end - 1) + return {"FINISHED"} diff --git a/power_sequencer/operators/render_apply_preset.py b/power_sequencer/operators/render_apply_preset.py new file mode 100644 index 00000000..dee19dbd --- /dev/null +++ b/power_sequencer/operators/render_apply_preset.py @@ -0,0 +1,128 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import os + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_render_apply_preset(bpy.types.Operator): + """ + *Brief* Applies a rendering preset to the project + + Sets rendering and encoding settings and an output filename based on a preset. + + Available presets: + + - YouTube: 1080p mp4 video encoded with H264 and AAC for audio, based on YouTube's recommended settings + - Twitter: 720p mp4 video + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "F12", "value": "PRESS", "alt": True}, + {"preset": "youtube"}, + "Apply Youtube Render Preset", + ) + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER"} + + preset: bpy.props.EnumProperty( + items=[ + ( + "youtube", + "youtube", + "Full HD mp4 with AAC audio, following recommendations from Youtube", + ), + ( + "twitter", + "twitter", + "HD ready mp4 with high enough bitrate for Twitter and Facebook", + ), + ], + name="Preset", + description="Preset to use ", + default="youtube", + ) + + name_pattern: bpy.props.EnumProperty( + items=[ + ("folder", "Folder", "Use the folder's name as the exported file name"), + ( + "blender", + "Blender file", + "Use the project's .blend file name as the exported file name", + ), + ("scene", "Current scene", "Use the scene's name as the exported file name"), + ], + name="Filename", + description="Auto name the rendered video after...", + default="blender", + ) + + @classmethod + def poll(cls, context): + return context.scene + + def execute(self, context): + if not bpy.data.is_saved: + self.report({"WARNING"}, "Save your file first") + return {"CANCELLED"} + + script_file = os.path.realpath(__file__) + addon_directory = os.path.dirname(script_file) + + # audio + if context.scene.render.ffmpeg.audio_codec == "NONE": + context.scene.render.ffmpeg.audio_codec = "AAC" + context.scene.render.ffmpeg.audio_bitrate = 192 + + # video + if self.preset == "youtube": + bpy.ops.script.python_file_run( + filepath=os.path.join(addon_directory, "render_presets", "youtube_1080.py") + ) + elif self.preset == "twitter": + bpy.ops.script.python_file_run( + filepath=os.path.join(addon_directory, "render_presets", "twitter_720p.py") + ) + + from os.path import splitext, dirname + + path = bpy.data.filepath + + exported_file_name = "video" + if self.name_pattern == "blender": + exported_file_name = splitext(bpy.path.basename(path))[0] + elif self.name_pattern == "folder": + exported_file_name = dirname(path).rsplit(sep="\\", maxsplit=1)[-1] + elif self.name_pattern == "scene": + exported_file_name = context.scene.name + + context.scene.render.filepath = "//" + exported_file_name + ".mp4" + + self.report({"INFO"}, "Render settings set to the {!s} preset".format(self.preset)) + return {"FINISHED"} diff --git a/power_sequencer/operators/render_presets/twitter_720p.py b/power_sequencer/operators/render_presets/twitter_720p.py new file mode 100644 index 00000000..e7324d96 --- /dev/null +++ b/power_sequencer/operators/render_presets/twitter_720p.py @@ -0,0 +1,47 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +# Resolution +bpy.context.scene.render.resolution_x = 1280 +bpy.context.scene.render.resolution_y = 720 +bpy.context.scene.render.resolution_percentage = 100 +bpy.context.scene.render.pixel_aspect_x = 1 +bpy.context.scene.render.pixel_aspect_y = 1 + +# FFMPEG +bpy.context.scene.render.image_settings.file_format = "FFMPEG" +bpy.context.scene.render.ffmpeg.format = "MPEG4" +bpy.context.scene.render.ffmpeg.codec = "H264" + +bpy.context.scene.render.ffmpeg.constant_rate_factor = "HIGH" +bpy.context.scene.render.ffmpeg.ffmpeg_preset = "MEDIUM" + + +is_ntsc = bpy.context.scene.render.fps != 25 +if is_ntsc: + bpy.context.scene.render.ffmpeg.gopsize = 18 +else: + bpy.context.scene.render.ffmpeg.gopsize = 15 +bpy.context.scene.render.ffmpeg.use_max_b_frames = False + +bpy.context.scene.render.ffmpeg.video_bitrate = 4000 +bpy.context.scene.render.ffmpeg.maxrate = 4000 +bpy.context.scene.render.ffmpeg.minrate = 0 +bpy.context.scene.render.ffmpeg.buffersize = 224 * 8 +bpy.context.scene.render.ffmpeg.packetsize = 2048 +bpy.context.scene.render.ffmpeg.muxrate = 10080000 diff --git a/power_sequencer/operators/render_presets/youtube_1080.py b/power_sequencer/operators/render_presets/youtube_1080.py new file mode 100644 index 00000000..9aa98830 --- /dev/null +++ b/power_sequencer/operators/render_presets/youtube_1080.py @@ -0,0 +1,47 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +# Resolution +bpy.context.scene.render.resolution_x = 1920 +bpy.context.scene.render.resolution_y = 1080 +bpy.context.scene.render.resolution_percentage = 100 +bpy.context.scene.render.pixel_aspect_x = 1 +bpy.context.scene.render.pixel_aspect_y = 1 + +# FFMPEG +bpy.context.scene.render.image_settings.file_format = "FFMPEG" +bpy.context.scene.render.ffmpeg.format = "MPEG4" +bpy.context.scene.render.ffmpeg.codec = "H264" + +bpy.context.scene.render.ffmpeg.constant_rate_factor = "PERC_LOSSLESS" +bpy.context.scene.render.ffmpeg.ffmpeg_preset = "GOOD" + + +is_ntsc = bpy.context.scene.render.fps != 25 +if is_ntsc: + bpy.context.scene.render.ffmpeg.gopsize = 18 +else: + bpy.context.scene.render.ffmpeg.gopsize = 15 +bpy.context.scene.render.ffmpeg.use_max_b_frames = False + +bpy.context.scene.render.ffmpeg.video_bitrate = 9000 +bpy.context.scene.render.ffmpeg.maxrate = 9000 +bpy.context.scene.render.ffmpeg.minrate = 0 +bpy.context.scene.render.ffmpeg.buffersize = 224 * 8 +bpy.context.scene.render.ffmpeg.packetsize = 2048 +bpy.context.scene.render.ffmpeg.muxrate = 10080000 diff --git a/power_sequencer/operators/ripple_delete.py b/power_sequencer/operators/ripple_delete.py new file mode 100644 index 00000000..6b88a8e9 --- /dev/null +++ b/power_sequencer/operators/ripple_delete.py @@ -0,0 +1,111 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from operator import attrgetter + +from .utils.doc import doc_brief, doc_description, doc_idname, doc_name +from .utils.functions import get_frame_range +from .utils.functions import get_mouse_frame_and_channel +from .utils.global_settings import SequenceTypes +from .utils.functions import slice_selection + + +class POWER_SEQUENCER_OT_ripple_delete(bpy.types.Operator): + """ + Delete selected strips and remove remaining gaps + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "X", "value": "PRESS", "shift": True}, {}, "Ripple Delete")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + # Auto select if no strip selected + frame, channel = get_mouse_frame_and_channel(context, event) + if not context.selected_sequences: + bpy.ops.power_sequencer.select_closest_to_mouse(frame=frame, channel=channel) + if not context.selected_sequences: + return {"CANCELLED"} + return self.execute(context) + + def execute(self, context): + scene = context.scene + sequencer = bpy.ops.sequencer + selection = context.selected_sequences + + audio_scrub_active = context.scene.use_audio_scrub + context.scene.use_audio_scrub = False + + channels = list(set([s.channel for s in selection])) + selection_blocks = slice_selection(context, selection) + + is_single_channel = len(channels) == 1 + if is_single_channel: + first_strip = selection_blocks[0][0] + for block in selection_blocks: + sequencer.select_all(action="DESELECT") + block_strip_start = block[0] + delete_start = block_strip_start.frame_final_start + delete_end = block[-1].frame_final_end + ripple_length = delete_end - delete_start + assert ripple_length > 0 + + for s in block: + s.select = True + sequencer.delete() + + strips_in_channel = [ + s + for s in bpy.context.sequences + if s.channel == channels[0] + and s.frame_final_start >= first_strip.frame_final_start + ] + strips_in_channel = sorted(strips_in_channel, key=attrgetter("frame_final_start")) + to_ripple = [s for s in strips_in_channel if s.frame_final_start > delete_start] + for s in to_ripple: + s.frame_start -= ripple_length + + else: + for block in selection_blocks: + sequencer.select_all(action="DESELECT") + for s in block: + s.select = True + selection_start, _ = get_frame_range(context, block) + sequencer.delete() + + scene.frame_current = selection_start + bpy.ops.power_sequencer.gap_remove() + + self.report( + {"INFO"}, + "Deleted " + str(len(selection)) + " sequence" + "s" if len(selection) > 1 else "", + ) + + context.scene.use_audio_scrub = audio_scrub_active + return {"FINISHED"} diff --git a/power_sequencer/operators/save_direct.py b/power_sequencer/operators/save_direct.py new file mode 100644 index 00000000..f9ae5a61 --- /dev/null +++ b/power_sequencer/operators/save_direct.py @@ -0,0 +1,49 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_save_direct(bpy.types.Operator): + """ + Saves current file without prompting for confirmation. Overrides Blender default + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "S", "value": "PRESS", "ctrl": True}, {}, "Direct Save")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.scene + + def execute(self, context): + if bpy.data.is_saved: + bpy.ops.wm.save_mainfile() + else: + bpy.ops.wm.save_as_mainfile({"dict": "override"}, "INVOKE_DEFAULT") + self.report({"INFO"}, "File saved") + return {"FINISHED"} diff --git a/power_sequencer/operators/scene_create_from_selection.py b/power_sequencer/operators/scene_create_from_selection.py new file mode 100644 index 00000000..4cf837d7 --- /dev/null +++ b/power_sequencer/operators/scene_create_from_selection.py @@ -0,0 +1,87 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from operator import attrgetter + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_scene_create_from_selection(bpy.types.Operator): + """ + *brief* Convert selected strips into a scene strip + + + Create a scene from the selected sequences, copying the current scene's settings, and + replace the selection with the newly created scene as a strip + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + move_to_first_frame: bpy.props.BoolProperty( + name="Move to First Frame", + description="The strips will start at frame 1 on the new scene", + default=True, + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + start_scene_name = context.scene.name + + selection = context.selected_sequences + selection_start_frame = min( + selection, key=attrgetter("frame_final_start") + ).frame_final_start + selection_start_channel = min(selection, key=attrgetter("channel")).channel + + # Create new scene for the scene strip + bpy.ops.scene.new(type="FULL_COPY") + new_scene_name = context.scene.name + + bpy.ops.sequencer.select_all(action="INVERT") + bpy.ops.power_sequencer.delete_direct() + frame_offset = selection_start_frame - 1 + for s in context.sequences: + try: + s.frame_start -= frame_offset + except Exception: + continue + bpy.ops.sequencer.select_all() + bpy.ops.power_sequencer.preview_to_selection() + + # Back to start scene + context.screen.scene = bpy.data.scenes[start_scene_name] + + bpy.ops.power_sequencer.delete_direct() + bpy.ops.sequencer.scene_strip_add( + frame_start=selection_start_frame, channel=selection_start_channel, scene=new_scene_name + ) + scene_strip = context.selected_sequences[0] + scene_strip.use_sequence = True + return {"FINISHED"} diff --git a/power_sequencer/operators/scene_cycle.py b/power_sequencer/operators/scene_cycle.py new file mode 100644 index 00000000..f0e5cb81 --- /dev/null +++ b/power_sequencer/operators/scene_cycle.py @@ -0,0 +1,54 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_scene_cycle(bpy.types.Operator): + """ + Cycle through scenes + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/7zhq8Tg.gif", + "description": doc_description(__doc__), + "shortcuts": [({"type": "TAB", "value": "PRESS", "shift": True}, {}, "Cycle Scenes")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return bpy.data.scenes + + def execute(self, context): + scenes = bpy.data.scenes + + scene_count = len(scenes) + + if context.screen.is_animation_playing: + bpy.ops.screen.animation_cancel(restore_frame=False) + for index in range(scene_count): + if context.scene == scenes[index]: + context.window.scene = scenes[(index + 1) % scene_count] + break + return {"FINISHED"} diff --git a/power_sequencer/operators/scene_merge_from.py b/power_sequencer/operators/scene_merge_from.py new file mode 100644 index 00000000..cab3e353 --- /dev/null +++ b/power_sequencer/operators/scene_merge_from.py @@ -0,0 +1,108 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from bpy.props import BoolProperty +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_merge_from_scene_strip(bpy.types.Operator): + """ + *brief* Copies all sequences and markers from a SceneStrip's scene into + the active scene. Optionally delete the source scene and the strip. + + + WARNING: Currently the operator doesn't recreate any animation data, + be careful by choosing to delete the scene after the merge. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + delete_scene = BoolProperty( + name="Delete Strip's scene", + description="Delete the SceneStrip's scene after the merging", + default=True, + ) + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor.active_strip + + def invoke(self, context, event): + window_manager = context.window_manager + return window_manager.invoke_props_dialog(self) + + def execute(self, context): + strip = context.scene.sequence_editor.active_strip + if strip.type != "SCENE": + return {'FINISHED'} + strip_scene = strip.scene + start_scene = context.window.scene + + self.merge_markers(context, strip_scene, start_scene) + self.merge_strips(context, strip_scene, start_scene) + + if not self.delete_scene: + return {"FINISHED"} + + bpy.ops.sequencer.select_all(action="DESELECT") + strip.select = True + bpy.ops.sequencer.delete() + context.window.scene = strip_scene + bpy.ops.scene.delete() + context.window.scene = start_scene + self.report(type={"WARNING"}, message="All animations on source scene were lost") + + return {"FINISHED"} + + def merge_strips(self, context, source_scene, target_scene): + context.window.scene = source_scene + bpy.ops.sequencer.select_all(action="SELECT") + bpy.ops.sequencer.copy() + + context.window.scene = target_scene + current_frame = context.scene.frame_current + active = context.scene.sequence_editor.active_strip + context.scene.frame_current = active.frame_final_start + bpy.ops.sequencer.select_all(action="DESELECT") + bpy.ops.sequencer.paste() + + context.scene.frame_current = current_frame + + def merge_markers(self, source_scene, target_scene): + if len(target_scene.timeline_markers) > 0: + bpy.ops.marker.select_all(action="DESELECT") + + bpy.context.screen.scene = source_scene + bpy.ops.marker.select_all(action="SELECT") + bpy.ops.marker.make_links_scene(scene=target_scene.name) + + bpy.context.screen.scene = target_scene + active = bpy.context.screen.scene.sequence_editor.active_strip + time_offset = active.frame_final_start + bpy.ops.marker.move(frames=time_offset) + bpy.ops.marker.select_all(action="DESELECT") diff --git a/power_sequencer/operators/scene_open_from_strip.py b/power_sequencer/operators/scene_open_from_strip.py new file mode 100644 index 00000000..9534855f --- /dev/null +++ b/power_sequencer/operators/scene_open_from_strip.py @@ -0,0 +1,53 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_open_scene_strip(bpy.types.Operator): + """ + Sets the current scene to the scene in the SceneStrip + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "E", "value": "PRESS", "alt": True, "ctrl": True}, {}, "Open Strip Scene") + ], + "keymap": "Sequencer", + } + + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor.active_strip + + def execute(self, context): + active_strip = context.scene.sequence_editor.active_strip + if active_strip.type != "SCENE": + return {'FINISHED'} + + strip_scene = active_strip.scene + context.screen.scene = bpy.data.scenes[strip_scene.name] + return {"FINISHED"} diff --git a/power_sequencer/operators/scene_rename_with_strip.py b/power_sequencer/operators/scene_rename_with_strip.py new file mode 100644 index 00000000..f142f8bd --- /dev/null +++ b/power_sequencer/operators/scene_rename_with_strip.py @@ -0,0 +1,60 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import operator + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_scene_rename_with_strip(bpy.types.Operator): + """ + Rename a Scene Strip and its source scene + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + new_name: bpy.props.StringProperty( + name="Strip New Name", + description="The name both the SceneStrip and its source Scene will take", + default="", + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def invoke(self, context, event): + window_manager = context.window_manager + return window_manager.invoke_props_dialog(self) + + def execute(self, context): + scene_strips = [s for s in context.selected_sequences if s.type == "SCENE"] + for strip in scene_strips: + strip.name = self.new_name + strip.scene.name = strip.name + return {"FINISHED"} diff --git a/power_sequencer/operators/select_all_left_or_right.py b/power_sequencer/operators/select_all_left_or_right.py new file mode 100644 index 00000000..e92a2f7d --- /dev/null +++ b/power_sequencer/operators/select_all_left_or_right.py @@ -0,0 +1,65 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_select_all_left_or_right(bpy.types.Operator): + """ + *Brief* Selects all strips left or right of the time cursor. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "Q", "value": "PRESS", "shift": True}, + {"side": "LEFT"}, + "Select all strips to the LEFT of the time cursor", + ), + ( + {"type": "E", "value": "PRESS", "shift": True}, + {"side": "RIGHT"}, + "Select all strips to the right of the time cursor", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + side: bpy.props.EnumProperty( + name="Side", + description=("Side to select"), + items=[ + ("LEFT", "Left", "Move strips back in time, to the left"), + ("RIGHT", "Right", "Move strips forward in time, to the right"), + ], + default="LEFT", + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + return bpy.ops.sequencer.select("INVOKE_DEFAULT", left_right=self.side) diff --git a/power_sequencer/operators/select_closest_to_mouse.py b/power_sequencer/operators/select_closest_to_mouse.py new file mode 100644 index 00000000..66860565 --- /dev/null +++ b/power_sequencer/operators/select_closest_to_mouse.py @@ -0,0 +1,58 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import find_strips_mouse +from .utils.functions import get_mouse_frame_and_channel +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_select_closest_to_mouse(bpy.types.Operator): + """ + Select the closest strip under the mouse cursor + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + frame: bpy.props.IntProperty(name="Frame") + channel: bpy.props.IntProperty(name="Channel") + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + self.frame, self.channel = get_mouse_frame_and_channel(context, event) + return self.execute(context) + + def execute(self, context): + try: + strip = find_strips_mouse(context, self.frame, self.channel)[0] + strip.select = True + except Exception: + return {"CANCELLED"} + return {"FINISHED"} diff --git a/power_sequencer/operators/select_linked_effect.py b/power_sequencer/operators/select_linked_effect.py new file mode 100644 index 00000000..56e38276 --- /dev/null +++ b/power_sequencer/operators/select_linked_effect.py @@ -0,0 +1,47 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import find_linked +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_select_linked_effect(bpy.types.Operator): + """ + Select all strips that are linked by an effect strip + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + for s in find_linked(context, context.sequences, context.selected_sequences): + s.select = True + return {"FINISHED"} diff --git a/power_sequencer/operators/select_linked_strips.py b/power_sequencer/operators/select_linked_strips.py new file mode 100644 index 00000000..86aa8d01 --- /dev/null +++ b/power_sequencer/operators/select_linked_strips.py @@ -0,0 +1,70 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_select_linked_strips(bpy.types.Operator): + """ + Add/Remove linked strips near mouse pointer to/from selection without the need to + previously have clicked/manually selected + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "L", "value": "PRESS"}, {}, "Add/Remove Linked to/from Selection")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"UNDO"} + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor + + def execute(self, context): + # save current selection first + selection = set(context.selected_sequences) + + # if previously selected strips are linked select links as well to toggle them too + bpy.ops.sequencer.select_linked() + selection_new = set(context.selected_sequences).difference(selection) + # deselect & select only the linked strips near mouse pointer + bpy.ops.sequencer.select_all(action="DESELECT") + # re-enable linked + add selection near mouse pointer + for s in selection_new: + s.select = True + bpy.ops.sequencer.select_linked() + bpy.ops.sequencer.select_linked_pick("INVOKE_DEFAULT", extend=True) + selection_new = set(context.selected_sequences) + + # identify if linked strips under mouse pointer need to be added or removed + action = len(selection.intersection(selection_new)) != len(selection_new) + + # re-enable previous selection + for s in selection: + s.select = True + + # take care of toggle for strips under mouse + for s in selection_new: + s.select = action + return {"FINISHED"} diff --git a/power_sequencer/operators/select_related_strips.py b/power_sequencer/operators/select_related_strips.py new file mode 100644 index 00000000..30a8b24a --- /dev/null +++ b/power_sequencer/operators/select_related_strips.py @@ -0,0 +1,140 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.global_settings import SequenceTypes +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_select_related_strips(bpy.types.Operator): + """ + *brief* Find and select all strips related to the selection + + + Find and select effects related to the selection, but also inputs of selected effects. + This helps to then copy or duplicate strips with all attached effects. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + find_all: bpy.props.BoolProperty( + name="Find All", + description=( + "Find all related strips recursively so that you can copy the selection" + " without getting an error from Blender" + ), + default=True, + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + if self.find_all: + related_strips = set() + for s in context.selected_sequences: + self.find_neighbours_recursive(related_strips, s, context) + else: + related_strips = [] + # Only select direct neighbours and attached effects + effects = [s for s in context.sequences if s.type in SequenceTypes.EFFECT] + found_effects = self.find_related_effects(context.selected_sequences, effects) + related_strips.extend(found_effects) + while len(found_effects) > 0: + found_effects = self.find_related_effects(found_effects, effects) + related_strips.extend(found_effects) + for s in related_strips: + s.select = True + return {"FINISHED"} + + def find_neighbours_recursive(self, visited, strip, context): + """ + Performs a depth first search traversal to the graph of strips, to find + all related strips. + Args: + - visited: A set with all the strips that have been visited. + - strip: The strip to start the search from. + """ + visited.add(strip) + neighbours = self.find_neighbours(strip, context) + for s in neighbours: + if s not in visited: + self.find_neighbours_recursive(visited, s, context) + + def find_neighbours(self, strip, context): + """ + Strips and their effect strips define a graph, where each node is a + strip and edges are their connections. It finds all the neighbours of a + strip in the graph, and *sometimes neighbours of neighbours and so on*. + *In order to find the neighbours of a strip the + bpy.ops.transform.seq_slide operator is used, and usually finds many + levels of neighbours, but always finds the first level, which is needed, + the other levels are redundant, but are included for brevity reasons. + Args: + - strip: The strip to find all its neighbours. + Returns: A list with all the neighbours of the strip and sometimes + neighbours of neighbours and so on. + """ + # Respects initial selection + init_selected_strips = [s for s in context.selected_sequences] + + neighbours = [] + bpy.ops.sequencer.select_all(action="DESELECT") + strip.select = True + bpy.ops.transform.seq_slide(value=(0, 0)) + strip.select = False + for s in context.selected_sequences: + neighbours.append(s) + + try: + neighbours.append(strip.input_1) + neighbours.append(strip.input_2) + except Exception: + pass + + bpy.ops.sequencer.select_all(action="DESELECT") + for s in init_selected_strips: + s.select = True + + return neighbours + + def find_related_effects(self, sequences, effects): + found = [] + for s in sequences: + for e in effects: + try: + if e.input_1 == s: + found.append(e) + except Exception: + continue + try: + if e.input_2 == s: + found.append(e) + except Exception: + continue + return found diff --git a/power_sequencer/operators/select_strips_under_cursor.py b/power_sequencer/operators/select_strips_under_cursor.py new file mode 100644 index 00000000..2fd37a60 --- /dev/null +++ b/power_sequencer/operators/select_strips_under_cursor.py @@ -0,0 +1,51 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_sequences_under_cursor +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_select_strips_under_cursor(bpy.types.Operator): + """ + Selects the strips that are currently under the time cursor + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + deselect_first: bpy.props.BoolProperty(name="Deselect First") + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + if self.deselect_first: + bpy.ops.sequencer.select_all(action="DESELECT") + for s in get_sequences_under_cursor(context): + s.select = True + return {"FINISHED"} diff --git a/power_sequencer/operators/set_timeline_range.py b/power_sequencer/operators/set_timeline_range.py new file mode 100644 index 00000000..7877c799 --- /dev/null +++ b/power_sequencer/operators/set_timeline_range.py @@ -0,0 +1,56 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_set_timeline_range(bpy.types.Operator): + """ + Set the timeline start and end frame using the time cursor + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + adjust: bpy.props.EnumProperty( + items=[("start", "start", "start"), ("end", "end", "end")], + name="Adjust", + description="Change the start or the end frame of the timeline", + default="start", + ) + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor + + def execute(self, context): + scene = context.scene + if self.adjust == "start": + scene.frame_start = scene.frame_current + elif self.adjust == "end": + scene.frame_end = scene.frame_current - 1 + return {"FINISHED"} diff --git a/power_sequencer/operators/snap.py b/power_sequencer/operators/snap.py new file mode 100644 index 00000000..29668e56 --- /dev/null +++ b/power_sequencer/operators/snap.py @@ -0,0 +1,58 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_sequences_under_cursor +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_snap(bpy.types.Operator): + """ + *Brief* Snaps selected strips to the time cursor ignoring locked sequences. + + Automatically selects sequences if there is no active selection. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "S", "value": "PRESS", "shift": True}, {}, "Snap sequences to cursor") + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + sequences = ( + context.selected_sequences + if len(context.selected_sequences) > 0 + else get_sequences_under_cursor(context) + ) + frame = context.scene.frame_current + for s in sequences: + s.select = True + bpy.ops.sequencer.snap(frame=frame) + return {"FINISHED"} diff --git a/power_sequencer/operators/snap_selection.py b/power_sequencer/operators/snap_selection.py new file mode 100644 index 00000000..0c93b76e --- /dev/null +++ b/power_sequencer/operators/snap_selection.py @@ -0,0 +1,58 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from .utils.functions import get_sequences_under_cursor +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_snap_selection(bpy.types.Operator): + """ + *Brief* Snap the entire selection to the time cursor. + + Automatically selects sequences if there is no active selection. + To snap each strip individually, see Snap. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "S", "value": "PRESS", "alt": True}, {}, "Snap selection to cursor") + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + sequences = ( + context.selected_sequences + if len(context.selected_sequences) > 0 + else get_sequences_under_cursor(context) + ) + time_move = context.scene.frame_current - sequences[0].frame_final_start + # bpy.ops.power_sequencer.select_related_strips() + for s in sequences: + s.frame_start += time_move + return {"FINISHED"} diff --git a/power_sequencer/operators/space_sequences.py b/power_sequencer/operators/space_sequences.py new file mode 100644 index 00000000..3f7bb3b0 --- /dev/null +++ b/power_sequencer/operators/space_sequences.py @@ -0,0 +1,66 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_mouse_frame_and_channel, convert_duration_to_frames +from .utils.global_settings import SequenceTypes +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_space_sequences(bpy.types.Operator): + """ + *brief* Offsets all strips to the right of the time cursor by a given duration, ignoring locked sequences + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [({"type": "EQUAL", "value": "PRESS"}, {}, "")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + gap_to_insert: bpy.props.FloatProperty( + name="Duration", description="The time offset to apply to the strips", default=1.0 + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + sequences = [ + s + for s in context.sequences + if s.type in SequenceTypes.CUTABLE + and s.frame_final_start >= context.scene.frame_current + and not s.lock + ] + + gap_frames = convert_duration_to_frames(context, self.gap_to_insert) + sorted_sequences = sorted(sequences, key=lambda s: s.frame_final_start, reverse=True) + for s in sorted_sequences: + s.frame_start += gap_frames + + markers = context.scene.timeline_markers + for m in [m for m in markers if m.frame >= context.scene.frame_current]: + m.frame += gap_frames + return {"FINISHED"} diff --git a/power_sequencer/operators/speed_remove_effect.py b/power_sequencer/operators/speed_remove_effect.py new file mode 100644 index 00000000..d969e902 --- /dev/null +++ b/power_sequencer/operators/speed_remove_effect.py @@ -0,0 +1,64 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_speed_remove_effect(bpy.types.Operator): + """ + *brief* Removes speed from META, un-groups META + + + This is the opposite of power_sequencer's "Add Speed" operator. It seeks out and removes + the speed modifier inside a meta and ungroups all the remaining strips within. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor.active_strip.type == "META" + + def execute(self, context): + active = context.scene.sequence_editor.active_strip + sub_strips = [] + for s in active.sequences: + if s.type == "SPEED": + speed_strip = s + else: + sub_strips.append(s) + + bpy.ops.sequencer.meta_separate() + bpy.ops.sequencer.select_all(action="DESELECT") + + speed_strip.select = True + bpy.ops.sequencer.delete() + + for s in sub_strips: + s.select = True + return {"FINISHED"} diff --git a/power_sequencer/operators/speed_up_movie_strip.py b/power_sequencer/operators/speed_up_movie_strip.py new file mode 100644 index 00000000..0c87b78b --- /dev/null +++ b/power_sequencer/operators/speed_up_movie_strip.py @@ -0,0 +1,127 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from math import ceil + +from .utils.global_settings import SequenceTypes +from .utils.functions import slice_selection +from .utils.functions import find_linked +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_speed_up_movie_strip(bpy.types.Operator): + """ + *brief* Adds a speed effect to the 2x speed, set frame end, wrap both into META + + Add 2x speed to strip and set its frame end accordingly. Wraps both the strip and the speed + modifier into a META strip. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/ZyEd0jD.gif", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "NUMPAD_2", "value": "PRESS", "alt": True}, + {"speed_factor": 2.0}, + "Speed x2", + ), + ( + {"type": "NUMPAD_3", "value": "PRESS", "alt": True}, + {"speed_factor": 3.0}, + "Speed x3", + ), + ( + {"type": "NUMPAD_4", "value": "PRESS", "alt": True}, + {"speed_factor": 4.0}, + "Speed x4", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + speed_factor: bpy.props.IntProperty( + name="Speed factor", description="How many times the footage gets sped up", default=2, min=0 + ) + individual_sequences: bpy.props.BoolProperty( + name="Affect individual strips", + description="Speed up every VIDEO strip individually", + default=False, + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + sequences = [s for s in context.selected_sequences if s.type in SequenceTypes.VIDEO] + + if not sequences: + self.report( + {"ERROR_INVALID_INPUT"}, + "No Movie meta_strip or Metastrips selected. Operation cancelled", + ) + return {"FINISHED"} + + selection_blocks = [] + if self.individual_sequences: + selection_blocks = [[s] for s in sequences] + else: + selection_blocks = slice_selection(context, sequences) + + for sequences in selection_blocks: + self.speed_effect_add(context, sequences) + + self.report( + {"INFO"}, "Successfully processed " + str(len(selection_blocks)) + " selection blocks" + ) + return {"FINISHED"} + + def speed_effect_add(self, context, sequences): + if not sequences: + return + + sequence_editor = context.scene.sequence_editor + bpy.ops.sequencer.select_all(action="DESELECT") + for s in sequences: + s.select = True + bpy.ops.sequencer.meta_make() + meta_strip = sequence_editor.active_strip + if len(meta_strip.sequences) == 1: + meta_strip.sequences[0].frame_offset_start = 0 + meta_strip.sequences[0].frame_offset_end = 0 + + bpy.ops.sequencer.effect_strip_add(type="SPEED") + speed_effect = sequence_editor.active_strip + speed_effect.use_default_fade = False + speed_effect.speed_factor = self.speed_factor + + duration = ceil(meta_strip.frame_final_duration / speed_effect.speed_factor) + meta_strip.frame_final_end = meta_strip.frame_final_start + duration + + sequence_editor.active_strip = meta_strip + speed_effect.select = True + meta_strip.select = True + bpy.ops.sequencer.meta_make() + sequence_editor.active_strip.name = ( + meta_strip.sequences[0].name + " " + str(self.speed_factor) + "x" + ) diff --git a/power_sequencer/operators/swap_strips.py b/power_sequencer/operators/swap_strips.py new file mode 100644 index 00000000..945f99ff --- /dev/null +++ b/power_sequencer/operators/swap_strips.py @@ -0,0 +1,247 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy
+from operator import attrgetter
+
+from .utils.doc import doc_name, doc_idname, doc_brief, doc_description
+
+
+class POWER_SEQUENCER_OT_swap_strips(bpy.types.Operator):
+ """
+ *brief* Swaps the 2 strips between them
+
+
+ Places the first strip in the channel and starting frame (frame_final_start) of the second
+ strip, and places the second strip in the channel and starting frame (frame_final_end) of
+ the first strip. If there is no space for the swap, it does nothing.
+ """
+
+ doc = {
+ "name": doc_name(__qualname__),
+ "demo": "",
+ "description": doc_description(__doc__),
+ "shortcuts": [],
+ "keymap": "Sequencer",
+ }
+ bl_idname = doc_idname(__qualname__)
+ bl_label = doc["name"]
+ bl_description = doc_brief(doc["description"])
+ bl_options = {"REGISTER", "UNDO"}
+
+ direction: bpy.props.EnumProperty(
+ name="Direction",
+ description="The direction to find the closest strip",
+ items=[
+ ("up", "Up", "The direction up from the selected strip"),
+ ("down", "Down", "The direction down from the selected strip"),
+ ],
+ default="up",
+ )
+
+ @classmethod
+ def poll(cls, context):
+ return context.selected_sequences +
+ def execute(self, context):
+ strip_1 = context.selected_sequences[0]
+ if len(context.selected_sequences) == 1:
+ strip_2 = self.find_closest_strip_vertical(context, strip_1, self.direction)
+ else:
+ strip_2 = context.selected_sequences[1]
+ if not strip_2 or strip_1.lock or strip_2.lock:
+ return {"CANCELLED"}
+
+ # Swap a strip and one of its effects
+ if hasattr(strip_1, "input_1") or hasattr(strip_2, "input_1"):
+ if not self.are_linked(strip_1, strip_2):
+ return {"CANCELLED"}
+ self.swap_with_effect(strip_1, strip_2)
+ return {"FINISHED"}
+
+ s1_start, s1_channel = strip_1.frame_final_start, strip_1.channel
+ s2_start, s2_channel = strip_2.frame_final_start, strip_2.channel
+
+ self.move_to_end(strip_1, context)
+ self.move_to_end(strip_2, context)
+
+ s1_start_2 = strip_1.frame_final_start
+ s2_start_2 = strip_2.frame_final_start
+
+ group_1 = {
+ s: s.channel
+ for s in context.sequences
+ if s.frame_final_start == s1_start_2 and s != strip_1
+ }
+ group_2 = {
+ s: s.channel
+ for s in context.sequences
+ if s.frame_final_start == s2_start_2 and s != strip_2
+ }
+
+ strip_2.select = False
+ bpy.ops.transform.seq_slide(
+ value=(s2_start - strip_1.frame_final_start, s2_channel - strip_1.channel)
+ )
+
+ bpy.ops.sequencer.select_all(action="DESELECT")
+ strip_2.select = True
+ bpy.ops.transform.seq_slide(
+ value=(s1_start - strip_2.frame_final_start, s1_channel - strip_2.channel)
+ )
+
+ if not self.fits(
+ strip_1, group_1, s2_start, s1_channel, s2_channel, context
+ ) or not self.fits(strip_2, group_2, s1_start, s2_channel, s1_channel, context):
+ self.reconstruct(strip_1, s1_channel, group_1, context)
+ self.reconstruct(strip_2, s2_channel, group_2, context)
+
+ bpy.ops.sequencer.select_all(action="DESELECT")
+ strip_1.select = True
+ bpy.ops.transform.seq_slide(
+ value=(s1_start - strip_1.frame_final_start, s1_channel - strip_1.channel)
+ )
+
+ bpy.ops.sequencer.select_all(action="DESELECT")
+ strip_2.select = True
+ bpy.ops.transform.seq_slide(
+ value=(s2_start - strip_2.frame_final_start, s2_channel - strip_2.channel)
+ )
+
+ bpy.ops.sequencer.select_all(action="DESELECT")
+ strip_1.select = True
+ strip_2.select = True
+ return {"CANCELLED"}
+
+ bpy.ops.sequencer.select_all(action="DESELECT")
+ strip_1.select = True
+ strip_2.select = True
+ return {"FINISHED"}
+
+ def move_to_frame(self, strip, frame, context):
+ """
+ Moves a strip based on its frame_final_start without changing its
+ duration.
+ Args:
+ - strip: The strip to be moved.
+ - frame: The frame, the frame_final_start of the strip will be placed
+ at.
+ """
+ selected_strips = context.selected_sequences
+ bpy.ops.sequencer.select_all(action="DESELECT")
+ strip.select = True
+
+ bpy.ops.transform.seq_slide(value=(frame - strip.frame_final_start, 0))
+
+ bpy.ops.sequencer.select_all(action="DESELECT")
+ for s in selected_strips:
+ s.select = True
+
+ def move_to_end(self, strip, context):
+ """
+ Moves a strip to an empty slot at the end of the sequencer, different
+ than its initial slot.
+ Args:
+ - strip: The strip to move.
+ """
+ end_frame = max(context.sequences, key=attrgetter("frame_final_end")).frame_final_end
+ self.move_to_frame(strip, end_frame, context)
+
+ def fits(self, strip, group, frame, init_channel, target_channel, context):
+ """
+ Checks if a swap has been successful or not.
+ Args:
+ - strip: The core strip of the swap.
+ - group: The effect strips of the core strip.
+ - frame: The starting frame of the target location.
+ - init_channel: The initial channel of the strip, before the swap took
+ place.
+ - target_channel: The channel of the target location.
+ Returns: True if the swap was successful, otherwise False.
+ """
+ if strip.frame_final_start != frame or strip.channel != target_channel:
+ return False
+
+ offset = strip.channel - init_channel
+ for s in group.keys():
+ if s.channel != group[s] + offset:
+ return False
+
+ return context.selected_sequences
+
+ def reconstruct(self, strip, init_channel, group, context):
+ """
+ Reconstructs a failed swap, based on a core strip. After its done, the
+ core strip is placed at the end of the sequencer, in an empty slot.
+ Args:
+ - strip: The core strip of the swap.
+ - init_channel: The initial channel of the core strip.
+ - group: A dictionary with the effect strips of the core strip, and
+ their target channels.
+ """
+ self.move_to_end(strip, context)
+ bpy.ops.sequencer.select_all(action="DESELECT")
+ strip.select = True
+ bpy.ops.transform.seq_slide(value=(0, init_channel - strip.channel))
+
+ for s in group.keys():
+ channel = group[s]
+ for u in group.keys():
+ if u.channel == channel and u != s:
+ u.channel += 1
+ s.channel = channel
+
+ def find_closest_strip_vertical(self, context, strip, direction):
+ """
+ Finds the closest strip to a given strip in a specific direction.
+ Args:
+ - strip: The base strip.
+ Returns: The closest strip to the given strip, in the proper direction.
+ If no strip is found, returns None.
+ """
+ strips_in_range = (
+ s
+ for s in context.sequences
+ if strip.frame_final_start <= s.frame_final_start
+ and s.frame_final_end <= strip.frame_final_end
+ )
+ if direction == "up":
+ strips_above = [s for s in strips_in_range if s.channel > strip.channel]
+ if not strips_above:
+ return
+ return min(strips_above, key=attrgetter("channel"))
+ elif direction == "down":
+ strips_below = [s for s in strips_in_range if s.channel < strip.channel]
+ if not strips_below:
+ return
+ return max(strips_below, key=attrgetter("channel"))
+
+ def are_linked(self, strip_1, strip_2):
+ return (
+ strip_1.frame_final_start == strip_2.frame_final_start
+ and strip_1.frame_final_end == strip_2.frame_final_end
+ )
+
+ def swap_with_effect(self, strip_1, strip_2):
+ effect_strip = strip_1 if hasattr(strip_1, "input_1") else strip_2
+ other_strip = strip_1 if effect_strip != strip_1 else strip_2
+
+ effect_strip_channel = effect_strip.channel
+ other_strip_channel = other_strip.channel
+
+ effect_strip.channel -= 1
+ other_strip.channel = effect_strip_channel
+ effect_strip.channel = other_strip_channel
diff --git a/power_sequencer/operators/synchronize_titles.py b/power_sequencer/operators/synchronize_titles.py new file mode 100644 index 00000000..3df342f4 --- /dev/null +++ b/power_sequencer/operators/synchronize_titles.py @@ -0,0 +1,112 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +# TODO: rewrite to sync strips to corresponding identifiers instead +# See https://github.com/GDquest/Blender-power-sequencer/issues/55 +class POWER_SEQUENCER_OT_synchronize_titles(bpy.types.Operator): + """ + *brief* Snap the selected image or text strips to the corresponding title marker + + + The marker and strip names have to start with TITLE-001 + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + TITLE_REGEX = r"^TITLE-?([0-9]+)-?" + + @classmethod + def poll(cls, context): + return context.scene.sequence_editor + + def execute(self, context): + markers = context.scene.timeline_markers + selection = context.selected_sequences + + if not markers and selection: + if not markers: + self.report({"INFO"}, "No markers, operation cancelled.") + else: + self.report({"INFO"}, "No sequences selected, operation cancelled.") + return {"CANCELLED"} + + title_markers = self.find_markers(context, self.TITLE_REGEX) + if not title_markers: + self.report({"INFO"}, "No title markers found, operation cancelled.") + + matched = self.match_sequences_and_markers(selection, title_markers, self.TITLE_REGEX) + for s, m in matched: + s.frame_start = m.frame + return {"FINISHED"} + + def match_sequences_and_markers(self, sequences, markers, regex): + """Takes a list of sequences, of markers, and checks if they both + match a regular expression. + Returns a list of pairs of sequence and marker as tuples + + Args: + - sequences, the list of sequences + - markers, a list of markers + - regex, the regular expression to match""" + if not sequences and markers and regex: + raise AttributeError("missing attributes") + + import re + from .utils.global_settings import SequenceTypes + + sequences = (s for s in sequences if s.type not in SequenceTypes.EFFECT) + + return_list = [] + re_title = re.compile(regex) + for s in sequences: + found = re_title.match(s.name) + title_id = int(found.group(1)) if found else None + for m in markers: + found = re_title.match(m.name) + marker_id = int(found.group(1)) if found else None + if marker_id == title_id: + return_list.append((s, m)) + break + return return_list + + def find_markers(self, context, regex): + """Finds and returns all markers using REGEX + Args: + - regex, the re match pattern to use""" + if not regex: + raise AttributeError("regex parameter missing") + + import re + + regex = re.compile(regex) + markers = context.scene.timeline_markers + markers = (m for m in markers if regex.match(m.name)) + return markers diff --git a/power_sequencer/operators/toggle_selected_mute.py b/power_sequencer/operators/toggle_selected_mute.py new file mode 100644 index 00000000..cd445205 --- /dev/null +++ b/power_sequencer/operators/toggle_selected_mute.py @@ -0,0 +1,71 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_toggle_selected_mute(bpy.types.Operator): + """ + Mute or unmute selected sequences + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "H", "value": "PRESS"}, + {"use_unselected": False}, + "Mute or Unmute Selected Strips", + ), + ( + {"type": "H", "value": "PRESS", "alt": True}, + {"use_unselected": True}, + "Mute or Unmute Selected Strips", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + use_unselected: bpy.props.BoolProperty( + name="Use unselected", description="Toggle non selected sequences", default=False + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + selection = context.selected_sequences + + if self.use_unselected: + selection = [s for s in context.sequences if s not in selection] + + if not selection: + self.report({"WARNING"}, "No sequences to toggle muted") + return {"CANCELLED"} + + mute = not selection[0].mute + for s in selection: + s.mute = mute + return {"FINISHED"} diff --git a/power_sequencer/operators/toggle_waveforms.py b/power_sequencer/operators/toggle_waveforms.py new file mode 100644 index 00000000..c3e45980 --- /dev/null +++ b/power_sequencer/operators/toggle_waveforms.py @@ -0,0 +1,82 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.global_settings import SequenceTypes +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_toggle_waveforms(bpy.types.Operator): + """ + *brief* Toggle audio waveforms + + Toggle drawing of waveforms for selected strips or for all audio strips if no selection + is active. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "https://i.imgur.com/HJ5ryhv.gif", + "description": doc_description(__doc__), + "shortcuts": [({"type": "W", "value": "PRESS", "alt": True}, {}, "Toggle Waveforms")], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + mode: bpy.props.EnumProperty( + items=[ + ("auto", "Auto", "Automatically toggle the waveform"), + ("on", "On", "Make the waveforms visible"), + ("off", "Off", "Make the waveforms invisible"), + ], + name="Waveform visibility", + description="Force the waveforms' visibility with On or Off, \ + or let Blender choose automatically", + default="auto", + ) + + @classmethod + def poll(cls, context): + return context.selected_sequences + + def execute(self, context): + selection = context.selected_sequences + if not selection: + selection = context.sequences + + sequences = [s for s in selection if s.type in SequenceTypes.SOUND] + + if not sequences: + self.report({"ERROR_INVALID_INPUT"}, "Select at least one sound strip") + return {"CANCELLED"} + + show_waveform = None + if self.mode == "auto": + from operator import attrgetter + + show_waveform = not sorted(sequences, key=attrgetter("frame_final_start"))[ + 0 + ].show_waveform + else: + show_waveform = True if self.mode == "on" else False + + for s in sequences: + s.show_waveform = show_waveform + return {"FINISHED"} diff --git a/power_sequencer/operators/transitions_remove.py b/power_sequencer/operators/transitions_remove.py new file mode 100644 index 00000000..aabb2af5 --- /dev/null +++ b/power_sequencer/operators/transitions_remove.py @@ -0,0 +1,85 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from operator import attrgetter + +from .utils.global_settings import SequenceTypes +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_transitions_remove(bpy.types.Operator): + """ + Delete a crossfade strip and moves the handles of the input strips to form a cut again + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + sequences_override = [] + + @classmethod + def poll(cls, context): + return context.selected_sequences + + + def execute(self, context): + to_process = ( + self.sequences_override if self.sequences_override else context.selected_sequences + ) + + transitions = [s for s in to_process if s.type in SequenceTypes.TRANSITION] + if not transitions: + return {"FINISHED"} + + saved_selection = [ + s for s in context.selected_sequences if s.type not in SequenceTypes.TRANSITION + ] + bpy.ops.sequencer.select_all(action="DESELECT") + for transition in transitions: + effect_middle_frame = round( + (transition.frame_final_start + transition.frame_final_end) / 2 + ) + + inputs = [transition.input_1, transition.input_2] + strips_to_edit = [] + for input in inputs: + if input.type in SequenceTypes.EFFECT and hasattr(input, "input_1"): + strips_to_edit.append(input.input_1) + else: + strips_to_edit.append(input) + + strip_1 = min(strips_to_edit, key=attrgetter("frame_final_end")) + strip_2 = max(strips_to_edit, key=attrgetter("frame_final_end")) + + strip_1.frame_final_end = effect_middle_frame + strip_2.frame_final_start = effect_middle_frame + + transition.select = True + bpy.ops.sequencer.delete() + + for s in saved_selection: + s.select = True + return {"FINISHED"} diff --git a/power_sequencer/operators/trim_left_or_right_handles.py b/power_sequencer/operators/trim_left_or_right_handles.py new file mode 100644 index 00000000..75a461da --- /dev/null +++ b/power_sequencer/operators/trim_left_or_right_handles.py @@ -0,0 +1,115 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +from operator import attrgetter + +import bpy + +from .utils.doc import doc_brief, doc_description, doc_idname, doc_name + + +class POWER_SEQUENCER_OT_trim_left_or_right_handles(bpy.types.Operator): + """ + Trims or extends the handle closest to the time cursor for all selected strips. + + If you keep the Shift key down, the edit will ripple through the timeline. + Auto selects sequences under the time cursor when you don't have a selection. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "K", "value": "PRESS", "alt": True}, + {"side": "RIGHT", "ripple": False}, + "Smart Snap Right", + ), + ( + {"type": "K", "value": "PRESS", "alt": True, "shift": True}, + {"side": "RIGHT", "ripple": True}, + "Smart Snap Right With Ripple", + ), + ( + {"type": "K", "value": "PRESS", "ctrl": True}, + {"side": "LEFT", "ripple": False}, + "Smart Snap Left", + ), + ( + {"type": "K", "value": "PRESS", "ctrl": True, "shift": True}, + {"side": "LEFT", "ripple": True}, + "Smart Snap Left With Ripple", + ), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + side: bpy.props.EnumProperty( + items=[("LEFT", "Left", "Left side"), ("RIGHT", "Right", "Right side")], + name="Snap side", + description="Handle side to use for the snap", + default="LEFT", + ) + ripple: bpy.props.BoolProperty(name="Ripple", default=False) + + @classmethod + def poll(cls, context): + return context.sequences + + def execute(self, context): + frame_current = context.scene.frame_current + + # Only select sequences under the time cursor + sequences = context.selected_sequences if context.selected_sequences else context.sequences + for s in sequences: + s.select = s.frame_final_start <= frame_current and s.frame_final_end >= frame_current + sequences = [s for s in sequences if s.select] + if not sequences: + return {"FINISHED"} + + for s in sequences: + if self.side == "LEFT": + s.select_left_handle = True + if self.side == "RIGHT": + s.select_right_handle = True + + # If trimming from the left, we need to save the start frame before trimming + ripple_start_frame = 0 + if self.ripple and self.side == "LEFT": + ripple_start_frame = min( + sequences, key=attrgetter("frame_final_start") + ).frame_final_start + + bpy.ops.sequencer.snap(frame=frame_current) + for s in sequences: + s.select_right_handle = False + s.select_left_handle = False + + if self.ripple and sequences: + if self.side == "RIGHT": + ripple_start_frame = max( + sequences, key=attrgetter("frame_final_end") + ).frame_final_end + bpy.ops.power_sequencer.gap_remove(frame=ripple_start_frame) + else: + bpy.ops.power_sequencer.gap_remove(frame=ripple_start_frame) + + return {"FINISHED"} diff --git a/power_sequencer/operators/trim_three_point_edit.py b/power_sequencer/operators/trim_three_point_edit.py new file mode 100644 index 00000000..aa4d888f --- /dev/null +++ b/power_sequencer/operators/trim_three_point_edit.py @@ -0,0 +1,66 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +from .utils.functions import get_mouse_frame_and_channel +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description + + +class POWER_SEQUENCER_OT_trim_three_point_edit(bpy.types.Operator): + """ + Trim the closest strip under the mouse cursor in or out + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ({"type": "I", "value": "PRESS"}, {"side": "LEFT"}, "Trim In"), + ({"type": "O", "value": "PRESS"}, {"side": "RIGHT"}, "Trim Out"), + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + side: bpy.props.EnumProperty( + items=[("LEFT", "Left", "Left side"), ("RIGHT", "Right", "Right side")], + name="Trim side", + description="Side of the strip(s) to trim, either LEFT or RIGHT", + default="LEFT", + ) + + @classmethod + def poll(cls, context): + return context.sequences + + def invoke(self, context, event): + frame, channel = get_mouse_frame_and_channel(context, event) + bpy.ops.sequencer.select_all(action="DESELECT") + bpy.ops.power_sequencer.select_closest_to_mouse(frame=frame, channel=channel) + if not context.selected_sequences: + bpy.ops.power_sequencer.select_strips_under_cursor() + return self.execute(context) + + def execute(self, context): + if not context.selected_sequences: + return {"CANCELLED"} + bpy.ops.power_sequencer.trim_left_or_right_handles(side=self.side) + return {"FINISHED"} diff --git a/power_sequencer/operators/trim_to_surrounding_cuts.py b/power_sequencer/operators/trim_to_surrounding_cuts.py new file mode 100644 index 00000000..3f98cc8c --- /dev/null +++ b/power_sequencer/operators/trim_to_surrounding_cuts.py @@ -0,0 +1,164 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +""" +Find the two closest cuts, trims and deletes all strips above in the range but leaves some +margin. Removes the newly formed gap. +""" +import bpy +from math import floor + +from .utils.functions import convert_duration_to_frames +from .utils.doc import doc_name, doc_idname, doc_brief, doc_description +from .utils.functions import ( + find_closest_surrounding_cuts_frames, + sequencer_workaround_2_80_audio_bug, +) + + +class POWER_SEQUENCER_OT_trim_to_surrounding_cuts(bpy.types.Operator): + """ + *Brief* Automatically trim to surrounding cuts with some time offset + + Finds the two cuts closest to the mouse cursor and trims the footage in between, leaving a little time offset. It's useful after you removed some bad audio but you need to keep some video around for a transition. + By default, the tool leaves 0.2 a seconds margin on either side of the trim. + """ + + doc = { + "name": doc_name(__qualname__), + "demo": "", + "description": doc_description(__doc__), + "shortcuts": [ + ( + {"type": "LEFTMOUSE", "value": "PRESS", "shift": True, "alt": True}, + {}, + "Trim to Surrounding Cuts", + ) + ], + "keymap": "Sequencer", + } + bl_idname = doc_idname(__qualname__) + bl_label = doc["name"] + bl_description = doc_brief(doc["description"]) + bl_options = {"REGISTER", "UNDO"} + + margin: bpy.props.FloatProperty( + name="Trim margin", + description="Margin to leave on either sides of the trim in seconds", + default=0.2, + min=0, + ) + gap_remove: bpy.props.BoolProperty( + name="Remove gaps", + description="When trimming the sequences, remove gaps automatically", + default=True, + ) + + @classmethod + def poll(cls, context): + return context + + def invoke(self, context, event): + if not context.sequences: + return {"CANCELLED"} + + sequencer = bpy.ops.sequencer + + # Convert mouse position to frame, channel + x = context.region.view2d.region_to_view(x=event.mouse_region_x, y=event.mouse_region_y)[0] + frame = round(x) + + left_cut_frame, right_cut_frame = find_closest_surrounding_cuts_frames(context, frame) + surrounding_cut_frames_duration = abs(left_cut_frame - right_cut_frame) + + margin_frame = convert_duration_to_frames(context, self.margin) + + if surrounding_cut_frames_duration <= margin_frame * 2: + self.report( + {"WARNING"}, + ("The trim margin is larger than the gap\n" "Use snap trim or reduce the margin"), + ) + return {"CANCELLED"} + + to_delete, to_trim = self.find_strips_in_range(context, left_cut_frame, right_cut_frame) + trim_start, trim_end = (left_cut_frame + margin_frame, right_cut_frame - margin_frame) + + for s in to_trim: + # If the strip is larger than the range to trim cut it in three + if s.frame_final_start < trim_start and s.frame_final_end > trim_end: + sequencer.select_all(action="DESELECT") + s.select = True + sequencer.cut(frame=trim_start, type="SOFT", side="RIGHT") + sequencer.cut(frame=trim_end, type="SOFT", side="LEFT") + to_delete.append(context.selected_sequences[0]) + continue + + if s.frame_final_start < trim_end and s.frame_final_end > trim_end: + s.frame_final_start = trim_end + elif s.frame_final_end > trim_start and s.frame_final_start < trim_start: + s.frame_final_end = trim_start + + # Delete all sequences that are between the cuts + sequencer.select_all(action="DESELECT") + for s in to_delete: + s.select = True + sequencer.delete() + + if self.gap_remove: + frame_to_remove_gap = right_cut_frame - 1 if frame == right_cut_frame else frame + # bpy.ops.anim.change_frame(frame_to_remove_gap) + context.scene.frame_current = frame_to_remove_gap + bpy.ops.power_sequencer.gap_remove() + context.scene.frame_current = trim_start + + # FIXME: Workaround Blender 2.80's audio bug, remove when fixed in Blender + sequencer_workaround_2_80_audio_bug(context) + + return {"FINISHED"} + + def find_strips_in_range( + self, context, start_frame, end_frame, sequences=None, find_overlapping=True + ): + """ + Returns strips which start and end within a certain frame range, or that overlap a + certain frame range + Args: + - start_frame, the start of the frame range + - end_frame, the end of the frame range + - sequences (optional): only work with these sequences. + If it doesn't receive any, the function works with all the sequences in the current context + - find_overlapping (optional): find and return a list of strips that overlap the + frame range + + Returns a tuple of two lists: + [0], strips entirely in the frame range + [1], strips that only overlap the frame range + """ + strips_in_range = [] + strips_overlapping_range = [] + if not sequences: + sequences = context.sequences + for s in sequences: + if start_frame <= s.frame_final_start <= end_frame: + if start_frame <= s.frame_final_end <= end_frame: + strips_in_range.append(s) + elif find_overlapping: + strips_overlapping_range.append(s) + elif find_overlapping and start_frame <= s.frame_final_end <= end_frame: + strips_overlapping_range.append(s) + if s.frame_final_start < start_frame and s.frame_final_end > end_frame: + strips_overlapping_range.append(s) + return strips_in_range, strips_overlapping_range diff --git a/power_sequencer/operators/utils/__init__.py b/power_sequencer/operators/utils/__init__.py new file mode 100644 index 00000000..ad15033c --- /dev/null +++ b/power_sequencer/operators/utils/__init__.py @@ -0,0 +1,16 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# diff --git a/power_sequencer/operators/utils/doc.py b/power_sequencer/operators/utils/doc.py new file mode 100644 index 00000000..82a87581 --- /dev/null +++ b/power_sequencer/operators/utils/doc.py @@ -0,0 +1,58 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +""" +Utilities to convert operator names and docstrings to human-readable text. +Used to generate names for Blender's operator search, and to generate Power Sequencer's documentation. +""" +import re + + +upper_match = lambda m: m.string + + +def doc_idname(s): + """ + Returns the id_name of the operator to register shortcuts in Blender's keymaps or call from other operators. + """ + out = ".".join(map(str.lower, s.split("_OT_"))) + return out + + +def doc_name(s): + """ + Returns the operator's name in a human readable format for Blender's operator search. + Removes POWER_SEQUENCER_OT_ from an operator's identifier + and converts it to title case. + """ + out = s.split("_OT")[-1] + out = out.replace("_", " ").lstrip().title() + return out + + +def doc_brief(s): + """ + Returns the first line of an operator's docstring to use as a summary of how the operator works. + The line in question must contain *brief*. + """ + return " ".join(s.split("\n\n")[0].split()[1:]) if s.startswith("*brief*") else s + + +def doc_description(s): + """ + Returns the lines after the brief line in an operator's documentation strings. See doc_brief above. + """ + return "\n".join(map(lambda x: x.strip(), s.split("\n"))).strip() diff --git a/power_sequencer/operators/utils/draw.py b/power_sequencer/operators/utils/draw.py new file mode 100644 index 00000000..0d993a53 --- /dev/null +++ b/power_sequencer/operators/utils/draw.py @@ -0,0 +1,121 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +"""Drawing utilities. A list of functions to draw common elements""" +# import bgl +import blf +from gpu_extras.batch import batch_for_shader +from mathutils import Vector +import math + + +def get_color_gizmo_primary(context): + color = context.preferences.themes[0].user_interface.gizmo_primary + return _color_to_list(color) + + +def get_color_gizmo_secondary(context): + color = context.preferences.themes[0].user_interface.gizmo_secondary + return _color_to_list(color) + + +def get_color_axis_x(context): + color = context.preferences.themes[0].user_interface.axis_x + return _color_to_list(color) + + +def get_color_axis_y(context): + color = context.preferences.themes[0].user_interface.axis_y + return _color_to_list(color) + + +def get_color_axis_z(context): + color = context.preferences.themes[0].user_interface.axis_z + return _color_to_list(color) + + +def draw_line(shader, start, end, color=(1.0, 1.0, 1.0, 1.0)): + """Draws a line using two Vector-based points""" + batch = batch_for_shader(shader, "LINES", {"pos": (start, end)}) + + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_rectangle(shader, origin, size, color=(1.0, 1.0, 1.0, 1.0)): + vertices = ( + (origin.x, origin.y), + (origin.x + size.x, origin.y), + (origin.x, origin.y + size.y), + (origin.x + size.x, origin.y + size.y), + ) + indices = ((0, 1, 2), (2, 1, 3)) + batch = batch_for_shader(shader, "TRIS", {"pos": vertices}, indices=indices) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_triangle(shader, point_1, point_2, point_3, color=(1.0, 1.0, 1.0, 1.0)): + vertices = (point_1, point_2, point_3) + indices = ((0, 1, 2),) + batch = batch_for_shader(shader, "TRIS", {"pos": vertices}, indices=indices) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + +def draw_triangle_equilateral(shader, center, radius, rotation=0.0, color=(1.0, 1.0, 1.0, 1.0)): + points = [] + for i in range(3): + angle = i * math.pi * 2 / 3 + rotation + offset = Vector((radius * math.cos(angle), radius * math.sin(angle))) + points.append(center + offset) + draw_triangle(shader, *points, color) + + +def draw_text(x, y, size, text, justify="left", color=(1.0, 1.0, 1.0, 1.0)): + font_id = 0 + blf.color(font_id, *color) + if justify == "right": + text_width, text_height = blf.dimensions(font_id, text) + else: + text_width = 0 + blf.position(font_id, x - text_width, y, 0) + blf.size(font_id, size, 72) + blf.draw(font_id, text) + + +def draw_arrow_head(shader, center, size, points_right=True, color=(1.0, 1.0, 1.0, 1.0)): + """ + Draws a triangular arrow using two Vectors: + - the triangle's center + - the triangle's size + """ + direction = 1 if points_right else -1 + + point_upper = Vector([center.x - size.x / 2 * direction, center.y + size.y / 2]) + point_tip = Vector([center.x + size.x / 2 * direction, center.y]) + point_lower = Vector([center.x - size.x / 2 * direction, center.y - size.y / 2]) + + draw_line(shader, point_upper, point_tip, color) + draw_line(shader, point_tip, point_lower, color) + + +def _color_to_list(color): + """Converts a Blender Color to a list of 4 color values to use with shaders and drawing""" + return list(color) + [1.0] diff --git a/power_sequencer/operators/utils/functions.py b/power_sequencer/operators/utils/functions.py new file mode 100644 index 00000000..35fd900d --- /dev/null +++ b/power_sequencer/operators/utils/functions.py @@ -0,0 +1,343 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import subprocess +from math import sqrt, floor +from operator import attrgetter +from .global_settings import SequenceTypes + + +def calculate_distance(x1, y1, x2, y2): + return sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + + +def convert_duration_to_frames(context, duration): + return round(duration * context.scene.render.fps / context.scene.render.fps_base) + + +def find_linked(context, sequences, selected_sequences): + """ + Takes a list of sequences and returns a list of all the sequences + and effects that are linked in time + + Args: + - sequences: a list of sequences + + Returns a list of all the linked sequences, but not the sequences passed to the function + """ + start, end = get_frame_range(context, sequences, selected_sequences) + sequences_in_range = [s for s in sequences if is_in_range(context, s, start, end)] + effects = (s for s in sequences_in_range if s.type in SequenceTypes.EFFECT) + selected_effects = (s for s in sequences if s.type in SequenceTypes.EFFECT) + + linked_sequences = [] + + # Filter down to effects that have at least one of seq as input and + # Append input sequences that aren't in the source list to linked_sequences + for e in effects: + if not hasattr(e, "input_2"): + continue + for s in sequences: + if e.input_2 == s: + linked_sequences.append(e) + if e.input_1 not in sequences: + linked_sequences.append(e.input_1) + elif e.input_1 == s: + linked_sequences.append(e) + if e.input_2 not in sequences: + linked_sequences.append(e.input_2) + + # Find inputs of selected effects that are not selected + for e in selected_effects: + try: + if e.input_1 not in sequences: + linked_sequences.append(e.input_1) + if e.input_count == 2: + if e.input_2 not in sequences: + linked_sequences.append(e.input_2) + except AttributeError: + continue + + return linked_sequences + + +def find_neighboring_markers(context, frame=None): + """Returns a tuple containing the closest marker to the left and to the right of the frame""" + markers = context.scene.timeline_markers + + if not (frame and markers): + return None, None + + markers = sorted(markers, key=attrgetter("frame")) + + previous_marker, next_marker = None, None + for m in markers: + previous_marker = m if m.frame < frame else previous_marker + if m.frame > frame: + next_marker = m + break + + return previous_marker, next_marker + + +def find_sequences_after(context, sequence): + """ + Finds the strips following the sequences passed to the function + Args: + - Sequences, the sequences to check + Returns all the strips after the sequence in the current context + """ + return [s for s in context.sequences if s.frame_final_start > sequence.frame_final_start] + + +def find_snap_candidate(context, frame=0): + """ + Returns the cut frame closest to the `frame` argument + """ + snap_candidate = 1000000 + + for s in context.sequences: + start_to_frame = frame - s.frame_final_start + end_to_frame = frame - s.frame_final_end + + distance_to_start = abs(start_to_frame) + distance_to_end = abs(end_to_frame) + + candidate = ( + frame - start_to_frame + if min(distance_to_start, distance_to_end) == distance_to_start + else frame - end_to_frame + ) + + if abs(frame - candidate) < abs(frame - snap_candidate): + snap_candidate = candidate + + return snap_candidate + + +def find_strips_mouse(context, frame, channel, select_linked=False): + """ + Finds a list of sequences to select based on the frame and channel the mouse cursor is at + + Args: + - frame: the frame the mouse or cursor is on + - channel: the channel the mouse is hovering + - select_linked: find and append the sequences linked in time if True + + Returns the sequence(s) under the mouse cursor as a list + Returns an empty list if nothing found + """ + sequences = [ + s + for s in context.sequences + if not s.lock and s.channel == channel and s.frame_final_start <= frame <= s.frame_final_end + ] + if select_linked: + linked_strips = [ + s + for s in context.sequences + if s.frame_final_start == sequences[0].frame_final_start + and s.frame_final_end == sequences[0].frame_final_end + ] + sequences.extend(linked_strips) + return sequences + + +def get_frame_range(context, sequences=[], get_from_start=False): + """ + Returns a tuple with the minimum and maximum frames of the + list of passed sequences. + Args: + - sequences, the sequences to use + - get_from_start, the returned start frame is set to 1 if + this boolean is True + """ + start, end = -1, -1 + start = ( + 1 + if get_from_start + else min(sequences, key=attrgetter("frame_final_start")).frame_final_start + ) + end = max(sequences, key=attrgetter("frame_final_end")).frame_final_end + return start, end + + +def get_mouse_frame_and_channel(context, event): + """ + Convert mouse coordinates from the event, from + pixels to frame, channel. + Returns a tuple of frame, channel as integers + """ + view2d = context.region.view2d + frame, channel = view2d.region_to_view(event.mouse_region_x, event.mouse_region_y) + return round(frame), floor(channel) + + +def is_in_range(context, sequence, start, end): + """ + Checks if a single sequence's start or end is in the range + + Args: + - sequence: the sequence to check for + - start, end: the start and end frames + Returns True if the sequence is within the range, False otherwise + """ + s_start = sequence.frame_final_start + s_end = sequence.frame_final_end + return start <= s_start <= end or start <= s_end <= end + + +def set_preview_range(context, start, end): + """Sets the preview range and timeline render range""" + if not (start and end) and start != 0: + raise AttributeError("Missing start or end parameter") + + scene = context.scene + + scene.frame_start = start + scene.frame_end = end + scene.frame_preview_start = start + scene.frame_preview_end = end + + +def slice_selection(context, sequences): + """ + Takes a list of sequences and breaks it down + into multiple lists of connected sequences + + Returns a list of lists of sequences, + each list corresponding to a block of sequences + that are connected in time and sorted by frame_final_start + """ + # Find when 2 sequences are not connected in time + if not sequences: + return [] + + break_ids = [0] + sorted_sequences = sorted(sequences, key=attrgetter("frame_final_start")) + last_sequence = sorted_sequences[0] + last_biggest_frame_end = last_sequence.frame_final_end + index = 0 + for s in sorted_sequences: + if s.frame_final_start > last_biggest_frame_end + 1: + break_ids.append(index) + last_biggest_frame_end = max(last_biggest_frame_end, s.frame_final_end) + last_sequence = s + index += 1 + + # Create lists + break_ids.append(len(sorted_sequences)) + cuts_count = len(break_ids) - 1 + broken_selection = [] + index = 0 + while index < cuts_count: + temp_list = [] + index_range = range(break_ids[index], break_ids[index + 1] - 1) + if len(index_range) == 0: + temp_list.append(sorted_sequences[break_ids[index]]) + else: + for counter in range(break_ids[index], break_ids[index + 1]): + temp_list.append(sorted_sequences[counter]) + if temp_list: + broken_selection.append(temp_list) + index += 1 + return broken_selection + + +def trim_strips( + context, start_frame, end_frame, select_mode, strips_to_trim=[], strips_to_delete=[] +): + """ + Remove the footage and audio between start_frame and end_frame. + """ + trim_start = min(start_frame, end_frame) + trim_end = max(start_frame, end_frame) + + strips_to_trim = [s for s in strips_to_trim if s.type in SequenceTypes.CUTABLE] + + for s in strips_to_trim: + if s.frame_final_start < trim_start and s.frame_final_end > trim_end: + bpy.ops.sequencer.select_all(action="DESELECT") + s.select = True + bpy.ops.sequencer.cut(frame=trim_start, type="SOFT", side="RIGHT") + bpy.ops.sequencer.cut(frame=trim_end, type="SOFT", side="LEFT") + strips_to_delete.append(context.selected_sequences[0]) + continue + elif s.frame_final_start < trim_end and s.frame_final_end > trim_end: + s.frame_final_start = trim_end + elif s.frame_final_end > trim_start and s.frame_final_start < trim_start: + s.frame_final_end = trim_start + + if strips_to_delete != []: + bpy.ops.sequencer.select_all(action="DESELECT") + for s in strips_to_delete: + s.select = True + bpy.ops.sequencer.delete() + return {"FINISHED"} + + +def find_closest_surrounding_cuts(context, frame): + """ + Returns a tuple of (strip_before, strip_after), the two closest sequences around a gap. + If the frame is in the middle of a strip, both strips may be the same. + """ + strip_before = max( + context.sequences, + key=lambda s: s.frame_final_end + if s.frame_final_end <= frame + else s.frame_final_start + if s.frame_final_start <= frame + else 0, + ) + strip_after = min( + context.sequences, + key=lambda s: s.frame_final_start + if s.frame_final_start >= frame + else s.frame_final_end + if s.frame_final_end >= frame + else 1000000, + ) + return strip_before, strip_after + + +def find_closest_surrounding_cuts_frames(context, frame): + before, after = find_closest_surrounding_cuts(context, frame) + if before == after: + frame_left, frame_right = before.frame_final_start, before.frame_final_end + else: + frame_left, frame_right = before.frame_final_end, after.frame_final_start + return frame_left, frame_right + + +def get_sequences_under_cursor(context): + frame = context.scene.frame_current + under_cursor = [ + s + for s in context.sequences + if s.frame_final_start <= frame and s.frame_final_end >= frame and not s.lock + ] + return under_cursor + + +def sequencer_workaround_2_80_audio_bug(context): + for s in context.sequences: + if s.lock: + continue + s.select = True + bpy.ops.transform.seq_slide(value=(0, 0)) + s.select = False + break diff --git a/power_sequencer/operators/utils/global_settings.py b/power_sequencer/operators/utils/global_settings.py new file mode 100644 index 00000000..88590783 --- /dev/null +++ b/power_sequencer/operators/utils/global_settings.py @@ -0,0 +1,110 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +class ProjectSettings: + RESOLUTION_X = 1920 + RESOLUTION_Y = 1080 + PROXY_RESOLUTION_X = 640 + PROXY_RESOLUTION_Y = 360 + PROXY_STRING = "_proxy" + + class FOLDER_NAMES: + AUDIO = "audio" + IMG = "img" + VIDEO = "video" + IMG_ASSETS = "-assets" + + def __dir__(self): + return self.FOLDER_NAMES.AUDIO, self.FOLDER_NAMES.IMG, self.FOLDER_NAMES.VIDEO + + +class SequenceTypes: + """ + Tuples of identifiers to check if a strip is of a certain type or type group + """ + + VIDEO = ("MOVIE", "MOVIECLIP", "META", "SCENE") + EFFECT = ( + "CROSS", + "ADD", + "SUBTRACT", + "ALPHA_OVER", + "ALPHA_UNDER", + "GAMMA_CROSS", + "MULTIPLY", + "OVER_DROP", + "WIPE", + "GLOW", + "TRANSFORM", + "COLOR", + "SPEED", + "ADJUSTMENT", + "GAUSSIAN_BLUR", + ) + TRANSITION = ("CROSS", "GAMMA_CROSS", "WIPE") + SOUND = ("SOUND",) + IMAGE = ("IMAGE",) + TRANSITIONABLE = ( + VIDEO + IMAGE + ("MULTICAM", "GAUSSIAN_BLUR", "TRANSFORM", "ADJUSTMENT", "SPEED") + ) + # Strips that can be cut. If most effect strips are linked to their inputs + # and shouldn't be cut, some can be edited directly + CUTABLE = VIDEO + SOUND + IMAGE + ("MULTICAM", "COLOR", "ADJUSTMENT") + + +EXTENSIONS_IMG = ( + "jpeg", + "jpg", + "png", + "tga", + "tiff", + "tif", + "exr", + "hdr", + "bmp", + "cin", + "dpx", + "psd", +) +EXTENSIONS_AUDIO = (".wav", ".mp3", ".ogg", ".flac", ".opus") +EXTENSIONS_VIDEO = ( + ".mp4", + ".avi", + ".mts", + ".flv", + ".mkv", + ".mov", + ".mpg", + ".mpeg", + ".vob", + ".ogv", + "webm", +) +EXTENSIONS_ALL = tuple(list(EXTENSIONS_IMG) + list(EXTENSIONS_AUDIO) + list(EXTENSIONS_VIDEO)) + + +class Extensions: + """ + Tuples of file types for checks when importing files + """ + + DICT = {"img": EXTENSIONS_IMG, "audio": EXTENSIONS_AUDIO, "video": EXTENSIONS_VIDEO} + + +class SearchMode: + NEXT = 1 + CHANNEL = 2 + ALL = 3 diff --git a/power_sequencer/operators/utils/info_progress_bar.py b/power_sequencer/operators/utils/info_progress_bar.py new file mode 100644 index 00000000..a3049bf5 --- /dev/null +++ b/power_sequencer/operators/utils/info_progress_bar.py @@ -0,0 +1,72 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import time + + +class InfoProgressBar: + """ + Draws a progress bar in the info header area + """ + + def __init__(self, progress_min=0, progress_max=100): + assert progress_min < progress_max + + self.progress_min = progress_min + self.progress_max = progress_max + + self._progress = bpy.props.FloatProperty( + default=self.progress_min, + min=self.progress_min, + max=self.progress_max, + subtype="PERCENTAGE", + ) + self._visible = False + + def update(self, context): + for area in context.screen.areas: + if area.type == "INFO": + area.tag_redraw() + + def draw(self): + if self.progress >= self.progress_max: + self.visible = False + else: + self.layout.prop(self, "_progress", text="Progress", slider=True) + + @property + def progress(self): + return self._progress + + @progress.setter + def progress(self, value): + self._progress = min(max(self.progress_min, value), self.progress_max) + + @property + def visible(self): + return self._visible + + @visible.setter + def visible(self, value): + self._visible = value + + if self._visible: + bpy.types.INFO_HT_header.append(self.draw) + bpy.app.handlers.scene_update_post.add(self.update) + else: + bpy.types.INFO_HT_header.remove(self.draw) + bpy.app.handlers.scene_update_post.remove(self.update) diff --git a/power_sequencer/scripts/BPSProxy/bpsproxy/__init__.py b/power_sequencer/scripts/BPSProxy/bpsproxy/__init__.py new file mode 100644 index 00000000..f14cfb6a --- /dev/null +++ b/power_sequencer/scripts/BPSProxy/bpsproxy/__init__.py @@ -0,0 +1,16 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# diff --git a/power_sequencer/scripts/BPSProxy/bpsproxy/__main__.py b/power_sequencer/scripts/BPSProxy/bpsproxy/__main__.py new file mode 100644 index 00000000..d8f25204 --- /dev/null +++ b/power_sequencer/scripts/BPSProxy/bpsproxy/__main__.py @@ -0,0 +1,171 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +""" +Tool to render video proxies using FFMPEG +Offers mp4 and webm options +""" +import argparse as ap +import glob as g +import logging as lg +import os.path as osp +import sys +from itertools import compress, starmap, tee + +from .call import call, call_makedirs +from .commands import get_commands, get_commands_vi +from .config import CONFIG as C +from .config import LOGGER, LOGLEV +from .utils import checktools, printw, printd, prints, ToolError + + +def find_files( + directory=".", ignored_directory=C["proxy_directory"], extensions=C["extensions"]["all"] +): + """ + Find files to process. + + Parameters + ---------- + directory: str + Working directory. + ignored_directory: str + Don't check for files in this directory. By default `BL_proxy`. + extensions: set(str) + Set of file extensions for filtering the directory tree. + + Returns + ------- + out: list(str) + List of file paths to be processed. + """ + if not osp.isdir(directory): + raise ValueError(("The given path '{}' is not a valid directory.".format(directory))) + xs = g.iglob("{}/**".format(osp.abspath(directory)), recursive=True) + xs = filter(lambda x: osp.isfile(x), xs) + xs = filter(lambda x: ignored_directory not in osp.dirname(x), xs) + xs = [x for x in xs if osp.splitext(x)[1].lower() in extensions] + return xs + + +def parse_arguments(cfg): + """ + Uses `argparse` to parse the command line arguments. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + + Returns + ------- + out: Namespace + Command line arguments. + """ + p = ap.ArgumentParser(description="Create proxies for Blender VSE using FFMPEG.") + p.add_argument( + "working_directory", + nargs="?", + default=".", + help="The directory containing media to create proxies for", + ) + p.add_argument( + "-p", + "--preset", + default="mp4", + choices=cfg["presets"], + help="a preset name for proxy encoding", + ) + p.add_argument( + "-s", + "--sizes", + nargs="+", + type=int, + default=[25], + choices=cfg["proxy_sizes"], + help="A list of sizes of the proxies to render, either 25, 50, or 100", + ) + p.add_argument( + "-v", "--verbose", action="count", default=0, help="Increase verbosity level (eg. -vvv)." + ) + p.add_argument( + "--dry-run", + action="store_true", + help=( + "Run the script without actual rendering or creating files and" + " folders. For DEBUGGING purposes" + ), + ) + + clargs = p.parse_args() + # normalize directory + clargs.working_directory = osp.abspath(clargs.working_directory) + # --dry-run implies maximum verbosity level + clargs.verbose = 99999 if clargs.dry_run else clargs.verbose + return clargs + + +def main(): + """ + Script entry point. + """ + tools = ["ffmpeg", "ffprobe"] + try: + # get command line arguments and set log level + clargs = parse_arguments(C) + lg.basicConfig(level=LOGLEV[min(clargs.verbose, len(LOGLEV) - 1)]) + + # log basic command line arguments + clargs.dry_run and LOGGER.info("DRY-RUN") + LOGGER.info("WORKING-DIRECTORY :: {}".format(clargs.working_directory)) + LOGGER.info("PRESET :: {}".format(clargs.preset)) + LOGGER.info("SIZES :: {}".format(clargs.sizes)) + + # check for external dependencies + checktools(tools) + + # find files to process + path_i = find_files(clargs.working_directory) + kwargs = {"path_i": path_i} + + printw(C, "Creating directories if necessary") + call_makedirs(C, clargs, **kwargs) + + printw(C, "Checking for existing proxies") + cmds = tee(get_commands(C, clargs, what="check", **kwargs)) + stdouts = call(C, clargs, cmds=cmds[0], check=False, shell=True, **kwargs) + checks = map(lambda s: s.strip().split(), stdouts) + checks = starmap(lambda fst, *tail: not all(fst == t for t in tail), checks) + kwargs["path_i"] = list(compress(kwargs["path_i"], checks)) + + if len(kwargs["path_i"]) != 0: + printw(C, "Processing", s="\n") + cmds = get_commands_vi(C, clargs, **kwargs) + call(C, clargs, cmds=cmds, **kwargs) + else: + printd(C, "All proxies exist or no files found, nothing to process", s="\n") + printd(C, "Done") + except (ToolError, ValueError) as e: + LOGGER.error(e) + prints(C, "Exiting") + except KeyboardInterrupt: + prints(C, "DirtyInterrupt. Exiting", s="\n\n") + sys.exit() + + +# this is so it can be ran as a module: `python3 -m bpsrender` (for testing) +if __name__ == "__main__": + main() diff --git a/power_sequencer/scripts/BPSProxy/bpsproxy/call.py b/power_sequencer/scripts/BPSProxy/bpsproxy/call.py new file mode 100644 index 00000000..dd74e3a8 --- /dev/null +++ b/power_sequencer/scripts/BPSProxy/bpsproxy/call.py @@ -0,0 +1,95 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +# import multiprocessing as mp +import os +import subprocess as sp +import sys + +from functools import partial +from itertools import chain, tee +from tqdm import tqdm +from .config import LOGGER +from .utils import get_dir, kickstart + +WINDOWS = ("win32", "cygwin") + + +def call_makedirs(cfg, clargs, **kwargs): + """ + Make BL_proxy directories if necessary. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments. + kwargs: dict + MANDATORY: path_i + Dictionary with additional information from previous step. + """ + path_i = kwargs["path_i"] + path_d = map(partial(get_dir, cfg, clargs, **kwargs), path_i) + path_d = tee(chain(*path_d)) + kickstart(map(lambda p: LOGGER.info("Directory @ {}".format(p)), path_d[0])) + if clargs.dry_run: + return + path_d = (os.makedirs(p, exist_ok=True) for p in path_d[1]) + kickstart(path_d) + + +def call(cfg, clargs, *, cmds, **kwargs): + """ + Generic subprocess calls. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments. + cmds: iter(tuple(str)) + kwargs: dict + MANDATORY: path_i + Dictionary with additional information from previous step. + + Returns + ------- + out: str + Stdout & Stderr gathered from subprocess call. + """ + kwargs_s = { + "stdout": sp.PIPE, + "stderr": sp.STDOUT, + "universal_newlines": True, + "check": kwargs.get("check", True), + "shell": kwargs.get("shell", False), + "creationflags": sp.CREATE_NEW_PROCESS_GROUP if sys.platform in WINDOWS else 0, + } + if kwargs_s["shell"]: + cmds = map(lambda cmd: (cmd[0], " ".join(cmd[1])), cmds) + cmds = tee(cmds) + kickstart(map(lambda cmd: LOGGER.debug("CALL :: {}".format(cmd[1])), cmds[0])) + if clargs.dry_run: + return [] + n = len(kwargs["path_i"]) + ps = tqdm( + map(lambda cmd: sp.run(cmd[1], **kwargs_s), cmds[1]), + total=n, + unit="file" if n == 1 else "files", + ) + return [p.stdout for p in ps] diff --git a/power_sequencer/scripts/BPSProxy/bpsproxy/commands.py b/power_sequencer/scripts/BPSProxy/bpsproxy/commands.py new file mode 100644 index 00000000..d481c58f --- /dev/null +++ b/power_sequencer/scripts/BPSProxy/bpsproxy/commands.py @@ -0,0 +1,190 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import os.path as osp +import shlex as sl +from itertools import chain +from .utils import get_path + + +def get_commands_check(cfg, clargs, **kwargs): + """ + ffprobe subprocess command generation. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments. + cmds: iter(tuple(str)) + kwargs: dict + MANDATORY: path_i_1, path_o_1 + Dictionary with additional information from previous step. + + Returns + ------- + out: iter(tuple(str)) + Iterator containing commands. + """ + cmd = ( + "ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of" + " default=noprint_wrappers=1:nokey=1 '{file}'" + ) + out = map(lambda s: kwargs["path_o_1"].format(size=s), clargs.sizes) + out = map(lambda f: cmd.format(file=f), out) + out = sl.split(cmd.format(file=kwargs["path_i_1"]) + " && " + " && ".join(out)) + return iter((out,)) + + +def get_commands_image_1(cfg, clargs, **kwargs): + """ + ffmpeg subprocess command generation for processing an image. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments. + cmds: iter(tuple(str)) + kwargs: dict + MANDATORY: path_i_1, path_o_1 + Dictionary with additional information from previous step. + + Returns + ------- + out: iter(tuple(str)) + Iterator containing commands. + """ + cmd = "ffmpeg -y -v quiet -stats -i '{path_i_1}' {common_all}" + common = "-f apng -filter:v scale=iw*{size}:ih*{size} '{path_o_1}'" + common_all = map(lambda s: kwargs["path_o_1"].format(size=s), clargs.sizes) + common_all = map( + lambda s: common.format(size=s[0] / 100.0, path_o_1=s[1]), zip(clargs.sizes, common_all) + ) + common_all = " ".join(common_all) + out = sl.split(cmd.format(path_i_1=kwargs["path_i_1"], common_all=common_all)) + return iter((out,)) + + +def get_commands_video_1(cfg, clargs, **kwargs): + """ + ffmpeg subprocess command generation for processing a video. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments. + cmds: iter(tuple(str)) + kwargs: dict + MANDATORY: path_i_1, path_o_1 + Dictionary with additional information from previous step. + + Returns + ------- + out: iter(tuple(str)) + Iterator containing commands. + """ + cmd = "ffmpeg -y -v quiet -stats -i '{path_i_1}' {common_all}" + common = ( + "-pix_fmt yuv420p" + " -g 1" + " -sn -an" + " -vf colormatrix=bt601:bt709" + " -vf scale=ceil(iw*{size}/2)*2:ceil(ih*{size}/2)*2" + " {preset}" + " '{path_o_1}'" + ) + common_all = map(lambda s: kwargs["path_o_1"].format(size=s), clargs.sizes) + common_all = map( + lambda s: common.format( + preset=cfg["presets"][clargs.preset], size=s[0] / 100.0, path_o_1=s[1] + ), + zip(clargs.sizes, common_all), + ) + common_all = " ".join(common_all) + out = sl.split(cmd.format(path_i_1=kwargs["path_i_1"], common_all=common_all)) + return iter((out,)) + + +def get_commands(cfg, clargs, *, what, **kwargs): + """ + Delegates the creation of commands lists to appropriate functions based on `what` parameter. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments. + cmds: iter(tuple(str)) + what: str + Determines the returned value (see: Returns[out]). + kwargs: dict + MANDATORY: path_i + Dictionary with additional information from previous step. + + Returns + ------- + out: iter(tuple(str, tuple(str))) + An iterator with the 1st element as a tag (the `what` parameter) and the 2nd + element as the iterator of the actual commands. + """ + get_commands_f = { + "video": get_commands_video_1, + "image": get_commands_image_1, + "check": get_commands_check, + } + ps = ( + kwargs["path_i"] + if what not in cfg["extensions"] + else filter( + lambda p: osp.splitext(p)[1].lower() in cfg["extensions"][what], kwargs["path_i"] + ) + ) + ps = map(lambda p: (p, get_path(cfg, clargs, p, **kwargs)), ps) + out = chain.from_iterable( + map(lambda p: get_commands_f[what](cfg, clargs, path_i_1=p[0], path_o_1=p[1], **kwargs), ps) + ) + return map(lambda c: (what, c), out) + + +def get_commands_vi(cfg, clargs, **kwargs): + """ + Delegates the creation of commands lists to appropriate functions for video/image processing. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments. + cmds: iter(tuple(str)) + kwargs: dict + MANDATORY: path_i_1, path_o_1 + Dictionary with additional information from previous step. + + Returns + ------- + out: iter(tuple(str, tuple(str))) + An iterator with the 1st element as a tag (the `what` parameter) and the 2nd + element as the iterator of the actual commands. + """ + ws = filter(lambda x: x is not "all", cfg["extensions"]) + return chain.from_iterable(map(lambda w: get_commands(cfg, clargs, what=w, **kwargs), ws)) diff --git a/power_sequencer/scripts/BPSProxy/bpsproxy/config.py b/power_sequencer/scripts/BPSProxy/bpsproxy/config.py new file mode 100644 index 00000000..eada47d5 --- /dev/null +++ b/power_sequencer/scripts/BPSProxy/bpsproxy/config.py @@ -0,0 +1,41 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import multiprocessing as mp +from itertools import chain +import logging as lg + + +CONFIG = { + "logger": "BPS", + "proxy_directory": "BL_proxy", + "proxy_sizes": (25, 50, 100), + "extensions": { + "video": {".mp4", ".mkv", ".mov", ".flv", ".mts"}, + "image": {".png", ".jpg", ".jpeg"}, + }, + "presets": { + "webm": "-c:v libvpx -crf 25 -speed 16 -threads {}".format(str(mp.cpu_count())), + "mp4": "-c:v libx264 -crf 25 -preset faster", + "nvenc": "-c:v h264_nvenc -qp 25 -preset fast", + }, + "pre": {"work": "»", "done": "•", "skip": "~"}, +} +CONFIG["extensions"]["all"] = set(chain(*CONFIG["extensions"].values())) + +LOGGER = lg.getLogger(CONFIG["logger"]) +LOGLEV = [lg.INFO, lg.DEBUG] +LOGLEV = [None] + sorted(LOGLEV, reverse=True) diff --git a/power_sequencer/scripts/BPSProxy/bpsproxy/utils.py b/power_sequencer/scripts/BPSProxy/bpsproxy/utils.py new file mode 100644 index 00000000..832a0beb --- /dev/null +++ b/power_sequencer/scripts/BPSProxy/bpsproxy/utils.py @@ -0,0 +1,109 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +""" +Collection of utility functions, class-independent +""" +import os.path as osp +from collections import deque +from shutil import which + + +class ToolError(Exception): + """Raised if external dependencies aren't found on system. + """ + + pass + + +def checktools(tools): + tools = [(t, which(t) or "") for t in tools] + check = {"tools": tools, "test": all(map(lambda x: x[1], tools))} + if not check["test"]: + msg = ["BPSProxy couldn't find external dependencies:"] + msg += [ + "[{check}] {tool}: {path}".format( + check="v" if path is not "" else "X", tool=tool, path=path or "NOT FOUND" + ) + for tool, path in check["tools"] + ] + msg += [ + ( + "Check if you have them properly installed and available in the PATH" + " environemnt variable." + ) + ] + raise ToolError("\n".join(msg)) + + +def get_path_video(cfg, clargs, path, **kwargs): + return osp.join( + osp.dirname(path), cfg["proxy_directory"], osp.basename(path), "proxy_{size}.avi" + ) + + +def get_path_image(cfg, clargs, path, **kwargs): + return osp.join( + osp.dirname(path), + cfg["proxy_directory"], + "images", + "{size}", + "{file}_proxy.jpg".format(file=osp.basename(path)), + ) + + +def get_path(cfg, clargs, path, **kwargs): + get_path_f = {"video": get_path_video, "image": get_path_image} + what = what_vi(cfg, clargs, path, **kwargs) + return get_path_f[what](cfg, clargs, path, **kwargs) + + +def get_dir_video(cfg, clargs, path, **kwargs): + return iter((osp.join(osp.dirname(path), cfg["proxy_directory"], osp.basename(path)),)) + + +def get_dir_image(cfg, clargs, path, **kwargs): + ps = osp.join(osp.dirname(path), cfg["proxy_directory"], "images", "{size}") + return map(lambda s: ps.format(size=s), clargs.sizes) + + +def get_dir(cfg, clargs, path, **kwargs): + get_dir_f = {"video": get_dir_video, "image": get_dir_image} + what = what_vi(cfg, clargs, path, **kwargs) + return get_dir_f[what](cfg, clargs, path, **kwargs) + + +def what_vi(cfg, clargs, p, **kwargs): + return "video" if osp.splitext(p)[1].lower() in cfg["extensions"]["video"] else "image" + + +def kickstart(it): + deque(it, maxlen=0) + + +def printw(cfg, text, s="\n", e="...", p="", **kwargs): + p = p or cfg["pre"]["work"] + print("{s}{p} {}{e}".format(text, s=s, e=e, p=p), **kwargs) + + +def printd(cfg, text, s="", e=".", p="", **kwargs): + p = p or cfg["pre"]["done"] + printw(cfg, text, s=s, e=e, p=p, **kwargs) + + +def prints(cfg, text, s="", e=".", p="", **kwargs): + p = p or cfg["pre"]["skip"] + printw(cfg, text, s=s, e=e, p=p, **kwargs) diff --git a/power_sequencer/scripts/BPSProxy/setup.py b/power_sequencer/scripts/BPSProxy/setup.py new file mode 100644 index 00000000..22aacf60 --- /dev/null +++ b/power_sequencer/scripts/BPSProxy/setup.py @@ -0,0 +1,55 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +from setuptools import setup + + +def readme(): + with open("README.rst") as f: + return f.read() + + +setup( + name="bpsproxy", + version="0.1.3.post1", + description="Blender Power Sequencer proxy generator tool", + long_description=readme(), + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Natural Language :: English", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3", + "Topic :: Multimedia :: Video", + "Topic :: Utilities", + ], + url="https://gitlab.com/razcore/bpsproxy", + keywords="blender proxy vse sequence editor productivity", + author="Răzvan C. Rădulescu", + author_email="razcore.art@gmail.com", + license="GPLv3", + packages=["bpsproxy"], + install_requires=["tqdm"], + zip_safe=False, + entry_points={"console_scripts": ["bpsproxy=bpsproxy.__main__:main"]}, + include_package_data=True, +) diff --git a/power_sequencer/scripts/BPSRender/bpsrender/__init__.py b/power_sequencer/scripts/BPSRender/bpsrender/__init__.py new file mode 100644 index 00000000..35a40273 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/__init__.py @@ -0,0 +1,17 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# + diff --git a/power_sequencer/scripts/BPSRender/bpsrender/__main__.py b/power_sequencer/scripts/BPSRender/bpsrender/__main__.py new file mode 100644 index 00000000..07c84fe2 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/__main__.py @@ -0,0 +1,147 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +""" +Renders videos edited in Blender 3D's Video Sequence Editor using multiple CPU +cores. Original script by Justin Warren: +https://github.com/sciactive/pulverize/blob/master/pulverize.py +Modified by sudopluto (Pranav Sharma), gdquest (Nathan Lovato) and +razcore (Razvan Radulescu) + +Under GPLv3 license +""" +import argparse as ap +import os.path as osp +import sys +from functools import partial + +from .calls import call +from .config import CONFIG as C +from .config import LOGGER +from .helpers import BSError, ToolError, checktools, kickstart, prints +from .setup import setup + +# https://github.com/mikeycal/the-video-editors-render-script-for-blender#configuring-the-script +# there seems no easy way to grab the ram usage in a mulitplatform way +# without writing platform dependent code, or by using a python module + +# Most popluar config is 4 cores, 8 GB ram, this is the default for the script +# https://store.steampowered.com/hwsurvey/ + + +def parse_arguments(cfg): + """ + Uses `argparse` to parse the command line arguments. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + + Returns + ------- + out: Namespace + Command line arguments (normalized). + """ + p = ap.ArgumentParser( + description="Multi-process Blender VSE rendering - will attempt to" + " create a folder called `render` inside of the folder" + " containing `blendfile`. Insider `render` another folder called" + " `parts` will be created for storing temporary files. These files" + " will be joined together as the last step to produce the final" + " render which will be stored inside `render` and it will have the" + " same name as `blendfile`" + ) + p.add_argument( + "-o", + "--output", + default=".", + help="Output folder (will contain a `bpsrender` temp folder for" "rendering parts).", + ) + p.add_argument( + "-w", + "--workers", + type=int, + default=cfg["cpu_count"], + help="Number of workers in the pool (for video rendering).", + ) + p.add_argument( + "-v", "--verbose", action="count", default=0, help="Increase verbosity level (eg. -vvv)." + ) + p.add_argument( + "--dry-run", + action="store_true", + help=( + "Run the script without actual rendering or creating files and" + " folders. For DEBUGGING purposes" + ), + ) + p.add_argument("-s", "--start", type=int, default=None, help="Start frame") + p.add_argument("-e", "--end", type=int, default=None, help="End frame") + p.add_argument( + "-m", "--mixdown-only", action="store_true", help="ONLY render the audio MIXDOWN" + ) + p.add_argument( + "-c", + "--concatenate-only", + action="store_true", + help="ONLY CONCATENATE the (already) available video chunks", + ) + p.add_argument( + "-d", + "--video-only", + action="store_true", + help="ONLY render the VIDEO (implies --concatenate-only).", + ) + p.add_argument( + "-j", + "--join-only", + action="store_true", + help="ONLY JOIN the mixdown with the video. This will produce the" " final render", + ) + p.add_argument("blendfile", help="Blender project file to render.") + + clargs = p.parse_args() + clargs.blendfile = osp.abspath(clargs.blendfile) + clargs.output = osp.abspath(clargs.output) + # --video-only implies --concatenate-only + clargs.concatenate_only = clargs.concatenate_only or clargs.video_only + # --dry-run implies maximum verbosity level + clargs.verbose = 99999 if clargs.dry_run else clargs.verbose + return clargs + + +def main(): + """ + Script entry point. + """ + tools = ["blender", "ffmpeg"] + try: + clargs = parse_arguments(C) + checktools(tools) + cmds, kwargs = setup(C, clargs) + kickstart(map(partial(call, C, clargs, **kwargs), cmds)) + except (BSError, ToolError) as e: + LOGGER.error(e) + except KeyboardInterrupt: + # TODO: add actual clean up code + prints(C, "DirtyInterrupt. Exiting", s="\n\n", e="...") + sys.exit() + + +# this is so it can be ran as a module: `python3 -m bpsrender` (for testing) +if __name__ == "__main__": + main() diff --git a/power_sequencer/scripts/BPSRender/bpsrender/bscripts/mixdown.py b/power_sequencer/scripts/BPSRender/bpsrender/bscripts/mixdown.py new file mode 100644 index 00000000..a6b885cc --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/bscripts/mixdown.py @@ -0,0 +1,30 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import os.path as osp +import sys + + +for strip in bpy.context.scene.sequence_editor.sequences_all: + if strip.type == "META": + continue + if strip.type != "SOUND": + strip.mute = True + +path = sys.argv[-1] +ext = osp.splitext(path)[1][1:].upper() +bpy.ops.sound.mixdown(filepath=path, check_existing=False, container=ext, codec=ext) diff --git a/power_sequencer/scripts/BPSRender/bpsrender/bscripts/probe.py b/power_sequencer/scripts/BPSRender/bpsrender/bscripts/probe.py new file mode 100644 index 00000000..92cffa60 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/bscripts/probe.py @@ -0,0 +1,30 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +EXT = { + "AVI_JPEG": ".avi", + "AVI_RAW": ".avi", + "FFMPEG": {"MKV": ".mkv", "OGG": ".ogv", "QUICKTIME": ".mov", "AVI": ".avi", "MPEG4": ".mp4"}, +} + +scene = bpy.context.scene + +ext = EXT.get(scene.render.image_settings.file_format, "UNDEFINED") +if scene.render.image_settings.file_format == "FFMPEG": + ext = ext[scene.render.ffmpeg.format] +print("\nBPS:{} {} {}\n".format(scene.frame_start, scene.frame_end, ext)) diff --git a/power_sequencer/scripts/BPSRender/bpsrender/bscripts/video.py b/power_sequencer/scripts/BPSRender/bpsrender/bscripts/video.py new file mode 100644 index 00000000..590e73eb --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/bscripts/video.py @@ -0,0 +1,19 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + +bpy.context.scene.render.ffmpeg.audio_codec = "NONE" diff --git a/power_sequencer/scripts/BPSRender/bpsrender/calls.py b/power_sequencer/scripts/BPSRender/bpsrender/calls.py new file mode 100644 index 00000000..5a223dd6 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/calls.py @@ -0,0 +1,410 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +# IMPURE +import multiprocessing as mp +import os +import signal as sig +import subprocess as sp +from functools import partial, reduce +from itertools import chain, islice, starmap, tee +from multiprocessing import Queue + +from tqdm import tqdm + +from .config import LOGGER +from .helpers import BSError, checkblender, kickstart, printd, prints, printw + + +def chunk_frames(cfg, clargs, cmds, **kwargs): + """ + Recover the chunk start/end frames from the constructed commands for the + video step. This is necessary to preserve purity until later steps. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmds: iter(tuple) + Iterator of commands to be passed to `subprocess`. + kwargs: dict + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter(tuple) + Start/end pairs of frames corresponding to the chunk commands created at + the video step. + """ + out = map(lambda x: (x, islice(x, 1, None)), cmds) + out = map(lambda x: zip(*x), out) + out = map(lambda x: filter(lambda y: y[0] in ("-s", "-e"), x), out) + out = map(lambda x: map(lambda y: int(y[1]), x), out) + out = map(lambda x: reduce(lambda acc, y: acc + (y,), x, ()), out) + return out + + +def append_chunks_file(cfg, clargs, cmds, **kwargs): + """ + IMPURE + Helper function for creating the chunks file that will be used by `ffmpeg` + to concatenate the chunks into one video file. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmds: iter(tuple) + Iterator of commands to be passed to `subprocess`. + kwargs: dict + MANDATORY w_frame_start, w_frame_end, ext + Dictionary with additional information from the setup step. + """ + with open(kwargs["chunks_file_path"], "a") as f: + for fs, fe in chunk_frames(cfg, clargs, cmds, **kwargs): + f.write( + "file '{rcp}{fs}-{fe}{ext}'\n".format( + rcp=kwargs["render_chunk_path"].rstrip("#"), + fs="{fs:0{frame_pad}d}".format(fs=fs, **cfg), + fe="{fe:0{frame_pad}d}".format(fe=fe, **cfg), + **kwargs + ) + ) + + +def call_probe(cfg, clargs, cmds, **kwargs): + """ + IMPURE + Probe `clargs.blendfile` for frame start, frame end and extension (for + video only). + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmds: iter(tuple) + Iterator of commands to be passed to `subprocess`. + kwargs: dict + Dictionary with additional information from the setup step. + + Returns + ------- + out: dict + Dictionary with info extracted from `clargs.blendfile`, namely: start + frame, end frame and extension (only useful for video step). + """ + kwargs_p = {"stdout": sp.PIPE, "stderr": sp.STDOUT, "universal_newlines": True} + + printw(cfg, "Probing") + printw(cfg, "Input(blend) @ {}".format(clargs.blendfile), s="") + frame_start, frame_end, ext = (0, 0, "") + if not clargs.dry_run: + with sp.Popen(next(cmds), **kwargs_p) as cp: + try: + tmp = map(partial(checkblender, "PROBE", [cfg["probe_py"]], cp), cp.stdout) + tmp = filter(lambda x: x.startswith("BPS"), tmp) + tmp = map(lambda x: x[4:].strip().split(), tmp) + frame_start, frame_end, ext = chain(*tmp) + except BSError as e: + LOGGER.error(e) + except KeyboardInterrupt: + raise + finally: + cp.terminate() + returncode = cp.poll() + if returncode != 0: + raise sp.CalledProcessError(returncode, cp.args) + frame_start = frame_start if clargs.start is None else clargs.start + frame_end = frame_end if clargs.end is None else clargs.end + out = { + "frame_start": int(frame_start), + "frame_end": int(frame_end), + "frames_total": int(frame_end) - int(frame_start) + 1, + "ext": ext, + } + if out["ext"] == "UNDEFINED": + raise BSError("Video extension is {ext}. Stopping!".format(ext=ext)) + printd(cfg, "Probing done") + return out + + +def call_mixdown(cfg, clargs, cmds, **kwargs): + """ + IMPURE + Calls blender to render the audio mixdown. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmds: iter(tuple) + Iterator of commands to be passed to `subprocess`. + kwargs: dict + MANDATORY render_mixdown_path + Dictionary with additional information from the setup step. + """ + kwargs_p = {"stdout": sp.PIPE, "stderr": sp.STDOUT, "universal_newlines": True} + + printw(cfg, "Rendering mixdown") + printw(cfg, "Output @ {}".format(kwargs["render_mixdown_path"]), s="") + if not clargs.dry_run: + with sp.Popen(next(cmds), **kwargs_p) as cp: + try: + tmp = map(partial(checkblender, "MIXDOWN", [cfg["mixdown_py"]], cp), cp.stdout) + tmp = filter(lambda x: x.startswith("BPS"), tmp) + tmp = map(lambda x: x[4:].strip().split(), tmp) + kickstart(tmp) + except BSError as e: + LOGGER.error(e) + except KeyboardInterrupt: + raise + finally: + cp.terminate() + returncode = cp.poll() + if returncode != 0: + raise sp.CalledProcessError(returncode, cp.args) + printd(cfg, "Mixdown done") + + +def call_chunk(cfg, clargs, queue, cmd, **kwargs): + """ + IMPURE + Calls blender to render one chunk (which part is determined by `cmd`). + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmd: tuple + Tuple to be passed to `subprocess`. + kwargs: dict + Dictionary with additional information from the setup step. + """ + sig.signal(sig.SIGINT, sig.SIG_IGN) + kwargs_p = {"stdout": sp.PIPE, "stderr": sp.STDOUT, "universal_newlines": True} + + if not clargs.dry_run: + # can't use nice functional syntax if we want to simplify with `with` + with sp.Popen(cmd, **kwargs_p) as cp: + try: + tmp = map( + partial( + checkblender, + "VIDEO", + [cfg["video_py"], "The encoder timebase is not set"], + cp, + ), + cp.stdout, + ) + tmp = filter(lambda x: x.startswith("Append frame"), tmp) + tmp = map(lambda x: x.split()[-1], tmp) + tmp = map(int, tmp) + tmp = map(lambda x: True, tmp) + kickstart(map(queue.put, tmp)) + queue.put(False) + except BSError as e: + LOGGER.error(e) + + +def call_video(cfg, clargs, cmds, **kwargs): + """ + IMPURE + Multi-process call to blender for rendering the (video) chunks. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmds: iter(tuple) + Iterator of commands to be passed to `subprocess`. + kwargs: dict + Dictionary with additional information from the setup step. + """ + printw(cfg, "Rendering video (w/o audio)") + printw(cfg, "Output @ {}".format(kwargs["render_chunk_path"]), s="") + try: + not clargs.dry_run and os.remove(kwargs["chunks_file_path"]) + LOGGER.info("CALL-VIDEO: generating {}".format(kwargs["chunks_file_path"])) + except OSError as e: + LOGGER.info("CALL-VIDEO: skipping {}: {}".format(e.filename, e.strerror)) + + cmds, cmds_cf = tee(cmds) + (not clargs.dry_run and append_chunks_file(cfg, clargs, cmds_cf, **kwargs)) + # prepare queue/worker + queues = queues_close = (Queue(),) * clargs.workers + # prpare processes + proc = starmap( + lambda q, cmd: mp.Process(target=partial(call_chunk, cfg, clargs, **kwargs), args=(q, cmd)), + zip(queues, cmds), + ) + # split iterator in 2 for later joining the processes and sum + # one of them + proc, proc_close = tee(proc) + proc = map(lambda p: p.start(), proc) + try: + not clargs.dry_run and kickstart(proc) + + # communicate with processes through the queues and use tqdm to show a + # simple terminal progress bar baesd on video total frames + queues = map(lambda q: iter(q.get, False), queues) + queues = chain(*queues) + queues = tqdm(queues, total=kwargs["frame_end"] - kwargs["frame_start"] + 1, unit="frames") + not clargs.dry_run and kickstart(queues) + except KeyboardInterrupt: + proc_close = map(lambda x: x.terminate(), proc_close) + not clargs.dry_run and kickstart(proc_close) + raise + finally: + # close and join processes and queues + proc_close = map(lambda x: x.join(), proc_close) + not clargs.dry_run and kickstart(proc_close) + + queues_close = map(lambda q: (q, q.close()), queues_close) + queues_close = starmap(lambda q, _: q.join_thread(), queues_close) + not clargs.dry_run and kickstart(queues_close) + printd(cfg, "Video chunks rendering done") + + +def call_concatenate(cfg, clargs, cmds, **kwargs): + """ + IMPURE + Calls ffmpeg in order to concatenate the video chunks together. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmds: iter(tuple) + Iterator of commands to be passed to `subprocess`. + kwargs: dict + MANDATORY: render_video_path + Dictionary with additional information from the setup step. + + Note + ---- + It expects the video chunk files to already be available. + """ + kwargs_p = {"stdout": sp.DEVNULL, "stderr": sp.DEVNULL} + printw(cfg, "Concatenating (video) chunks") + printw(cfg, "Output @ {}".format(kwargs["render_video_path"]), s="") + if not clargs.dry_run: + with sp.Popen(next(cmds), **kwargs_p) as cp: + try: + returncode = cp.wait() + if returncode != 0: + raise sp.CalledProcessError(returncode, cp.args) + except KeyboardInterrupt: + raise + finally: + cp.terminate() + printd(cfg, "Concatenating done") + + +def call_join(cfg, clargs, cmds, **kwargs): + """ + IMPURE + Calls ffmpeg for joining the audio mixdown and the video. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmds: iter(tuple) + Iterator of commands to be passed to `subprocess`. + kwargs: dict + MANDATORY: render_audiovideo_path + Dictionary with additional information from the setup step. + + Note + ---- + It expects the audio mixdown and video files to already be available. + """ + kwargs_p = {"stdout": sp.DEVNULL, "stderr": sp.DEVNULL} + printw(cfg, "Joining audio/video") + printw(cfg, "Output @ {}".format(kwargs["render_audiovideo_path"]), s="") + if not clargs.dry_run: + with sp.Popen(next(cmds), **kwargs_p) as cp: + try: + returncode = cp.wait() + if returncode != 0: + raise sp.CalledProcessError(returncode, cp.args) + except KeyboardInterrupt: + raise + finally: + cp.terminate() + printd(cfg, "Joining done") + + +def call(cfg, clargs, cmds, **kwargs): + """ + IMPURE + Delegates work to appropriate `call_*` functions. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + cmds: iter(tuple) + Iterator of commands to be passed to `subprocess` + kwargs: dict + MANDATORY: render_audiovideo_path + Dictionary with additional information from the setup step. + + Returns + ------- + out: dict or None + It passes on the output from the `call_*` functions. See `call_*` for + specific details. + + Note + ---- + It tries to be smart and skip steps if child subprocesses give errors. + Example if `--join-only` is passed, but the audio mixdown or video file + aren't available on hard drive. + """ + calls = { + "probe": call_probe, + "mixdown": call_mixdown, + "video": call_video, + "concatenate": call_concatenate, + "join": call_join, + } + try: + out = calls[cmds[0]](cfg, clargs, cmds[1], **kwargs) + return out + except sp.CalledProcessError: + prints( + cfg, + ("WARNING:{}: Something went wrong when calling" " command - SKIPPING").format(cmds[0]), + ) diff --git a/power_sequencer/scripts/BPSRender/bpsrender/commands.py b/power_sequencer/scripts/BPSRender/bpsrender/commands.py new file mode 100644 index 00000000..dc669806 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/commands.py @@ -0,0 +1,341 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import math as m +from collections import OrderedDict +from itertools import chain, islice + +from .config import LOGGER + + +def get_commands_probe(cfg, clargs, **kwargs): + """ + Create the command for probing the `clargs.blendfile`. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter(list) + An iterator for which each element is a list to be sent to functions like + `subprocess.run`. + """ + out = ( + "blender", + "--background", + clargs.blendfile, + "--python", + kwargs["probe_py_normalized"], + "--disable-autoexec", + ) + LOGGER.debug("CMD-PROBE: {cmd}".format(cmd=" ".join(out))) + return iter((out,)) + + +def get_commands_chunk(cfg, clargs, **kwargs): + """ + Create the command for rendering a (video) chunk from `clargs.blendfile`. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + MANDATORY render_chunk_path, w_frame_start, w_frame_end + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter(list) + An iterator for which each element is a list to be sent to functions like + `subprocess.run`. + """ + out = ( + "blender", + "--background", + clargs.blendfile, + "--python", + kwargs["video_py_normalized"], + "--disable-autoexec", + "--render-output", + kwargs["render_chunk_path"], + "-s", + str(kwargs["w_frame_start"]), + "-e", + str(kwargs["w_frame_end"]), + "--render-anim", + ) + LOGGER.debug( + "CMD-CHUNK({w_frame_start}-{w_frame_end}): {cmd}".format(cmd=" ".join(out), **kwargs) + ) + return iter((out,)) + + +def get_commands_video(cfg, clargs, **kwargs): + """ + Create the list of commands (one command per chunk) for rendering a video + from `clargs.blendfile`. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + MANDATORY chunk_file_path, frame_start, frame_end, frames_total + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter(tuple) + An iterator for which each element is a tuple to be sent to functions like + `subprocess.run`. + """ + LOGGER.debug("CMD-VIDEO:") + chunk_length = int(m.floor(kwargs["frames_total"] / clargs.workers)) + out = map(lambda w: (w, kwargs["frame_start"] + w * chunk_length), range(clargs.workers)) + out = map( + lambda x: ( + x[1], + x[1] + chunk_length - 1 if x[0] != clargs.workers - 1 else kwargs["frame_end"], + ), + out, + ) + out = map( + lambda x: get_commands( + cfg, clargs, "chunk", w_frame_start=x[0], w_frame_end=x[1], **kwargs + ), + out, + ) + out = map(lambda x: x[1], out) + out = chain(*out) + return tuple(out) + + +def get_commands_mixdown(cfg, clargs, **kwargs): + """ + Create the command to render the mixdown from `clargs.blendfile`. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + MANDATORY render_mixdown_path + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter(tuple) + An iterator for which each element is a tuple to be sent to functions like + `subprocess.run`. + """ + out = ( + "blender --background {blendfile} --python {mixdown_py_normalized}" + " --disable-autoexec -- {render_mixdown_path}".format(**cfg, **vars(clargs), **kwargs) + ) + out = ( + "blender", + "--background", + clargs.blendfile, + "--python", + kwargs["mixdown_py_normalized"], + "--disable-autoexec", + "--", + kwargs["render_mixdown_path"], + ) + LOGGER.debug("CMD-MIXDOWN: {cmd}".format(cmd=" ".join(out))) + return iter((out,)) + + +def get_commands_concatenate(cfg, clargs, **kwargs): + """ + Create the command to concatenate the available video chunks generated + beforehand. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + MANDATORY chunks_file_path, render_video_path + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter(tuple) + An iterator for which each element is a tuple to be sent to functions like + `subprocess.run`. + """ + out = ( + "ffmpeg", + "-stats", + "-f", + "concat", + "-safe", + "-0", + "-i", + kwargs["chunks_file_path"], + "-c", + "copy", + "-y", + kwargs["render_video_path"], + ) + LOGGER.debug("CMD-CONCATENATE: {cmd}".format(cmd=" ".join(out))) + return iter((out,)) + + +def get_commands_join(cfg, clargs, **kwargs): + """ + Create the command to join the available audio mixdown and video generated + beforehand. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + MANDATORY chunks_file_path, render_video_path + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter(tuple) + An iterator for which each element is a tuple to be sent to functions like + `subprocess.run`. + """ + out = ( + "ffmpeg", + "-stats", + "-i", + kwargs["render_video_path"], + "-i", + kwargs["render_mixdown_path"], + "-map", + "0:v:0", + "-c:v", + "copy", + "-map", + "1:a:0", + "-c:a", + "aac", + "-b:a", + "192k", + "-y", + kwargs["render_audiovideo_path"], + ) + LOGGER.debug("CMD-JOIN: {cmd}".format(cmd=" ".join(out))) + return iter((out,)) + + +def get_commands(cfg, clargs, what="", **kwargs): + """ + Delegates the creation of commands lists to appropriate functions based on + `what` parameter. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + what: str (default = '') + Determines the returned value (see: Returns[out]). + kwargs: dict + MANDATORY -- see individual functions for the list of mandatory keys + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter or (str, iter) + |- what == '' is True + An iterator with elements of the type (str) for determining the order in + which to call the functions in the setup step. + NOTE: it skipps the "internal use only" functions. + |- else + A tuple with the 1st element as a tag (the `what` parameter) and the 2nd + element as the iterator of the actual commands. + """ + get_commands_f = OrderedDict( + ( + # internal use only + ("probe", get_commands_probe), + ("chunk", get_commands_chunk), + # direct connection to command line arguments - in order of execution + ("mixdown", get_commands_mixdown), + ("video", get_commands_video), + ("concatenate", get_commands_concatenate), + ("join", get_commands_join), + ) + ) + + return ( + islice(get_commands_f, 2, None) + if what == "" + else (what, get_commands_f[what](cfg, clargs, **kwargs)) + ) + + +def get_commands_all(cfg, clargs, **kwargs): + """ + Prepare the list of commands to be executed depending on the command line + arguments. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + MANDATORY -- see individual functions for the list of mandatory keys + Dictionary with additional information from the setup step. + + Returns + ------- + out: iter((str, tuple)) + An iterator for which each element is a (str, iter(tuple)). The string + value is for tagging the iterator command list (2nd element) for filtering + later based on the given command line arguments. + """ + end = "_only" + out = filter(lambda x: x[0].endswith(end), vars(clargs).items()) + out = map(lambda x: (x[0][: -len(end)], x[1]), out) + order = list(get_commands(cfg, clargs)) + out = sorted(out, key=lambda x: order.index(x[0])) + out = ( + map(lambda k: k[0], out) + if all(map(lambda k: not k[1], out)) + else map(lambda k: k[0], filter(lambda k: k[1], out)) + ) + out = map(lambda k: get_commands(cfg, clargs, k, **kwargs), out) + return out diff --git a/power_sequencer/scripts/BPSRender/bpsrender/config.py b/power_sequencer/scripts/BPSRender/bpsrender/config.py new file mode 100644 index 00000000..b87e3ea3 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/config.py @@ -0,0 +1,37 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import logging as lg +import multiprocessing as mp +import os.path as osp + +CONFIG = { + "logger": "BPS", + "cpu_count": min(int(mp.cpu_count() / 2), 6), + "bs_path": osp.join(osp.dirname(osp.abspath(__file__)), "bscripts"), + "frame_pad": 7, + "parts_folder": "bpsrender", + "chunks_file": "chunks.txt", + "video_file": "video{}", + "pre": {"work": "»", "done": "•", "skip": "~"}, + "probe_py": "probe.py", + "mixdown_py": "mixdown.py", + "video_py": "video.py", +} + +LOGGER = lg.getLogger(CONFIG["logger"]) +LOGLEV = [lg.INFO, lg.DEBUG] +LOGLEV = [None] + sorted(LOGLEV, reverse=True) diff --git a/power_sequencer/scripts/BPSRender/bpsrender/helpers.py b/power_sequencer/scripts/BPSRender/bpsrender/helpers.py new file mode 100644 index 00000000..9ebcf2b0 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/helpers.py @@ -0,0 +1,110 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +from collections import deque +from shutil import which + + +class BSError(Exception): + """ + Custom Exception raised if Blender is called with a python script argument + and gives error while trying to execute the script. + """ + + pass + + +class ToolError(Exception): + """Raised if external dependencies aren't found on system. + """ + + pass + + +def checktools(tools): + tools = [(t, which(t) or "") for t in tools] + check = {"tools": tools, "test": all(map(lambda x: x[1], tools))} + if not check["test"]: + msg = ["BPSRender couldn't find external dependencies:"] + msg += [ + "[{check}] {tool}: {path}".format( + check="v" if path is not "" else "X", tool=tool, path=path or "NOT FOUND" + ) + for tool, path in check["tools"] + ] + msg += [ + ( + "Check if you have them properly installed and available in the PATH" + " environemnt variable." + ), + "Exiting...", + ] + raise ToolError("\n".join(msg)) + + +def checkblender(what, search, cp, s): + """ + IMPURE + Check Blender output for python script execution error. + + Parameters + ---------- + what: str + A tag used in the exception message. + search: iter(str) + One or more string(s) to search for in Blender's output. + cp: Popen + Blender subprocess. + s: PIPE + Blender's output. + + Returns + ------- + out: PIPE + The same pipe `s` is returned so that it can be iterated over on later + steps. + """ + if not isinstance(search, list): + search = [search] + for search_item in search: + if search_item in s: + message = ( + "Script {what} was not properly executed in" " Blender".format(what=what), + "CMD: {cmd}".format(what=what, cmd=" ".join(cp.args)), + "DUMP:".format(what=what), + s, + ) + raise BSError("\n".join(message)) + return s + + +def printw(cfg, text, s="\n", e="...", p="", **kwargs): + p = p or cfg["pre"]["work"] + print("{s}{p} {}{e}".format(text, s=s, e=e, p=p), **kwargs) + + +def printd(cfg, text, s="", e=".", p="", **kwargs): + p = p or cfg["pre"]["done"] + printw(cfg, text, s=s, e=e, p=p, **kwargs) + + +def prints(cfg, text, s="", e=".", p="", **kwargs): + p = p or cfg["pre"]["skip"] + printw(cfg, text, s=s, e=e, p=p, **kwargs) + + +def kickstart(it): + deque(it, maxlen=0) diff --git a/power_sequencer/scripts/BPSRender/bpsrender/setup.py b/power_sequencer/scripts/BPSRender/bpsrender/setup.py new file mode 100644 index 00000000..aba30d07 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/bpsrender/setup.py @@ -0,0 +1,182 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +# IMPURE +import logging as lg +import os +import os.path as osp +from functools import reduce +from itertools import starmap + +from .calls import call +from .commands import get_commands, get_commands_all +from .config import LOGGER, LOGLEV +from .helpers import kickstart + + +def setup_bspy(cfg, clargs, **kwargs): + """ + Normalize the names of the script to be ran in Blender for certain steps. + Eg. the probe step depends on the script located in + `bpsrender/cfg['probe_py']`. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + + Returns + ------- + out: dict + Dictoinary to be used in call steps. + """ + out = filter(lambda x: x[0].endswith("_py"), cfg.items()) + out = starmap(lambda k, v: ("{}_normalized".format(k), osp.join(cfg["bs_path"], v)), out) + return dict(out) + + +def setup_probe(cfg, clargs, **kwargs): + """ + IMPURE + Call Blender and extract information that will be necessary for later + steps. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + MANDATORY -- see individual functions for the list of mandatory keys + Dictionary with additional information from the previous setup step. + + Returns + ------- + out: dict + Dictoinary to be used in call steps. + """ + return call(cfg, clargs, get_commands(cfg, clargs, "probe", **kwargs), **kwargs) + + +def setup_paths(cfg, clargs, **kwargs): + """ + Figure out appropriate path locations to store output for parts and final + render. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + MANDATORY -- see individual functions for the list of mandatory keys + Dictionary with additional information from the previous setup step. + + Returns + ------- + out: dict + Dictionary storing all relevant information pertaining to folder and file + paths. + + Note + ---- + It also creates the folder structure 'render/parts' where + `clargs.blendfile` is stored on disk. + """ + render_parts_path = osp.join(clargs.output, cfg["parts_folder"]) + name = osp.splitext(osp.basename(clargs.blendfile))[0] + render_mixdown_path = osp.join(render_parts_path, "{}_m.flac".format(name)) + render_chunk_path = osp.join(render_parts_path, "{}_c_{}".format(name, "#" * cfg["frame_pad"])) + render_video_path = osp.join(render_parts_path, "{}_v{}".format(name, kwargs["ext"])) + render_audiovideo_path = osp.join(clargs.output, "{}{}".format(name, kwargs["ext"])) + chunks_file_path = osp.join(render_parts_path, cfg["chunks_file"]) + + out = { + "render_path": clargs.output, + "render_parts_path": render_parts_path, + "chunks_file_path": chunks_file_path, + "render_chunk_path": render_chunk_path, + "render_video_path": render_video_path, + "render_mixdown_path": render_mixdown_path, + "render_audiovideo_path": render_audiovideo_path, + } + return out + + +def setup_folders_hdd(cfg, clargs, **kwargs): + """ + IMPURE + Prepares the folder structure `cfg['render']/cfg['parts']'`. + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + Dictionary with additional information from the previous setup step. + + Returns + ------- + out: (iter((str, iter(tuple))), dict) + 1st element: see commands.py:get_commands_all + 2nd elment: the keyword arguments used by calls.py:call + """ + # create folder structure if it doesn't exist already only if + # appropriate command line arguments are given + do_it = filter(lambda x: x[0].endswith("_only"), vars(clargs).items()) + do_it = all(map(lambda x: not x[1], do_it)) + do_it = not clargs.dry_run and clargs.video_only or clargs.mixdown_only or do_it + do_it and os.makedirs(kwargs["render_parts_path"], exist_ok=True) + return {} + + +def setup(cfg, clargs): + """ + IMPURE -- setup_paths + Prepares the folder structure 'render/parts', the appropriate command lists + to be called and the keyword arguments to be passed to call functions + (calls.py). + + Parameters + ---------- + cfg: dict + Configuration dictionary. + clargs: Namespace + Command line arguments (normalized). + kwargs: dict + Dictionary with additional information from the previous setup step. + + Returns + ------- + out: (iter((str, iter(tuple))), dict) + 1st element: see commands.py:get_commands_all + 2nd elment: the keyword arguments used by calls.py:call + """ + setups_f = (setup_bspy, setup_probe, setup_paths, setup_folders_hdd) + lg.basicConfig(level=LOGLEV[min(clargs.verbose, len(LOGLEV) - 1)]) + + kwargs = dict(reduce(lambda acc, sf: {**acc, **sf(cfg, clargs, **acc)}, setups_f, {})) + + LOGGER.info("Setup:") + kickstart(starmap(lambda k, v: LOGGER.info("{}: {}".format(k, v)), kwargs.items())) + return get_commands_all(cfg, clargs, **kwargs), kwargs diff --git a/power_sequencer/scripts/BPSRender/setup.py b/power_sequencer/scripts/BPSRender/setup.py new file mode 100644 index 00000000..4c4a74b9 --- /dev/null +++ b/power_sequencer/scripts/BPSRender/setup.py @@ -0,0 +1,55 @@ +# +# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +from setuptools import setup + + +def readme(): + with open("README.rst") as f: + return f.read() + + +setup( + name="bpsrender", + version="0.1.40.post1", + description="Blender Power Sequencer Renderer", + long_description=readme(), + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Natural Language :: English", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Topic :: Text Processing :: Linguistic", + "Topic :: Multimedia :: Video", + "Topic :: Utilities", + ], + url="https://gitlab.com/razcore/BPSRender", + keywords="blender render parallel multiprocess speedup utility" " productivty", + author="Răzvan C. Rădulescu", + author_email="razcore.art@gmail.com", + license="GPLv3", + packages=["bpsrender"], + install_requires=["tqdm"], + zip_safe=False, + entry_points={"console_scripts": ["bpsrender=bpsrender.__main__:main"]}, + include_package_data=True, +) diff --git a/power_sequencer/ui/__init__.py b/power_sequencer/ui/__init__.py new file mode 100644 index 00000000..61edcb35 --- /dev/null +++ b/power_sequencer/ui/__init__.py @@ -0,0 +1,48 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from .menu_contextual import POWER_SEQUENCER_MT_contextual +from .menu_toolbar import ( + POWER_SEQUENCER_MT_main, + POWER_SEQUENCER_MT_playback, + POWER_SEQUENCER_MT_strips, + POWER_SEQUENCER_MT_select, + POWER_SEQUENCER_MT_edit, + POWER_SEQUENCER_MT_markers, + POWER_SEQUENCER_MT_file, + POWER_SEQUENCER_MT_trim, + POWER_SEQUENCER_MT_preview, + POWER_SEQUENCER_MT_audio, + POWER_SEQUENCER_MT_transitions, +) + +classes = [ + POWER_SEQUENCER_MT_contextual, + POWER_SEQUENCER_MT_main, + POWER_SEQUENCER_MT_playback, + POWER_SEQUENCER_MT_strips, + POWER_SEQUENCER_MT_select, + POWER_SEQUENCER_MT_edit, + POWER_SEQUENCER_MT_markers, + POWER_SEQUENCER_MT_file, + POWER_SEQUENCER_MT_trim, + POWER_SEQUENCER_MT_preview, + POWER_SEQUENCER_MT_audio, + POWER_SEQUENCER_MT_transitions, +] + +register_ui, unregister_ui = bpy.utils.register_classes_factory(classes) diff --git a/power_sequencer/ui/menu_contextual.py b/power_sequencer/ui/menu_contextual.py new file mode 100644 index 00000000..6ba0640a --- /dev/null +++ b/power_sequencer/ui/menu_contextual.py @@ -0,0 +1,83 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +from ..operators.utils.global_settings import SequenceTypes + + +class POWER_SEQUENCER_MT_contextual(bpy.types.Menu): + bl_label = "Power Sequencer" + # bl_idname = "SEQUENCER_MT_power_sequencer_menu" + + def draw(self, context): + layout = self.layout + + if not bpy.data.is_saved: + layout.label("Please save your project") + layout.operator("wm.save_as_mainfile", icon="SAVE_AS", text="Save as") + return + + if not context.sequences: + layout.operator( + "power_sequencer.import_local_footage", icon="SEQUENCE", text="Import local footage" + ) + return + + selection = context.selected_sequences + active_strip = context.scene.sequence_editor.active_strip + types = set([s.type for s in selection]) + + if active_strip.type == "GAMMA_CROSS": + layout.operator( + "power_sequencer.crossfade_edit", icon="ACTION_TWEAK", text="Edit crossfade" + ) + + for t in types: + if t in SequenceTypes.VIDEO: + layout.operator("power_sequencer.fade_add", icon="IMAGE_ALPHA", text="Fade strips") + break + + if len(selection) == 1: + for s in [active_strip, selection[0]]: + if s.type in SequenceTypes.VIDEO or s.type in SequenceTypes.IMAGE: + layout.separator() + layout.operator( + "power_sequencer.crossfade_add", icon="IMAGE_ALPHA", text="Auto crossfade" + ) + break + + # TODO: Doesn't work from the menu, I guess there's an issue with the invoke method? + # layout.separator() + # layout.operator('power_sequencer.grab_closest_handle_or_cut', icon='SNAP_SURFACE', text='Grab cut or handle') + + if len(selection) > 1: + layout.separator() + + layout.operator( + "power_sequencer.ripple_delete", icon="AUTOMERGE_ON", text="Ripple delete" + ) + layout.operator( + "power_sequencer.snap_selection", icon="SNAP_ON", text="Snap selection" + ) + + layout.separator() + + layout.operator( + "power_sequencer.import_local_footage", icon="SEQUENCE", text="Import local footage" + ) + layout.operator( + "power_sequencer.render_video", icon="RENDER_ANIMATION", text="Render video for the web" + ) diff --git a/power_sequencer/ui/menu_toolbar.py b/power_sequencer/ui/menu_toolbar.py new file mode 100644 index 00000000..cb647da5 --- /dev/null +++ b/power_sequencer/ui/menu_toolbar.py @@ -0,0 +1,204 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy + + +class POWER_SEQUENCER_MT_main(bpy.types.Menu): + bl_label = "Power Sequencer" + + def draw(self, context): + layout = self.layout + + layout.separator() + + layout.menu("POWER_SEQUENCER_MT_file") + layout.menu("POWER_SEQUENCER_MT_edit") + layout.menu("POWER_SEQUENCER_MT_select") + layout.menu("POWER_SEQUENCER_MT_trim") + layout.menu("POWER_SEQUENCER_MT_strips") + layout.menu("POWER_SEQUENCER_MT_transitions") + layout.menu("POWER_SEQUENCER_MT_audio") + layout.menu("POWER_SEQUENCER_MT_playback") + layout.menu("POWER_SEQUENCER_MT_preview") + layout.menu("POWER_SEQUENCER_MT_markers") + + layout.separator() + + layout.operator("power_sequencer.render_apply_preset", text="Apply Render Preset") + + +class POWER_SEQUENCER_MT_playback(bpy.types.Menu): + bl_label = "Playback" + + def draw(self, context): + layout = self.layout + layout.operator("power_sequencer.playback_speed_increase") + layout.operator("power_sequencer.playback_speed_decrease") + layout.operator("power_sequencer.playback_speed_set") + + +class POWER_SEQUENCER_MT_strips(bpy.types.Menu): + bl_label = "Strips" + + def draw(self, context): + layout = self.layout + layout.operator("power_sequencer.speed_up_movie_strip") + layout.operator("power_sequencer.speed_remove_effect") + + layout.separator() + + layout.operator("power_sequencer.concatenate_strips") + layout.operator("power_sequencer.swap_strips") + layout.operator("power_sequencer.toggle_selected_mute") + layout.operator("power_sequencer.channel_offset") + + layout.separator() + + layout.operator("power_sequencer.make_still_image") + + +class POWER_SEQUENCER_MT_transitions(bpy.types.Menu): + bl_label = "Transitions" + + def draw(self, context): + layout = self.layout + layout.operator("power_sequencer.fade_add") + layout.operator("power_sequencer.fade_clear") + + layout.separator() + + layout.operator("power_sequencer.crossfade_add") + layout.operator("power_sequencer.crossfade_edit") + layout.operator("power_sequencer.transitions_remove") + + +class POWER_SEQUENCER_MT_select(bpy.types.Menu): + bl_label = "Select" + + def draw(self, context): + layout = self.layout + + layout.operator("power_sequencer.select_linked_effect") + + layout.separator() + + layout.operator("power_sequencer.deselect_all_strips_left_or_right") + layout.operator("power_sequencer.deselect_handles_and_grab") + + +class POWER_SEQUENCER_MT_edit(bpy.types.Menu): + bl_label = "Edit" + + def draw(self, context): + layout = self.layout + layout.operator("power_sequencer.delete_direct") + layout.operator("power_sequencer.ripple_delete") + + layout.separator() + + layout.operator("power_sequencer.gap_remove") + layout.operator( + "power_sequencer.copy_selected_sequences", text="Copy Selected", icon="COPYDOWN" + ) + + layout.separator() + + layout.operator("power_sequencer.grab") + layout.operator("power_sequencer.grab_closest_cut") + layout.operator("power_sequencer.grab_sequence_handles") + + layout.separator() + + layout.operator("power_sequencer.trim_left_or_right_handles") + layout.operator("power_sequencer.snap_selection") + + layout.separator() + + layout.operator("power_sequencer.scene_cycle") + + +class POWER_SEQUENCER_MT_markers(bpy.types.Menu): + bl_label = "Markers" + + def draw(self, context): + layout = self.layout + + layout.operator("power_sequencer.marker_delete_closest") + layout.operator("power_sequencer.marker_delete_direct") + + layout.separator() + + layout.operator("power_sequencer.synchronize_titles") + + layout.separator() + + layout.operator("power_sequencer.marker_go_to_next") + layout.operator("power_sequencer.copy_markers_as_timecodes") + layout.operator("power_sequencer.marker_snap_to_cursor") + layout.operator("power_sequencer.set_preview_between_markers") + layout.operator("power_sequencer.markers_snap_matching_strips") + + +class POWER_SEQUENCER_MT_file(bpy.types.Menu): + bl_label = "File" + + def draw(self, context): + layout = self.layout + layout.operator( + "power_sequencer.open_project_directory", + text="Open Project Directory", + icon="FILE_FOLDER", + ) + layout.operator("power_sequencer.save_direct") + layout.operator("power_sequencer.import_local_footage") + + +class POWER_SEQUENCER_MT_trim(bpy.types.Menu): + bl_label = "Trim" + + def draw(self, context): + layout = self.layout + layout.operator("power_sequencer.trim_three_point_edit") + layout.operator("power_sequencer.trim_to_surrounding_cuts") + + layout.separator() + + layout.operator("power_sequencer.mouse_trim") + layout.operator("power_sequencer.mouse_trim_instantly") + + +class POWER_SEQUENCER_MT_preview(bpy.types.Menu): + bl_label = "Preview" + + def draw(self, context): + layout = self.layout + layout.operator("power_sequencer.preview_closest_cut") + layout.operator("power_sequencer.preview_to_selection") + + layout.separator() + + layout.operator("power_sequencer.set_timeline_range") + + +class POWER_SEQUENCER_MT_audio(bpy.types.Menu): + bl_label = "Audio" + + def draw(self, context): + layout = self.layout + layout.operator("power_sequencer.align_audios") + layout.operator("power_sequencer.toggle_waveforms") + layout.operator("power_sequencer.mouse_toggle_mute") diff --git a/power_sequencer/utils/addon_auto_imports.py b/power_sequencer/utils/addon_auto_imports.py new file mode 100644 index 00000000..e570f53c --- /dev/null +++ b/power_sequencer/utils/addon_auto_imports.py @@ -0,0 +1,72 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import pkgutil +import importlib + +reload_event = False + + +def setup_addon_modules(path, package_name, ignore_packages=[], ignore_modules=[]): + """ + Imports and reloads all modules in this addon. + + path -- __path__ from __init__.py + package_name -- __name__ from __init__.py + ignore_packages -- list of packages to ignore, + skips the package if the string is in the name + ignore_modules -- list of module_names to ignore, strings + skips the module if the string is in the name + """ + + def get_submodule_names(path=path[0], root=""): + module_names = [] + for ignore in ignore_packages: + if ignore in root: + return [] + for importer, module_name, is_package in pkgutil.iter_modules([path]): + skip_module = False + for ignore in ignore_modules: + if ignore in module_name: + skip_module = True + if skip_module: + continue + if is_package: + sub_path = path + "\\" + module_name + sub_root = root + module_name + "." + module_names.extend(get_submodule_names(sub_path, sub_root)) + else: + module_names.append(root + module_name) + return module_names + + def import_submodules(names): + modules = [] + for name in names: + modules.append(importlib.import_module("." + name, package_name)) + return modules + + def reload_modules(modules): + for module in modules: + importlib.reload(module) + + names = get_submodule_names() + modules = import_submodules(names) + if reload_event: + reload_modules(modules) + return modules + + +reload_event = True diff --git a/power_sequencer/utils/register_shortcuts.py b/power_sequencer/utils/register_shortcuts.py new file mode 100644 index 00000000..074fb1c3 --- /dev/null +++ b/power_sequencer/utils/register_shortcuts.py @@ -0,0 +1,62 @@ +# +# Copyright (C) 2016-2019 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors +# +# This file is part of Power Sequencer. +# +# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Power Sequencer. If +# not, see <https://www.gnu.org/licenses/>. +# +import bpy +import operator as op +from .. import operators +from itertools import groupby + + +keymaps_meta = {"Frames": "EMPTY", "Sequencer": "SEQUENCE_EDITOR", "Markers": "EMPTY"} + + +def set_keymap_property(properties, property_name, value): + try: + setattr(properties, property_name, value) + except AttributeError: + print( + "Warning: property '%s' not found in keymap item '%s'" + % (property_name, properties.__class__.__name__) + ) + except Exception as e: + print("Warning: %r" % e) + + +def register_shortcuts(): + def keymapgetter(operator): + return operator[1]["keymap"] + + data = dir(operators) + data = filter(lambda operator: operator[0].isupper(), data) + data = map(lambda operator: op.attrgetter(operator), data) + data = map(lambda operator: operator(operators), data) + data = map(lambda operator: op.attrgetter("bl_idname", "doc")(operator), data) + data = {k: v for k, v in data if v != {}} + data.update(operators.doc) + data = sorted(data.items(), key=keymapgetter) + data = groupby(data, key=keymapgetter) + + kms = [] + wm = bpy.context.window_manager + for name, group in data: + km = wm.keyconfigs.addon.keymaps.new(name=name, space_type=keymaps_meta[name]) + for bl_idname, d in group: + for s in d["shortcuts"]: + kmi = km.keymap_items.new(bl_idname, **s[0]) + for pn, pv in s[1].items(): + set_keymap_property(kmi.properties, pn, pv) + kms.append((km, kmi)) + return kms |