From 532111c28d62f9ca22ae3f9099ddd7305f415218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotrek=20Brzezi=C5=84ski?= Date: Fri, 9 Jul 2021 17:15:35 +0200 Subject: [PATCH 1/6] timeline: Fix rate calculation Note: this only fixes waveform display at 1x rate, the rest of the code needs further adjustments to properly support time effects. --- pitivi/timeline/previewers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pitivi/timeline/previewers.py b/pitivi/timeline/previewers.py index f2652a141..bb27f817c 100644 --- a/pitivi/timeline/previewers.py +++ b/pitivi/timeline/previewers.py @@ -1282,14 +1282,17 @@ class AudioPreviewer(Gtk.Layout, Previewer, Zoomable, Loggable): # calculated in the context of the asset duration. rect = Gdk.cairo_get_clip_rectangle(context)[1] clip = self.ges_elem.get_parent() + start = self.ges_elem.props.start inpoint = self.ges_elem.props.in_point duration = self.ges_elem.props.duration max_duration = self.ges_elem.get_asset().get_filesource_asset().get_duration() start_ns = min(max(0, self.pixel_to_ns(rect.x) + inpoint), max_duration) end_ns = min(max(0, self.pixel_to_ns(rect.x + rect.width) + inpoint), max_duration) + # Get the overall rate of the clip in the current area the clip is used - # FIXME: Smarted computation will be needed when we make the rate keyframeable - rate = (duration - inpoint) / clip.get_timeline_time_from_internal_time(self.ges_elem, duration) + internal_end = clip.get_internal_time_from_timeline_time(self.ges_elem, start + duration) + internal_duration = internal_end - inpoint + rate = internal_duration / duration zoom = self.get_current_zoom_level() height = self.get_allocation().height - 2 * CLIP_BORDER_WIDTH -- GitLab From 474e8e2260188d9886bb7201b379b4a12f17a904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotrek=20Brzezi=C5=84ski?= Date: Sat, 7 Aug 2021 20:22:12 +0200 Subject: [PATCH 2/6] timeline: Add clip markers Uses the existing MarkerBox as a base, adds the ability to display and manage markers on each source visible on the timeline, moves marker-related actions to the TimelineContainer, as well as extends tests coverage. --- data/pixmaps/clip-marker-hover.png | Bin 0 -> 629 bytes data/pixmaps/clip-marker-select.png | Bin 0 -> 630 bytes data/pixmaps/clip-marker.png | Bin 0 -> 616 bytes pitivi/check.py | 2 +- pitivi/clipproperties.py | 4 +- pitivi/timeline/elements.py | 86 +++++++--- pitivi/timeline/markers.py | 235 +++++++++++++++++++--------- pitivi/timeline/timeline.py | 105 ++++++++++++- pitivi/utils/ui.py | 44 ++++++ pitivi/utils/widgets.py | 10 +- tests/test_effects.py | 4 +- tests/test_timeline_markers.py | 137 ++++++++++++++-- 12 files changed, 505 insertions(+), 122 deletions(-) create mode 100644 data/pixmaps/clip-marker-hover.png create mode 100644 data/pixmaps/clip-marker-select.png create mode 100644 data/pixmaps/clip-marker.png diff --git a/data/pixmaps/clip-marker-hover.png b/data/pixmaps/clip-marker-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9216a8a6bf91621ec1f99f15ad04c2c72f6fba GIT binary patch literal 629 zcmV-*0*d{KP)EX>4Tx04R}tkv&MmKpe$iQ>7vm!4?#8$WWauh>AFB6^c+H)C#RSm|XfHG-*gu zTpR`0f`cE6RR;V;BhS0*#vEd>=bb;{*sk16O*>U#SDrpQP7X zTI>ku-3BhMTbi;5TBg&i4QS0CGu0K~xyi#m%t^0zni-(KFM{ zZ$RuoFxfUtWQ!3@tV57x>+pjHwjh|RsmQWU<_#YQUdhZoR^~gE%zUE!fUnwspj3_h z84e9@sMz2i;{+P9h=@ythJP)EX>4Tx04R}tkv&MmKpe$iQ>7vm!4?#8$WWauh>AFB6^c+H)C#RSm|XfHG-*gu zTpR`0f`cE6RR;V;BhS0*#vEd>=bb;{*sk16O*>U#SDrpQP7X zTI>ku-3BhMTbi;5TFF_W==Rf7?_foN36_e=&(ik0e7_lL8%(^ zXE-*vvA*xQ%%B_gH6rdZh=?aPIDDWJ))DcT+{w&7GyCuU1-iHxeDmvz Q9{>OV07*qoM6N<$f^(VzrT_o{ literal 0 HcmV?d00001 diff --git a/data/pixmaps/clip-marker.png b/data/pixmaps/clip-marker.png new file mode 100644 index 0000000000000000000000000000000000000000..f061dc8f5495a7ce74be9ff264e7d6d90be348cf GIT binary patch literal 616 zcmV-u0+;=XP)EX>4Tx04R}tkv&MmKpe$iQ>7vm!4?#8$WWauh>AFB6^c+H)C#RSm|XfHG-*gu zTpR`0f`cE6RR;V;BhS0*#vEd>=bb;{*sk16O*>U#SDrpQP7X zTI>ku-3BhMTbi;5T 1.19 +GST_VERSION = "1.17.90" # FIXME Remove checks in proxy.py and utils/markers.py once we bump to >= 1.19.2 GTK_API_VERSION = "3.0" GLIB_API_VERSION = "2.0" HARD_DEPENDENCIES = [GICheck(version_required="3.20.0"), diff --git a/pitivi/clipproperties.py b/pitivi/clipproperties.py index 59bb166c9..d847f4e9e 100644 --- a/pitivi/clipproperties.py +++ b/pitivi/clipproperties.py @@ -1036,12 +1036,12 @@ class TransformationProperties(Gtk.Expander, Loggable): else: self._activate_keyframes_btn.set_tooltip_text( _("Activate keyframes")) - self.source.ui_element.show_default_keyframes() + self.source.ui.show_default_keyframes() else: self._prev_keyframe_btn.set_sensitive(True) self._next_keyframe_btn.set_sensitive(True) self._activate_keyframes_btn.set_tooltip_text(_("Hide keyframes")) - self.source.ui_element.show_multiple_keyframes( + self.source.ui.show_multiple_keyframes( list(self.__control_bindings.values())) def __update_control_bindings(self): diff --git a/pitivi/timeline/elements.py b/pitivi/timeline/elements.py index ad0c8076a..b6f8c1940 100644 --- a/pitivi/timeline/elements.py +++ b/pitivi/timeline/elements.py @@ -35,6 +35,8 @@ from matplotlib.lines import Line2D from pitivi.configure import get_pixmap_dir from pitivi.effects import ALLOWED_ONLY_ONCE_EFFECTS +from pitivi.timeline.markers import ClipMarkersBox +from pitivi.timeline.markers import Marker from pitivi.timeline.previewers import AudioPreviewer from pitivi.timeline.previewers import ImagePreviewer from pitivi.timeline.previewers import TitlePreviewer @@ -50,7 +52,7 @@ from pitivi.utils.timeline import Selected from pitivi.utils.timeline import UNSELECT from pitivi.utils.timeline import Zoomable from pitivi.utils.ui import EFFECT_TARGET_ENTRY -from pitivi.utils.ui import set_children_state_recurse +from pitivi.utils.ui import set_children_state_except from pitivi.utils.ui import unset_children_state_recurse KEYFRAME_LINE_HEIGHT = 2 @@ -654,9 +656,6 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable): self.__width = 0 self.__height = 0 - # Needed for effect's keyframe toggling - self._ges_elem.ui_element = self - self.props.vexpand = True self.__previewer = self._get_previewer() @@ -667,6 +666,9 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable): if self.__background: self.add(self.__background) + self.markers = ClipMarkersBox(self.app, self._ges_elem) + self.add(self.markers) + self.keyframe_curve = None self.__controlled_property = None self.show_all() @@ -685,22 +687,18 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable): if self.__previewer: self.__previewer.release() + if self.markers: + self.markers.release() + # Public API def set_size(self, width, height): width = max(0, width) self.set_size_request(width, height) - if self.__previewer: - self.__previewer.set_size_request(width, height) - - if self.__background: - self.__background.set_size_request(width, height) - - if self.keyframe_curve: - self.keyframe_curve.set_size_request(width, height) - - self.__width = width - self.__height = height + if self.__width != width or self.__height != height: + self.__width = width + self.__height = height + self.update_sizes_and_positions() def show_keyframes(self, ges_elem, prop): self.__set_keyframes(ges_elem, prop) @@ -771,7 +769,7 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable): self.keyframe_curve.connect("leave", self.__curve_leave_cb) self.keyframe_curve.set_size_request(self.__width, self.__height) self.keyframe_curve.show() - self.__update_keyframe_curve_visibility() + self.__update_keyframe_curve() def __create_control_binding(self, element): """Creates the required ControlBinding and keyframes.""" @@ -806,23 +804,47 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable): if project.pipeline.get_simple_state() != Gst.State.PLAYING: self.propagate_draw(self.keyframe_curve, cr) + if self.markers and self.markers.is_drawable(): + self.propagate_draw(self.markers, cr) + # Callbacks def __selected_changed_cb(self, unused_selected, selected): if not self.keyframe_curve and self.__controlled_property and \ selected and len(self.timeline.selection) == 1: self.__create_keyframe_curve() - if self.keyframe_curve: - self.__update_keyframe_curve_visibility() - if self.__previewer: self.__previewer.set_selected(selected) - def __update_keyframe_curve_visibility(self): + self.update_sizes_and_positions() + + def update_sizes_and_positions(self): + markers_height = self.markers.props.height_request + width = self.__width + height = self.__height + + if self.__background: + self.__background.set_size_request(width, height) + + if self.__previewer: + self.__previewer.set_size_request(width, height) + + if self.markers: + self.markers.set_size_request(width, markers_height) + + # Prevent keyframe curve from overlapping onto markers. + if self.keyframe_curve: + self.keyframe_curve.set_size_request(self.__width, self.__height - markers_height) + self.__update_keyframe_curve() + + def __update_keyframe_curve(self): """Updates the keyframes widget visibility by adding or removing it.""" if self._ges_elem.selected and len(self.timeline.selection) == 1: + markers_height = self.markers.props.height_request if not self.keyframe_curve.get_parent(): - self.add(self.keyframe_curve) + self.put(self.keyframe_curve, 0, markers_height) + else: + self.move(self.keyframe_curve, 0, markers_height) else: self.remove(self.keyframe_curve) @@ -1277,6 +1299,15 @@ class Clip(Gtk.EventBox, Zoomable, Loggable): parent_height != self._current_parent_height or \ layer != self._current_parent: + offset_px = self.ns_to_pixel(self.ges_clip.props.in_point) + + for ges_timeline_element in self.ges_clip.get_children(False): + if not ges_timeline_element.ui: + continue + + if ges_timeline_element.ui.markers: + ges_timeline_element.ui.markers.offset = offset_px + layer.move(self, x, y) self.set_size_request(width, height) @@ -1374,7 +1405,7 @@ class Clip(Gtk.EventBox, Zoomable, Loggable): if (event.type == Gdk.EventType.ENTER_NOTIFY and event.mode == Gdk.CrossingMode.NORMAL and not self.timeline.scrubbing): - set_children_state_recurse(self, Gtk.StateFlags.PRELIGHT) + set_children_state_except(self, Gtk.StateFlags.PRELIGHT, Marker) for handle in self.handles: handle.enlarge() elif (event.type == Gdk.EventType.LEAVE_NOTIFY and @@ -1440,6 +1471,17 @@ class SourceClip(Clip): self.get_style_context().add_class("Clip") + def _add_child(self, ges_timeline_element): + super()._add_child(ges_timeline_element) + + # In some cases a GESEffect is added here, + # so we have to limit the markers initialization to GESSources. + if not isinstance(ges_timeline_element, GES.Source): + return + + if not hasattr(ges_timeline_element, "markers_manager"): + ges_timeline_element.markers_manager = MarkerListManager(self.app, ges_timeline_element) + def _remove_child(self, ges_timeline_element): if ges_timeline_element.ui: self._elements_container.remove(ges_timeline_element.ui) diff --git a/pitivi/timeline/markers.py b/pitivi/timeline/markers.py index ae6c0f6dc..024c2a4a4 100644 --- a/pitivi/timeline/markers.py +++ b/pitivi/timeline/markers.py @@ -15,26 +15,24 @@ # You should have received a copy of the GNU Lesser General Public # License along with this program; if not, see . """Markers display and management.""" -from gettext import gettext as _ from typing import Optional from gi.repository import Gdk -from gi.repository import Gio -from gi.repository import GLib from gi.repository import Gtk from pitivi.utils.loggable import Loggable -from pitivi.utils.pipeline import PipelineError from pitivi.utils.timeline import Zoomable from pitivi.utils.ui import SPACING -MARKER_WIDTH = 10 +TIMELINE_MARKER_SIZE = 10 +CLIP_MARKER_HEIGHT = 12 +CLIP_MARKER_WIDTH = 10 class Marker(Gtk.EventBox, Loggable): """Widget representing a marker.""" - def __init__(self, ges_marker): + def __init__(self, ges_marker, class_name, width, height): Gtk.EventBox.__init__(self) Loggable.__init__(self) @@ -44,8 +42,10 @@ class Marker(Gtk.EventBox, Loggable): self.ges_marker = ges_marker self.ges_marker.ui = self self.position_ns = self.ges_marker.props.position + self.width = width + self.height = height - self.get_style_context().add_class("Marker") + self.get_style_context().add_class(class_name) self.ges_marker.connect("notify-meta", self._notify_meta_cb) self._selected = False @@ -54,10 +54,10 @@ class Marker(Gtk.EventBox, Loggable): return Gtk.SizeRequestMode.CONSTANT_SIZE def do_get_preferred_height(self): - return MARKER_WIDTH, MARKER_WIDTH + return self.height, self.height def do_get_preferred_width(self): - return MARKER_WIDTH, MARKER_WIDTH + return self.width, self.width def do_enter_notify_event(self, unused_event): self.set_state_flags(Gtk.StateFlags.PRELIGHT, clear=False) @@ -117,8 +117,8 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): self.props.hexpand = True self.props.valign = Gtk.Align.START - self.offset = 0 - self.props.height_request = MARKER_WIDTH + self._offset = 0 + self.props.height_request = TIMELINE_MARKER_SIZE self.__markers_container = None self.marker_moving = None @@ -128,55 +128,16 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK) - self._create_actions() - - def _create_actions(self): - self.action_group = Gio.SimpleActionGroup() - self.insert_action_group("markers", self.action_group) - self.app.shortcuts.register_group("markers", _("Markers"), position=70) - - self.add_marker_action = Gio.SimpleAction.new("marker-add", GLib.VariantType("mx")) - self.add_marker_action.connect("activate", self._add_marker_cb) - self.action_group.add_action(self.add_marker_action) - self.app.shortcuts.add("markers.marker-add(@mx nothing)", ["m"], - self.add_marker_action, - _("Add a marker")) - - self.seek_backward_marker_action = Gio.SimpleAction.new("seek-backward-marker", None) - self.seek_backward_marker_action.connect("activate", self._seek_backward_marker_cb) - self.action_group.add_action(self.seek_backward_marker_action) - self.app.shortcuts.add("markers.seek-backward-marker", ["Left"], - self.seek_backward_marker_action, - _("Seek to the first marker before the playhead")) - - self.seek_forward_marker_action = Gio.SimpleAction.new("seek-forward-marker", None) - self.seek_forward_marker_action.connect("activate", self._seek_forward_marker_cb) - self.action_group.add_action(self.seek_forward_marker_action) - self.app.shortcuts.add("markers.seek-forward-marker", ["Right"], - self.seek_forward_marker_action, - _("Seek to the first marker after the playhead")) - - def _seek_backward_marker_cb(self, action, param): - current_position = self.app.project_manager.current_project.pipeline.get_position(fails=False) - position = self.first_marker(before=current_position) - if position is None: - return - - self.app.project_manager.current_project.pipeline.simple_seek(position) - self.app.gui.editor.timeline_ui.timeline.scroll_to_playhead(align=Gtk.Align.CENTER, when_not_in_view=True) - - def _seek_forward_marker_cb(self, action, param): - current_position = self.app.project_manager.current_project.pipeline.get_position(fails=False) - position = self.first_marker(after=current_position) - if position is None: - return - - self.app.project_manager.current_project.pipeline.simple_seek(position) - self.app.gui.editor.timeline_ui.timeline.scroll_to_playhead(align=Gtk.Align.CENTER, when_not_in_view=True) - def first_marker(self, before: Optional[int] = None, after: Optional[int] = None) -> Optional[int]: + """Returns position of the closest marker found before or after the given timestamp. + + None is returned if no such marker is found. + """ assert (after is not None) != (before is not None) + if not self.markers_container: + return None + if after is not None: start = after + 1 end = self.app.project_manager.current_project.ges_timeline.props.duration @@ -188,7 +149,7 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): return None markers_positions = list([ges_marker.props.position - for ges_marker in self.__markers_container.get_markers() + for ges_marker in self.markers_container.get_markers() if start <= ges_marker.props.position < end]) if not markers_positions: return None @@ -198,19 +159,12 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): else: return max(markers_positions) - def _add_marker_cb(self, action, param): - maybe = param.get_maybe() - if maybe: - position = maybe.get_int64() - else: - try: - position = self.app.project_manager.current_project.pipeline.get_position(fails=False) - except PipelineError: - self.warning("Could not get pipeline position") - return + def add_at_timeline_time(self, position): + """Adds a marker at the given timeline position.""" + if not self.markers_container: + return - with self.app.action_log.started("Added marker", toplevel=True): - self.__markers_container.add(position) + self.markers_container.add(position) @property def markers_container(self): @@ -223,6 +177,8 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): for marker in self.layout.get_children(): self.layout.remove(marker) self.__markers_container.disconnect_by_func(self._marker_added_cb) + self.__markers_container.disconnect_by_func(self._marker_removed_cb) + self.__markers_container.disconnect_by_func(self._marker_moved_cb) self.__markers_container = ges_markers_container if self.__markers_container: @@ -231,6 +187,24 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): self.__markers_container.connect("marker-removed", self._marker_removed_cb) self.__markers_container.connect("marker-moved", self._marker_moved_cb) + def release(self): + if self.__markers_container: + self.__markers_container.disconnect_by_func(self._marker_added_cb) + self.__markers_container.disconnect_by_func(self._marker_removed_cb) + self.__markers_container.disconnect_by_func(self._marker_moved_cb) + + @property + def offset(self): + return self._offset + + @offset.setter + def offset(self, value): + if self.offset == value: + return + + self._offset = value + self._update_position() + def __create_marker_widgets(self): markers = self.__markers_container.get_markers() @@ -241,17 +215,19 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): def _hadj_value_changed_cb(self, hadj): """Handles the adjustment value change.""" self.offset = hadj.get_value() - self._update_position() def zoom_changed(self): self._update_position() def _update_position(self): for marker in self.layout.get_children(): - position = self.ns_to_pixel(marker.position) - self.offset - MARKER_WIDTH / 2 + position = self.ns_to_pixel(marker.position) - self.offset - marker.width / 2 self.layout.move(marker, position, 0) def do_button_press_event(self, event): + if not self.markers_container: + return False + event_widget = Gtk.get_event_widget(event) button = event.button if button == Gdk.BUTTON_PRIMARY: @@ -269,11 +245,15 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): else: position = self.pixel_to_ns(event.x + self.offset) - param = GLib.Variant.new_maybe(GLib.VariantType("x"), GLib.Variant.new_int64(position)) - self.add_marker_action.activate(param) - self.marker_new.selected = True + with self.app.action_log.started("Added marker", toplevel=True): + self.__markers_container.add(position) + self.marker_new.selected = True + return True def do_button_release_event(self, event): + if not self.markers_container: + return False + button = event.button event_widget = Gtk.get_event_widget(event) if button == Gdk.BUTTON_PRIMARY: @@ -281,28 +261,42 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): self.marker_moving.selected = False self.marker_moving = None self.app.action_log.commit("Move marker") + return True elif self.marker_new: self.marker_new.selected = False self.marker_new = None + return True elif button == Gdk.BUTTON_SECONDARY and isinstance(event_widget, Marker): with self.app.action_log.started("Removed marker", toplevel=True): self.__markers_container.remove(event_widget.ges_marker) + return True + + return False def do_motion_notify_event(self, event): + if not self.markers_container: + return False + event_widget = Gtk.get_event_widget(event) if event_widget is self.marker_moving: event_x, unused_y = event_widget.translate_coordinates(self, event.x, event.y) event_x = max(0, event_x) position_ns = self.pixel_to_ns(event_x + self.offset) self.__markers_container.move(self.marker_moving.ges_marker, position_ns) + return True + + return False def _marker_added_cb(self, unused_markers, position, ges_marker): self._add_marker(position, ges_marker) + def _create_marker(self, ges_marker): + return Marker(ges_marker, "Marker", TIMELINE_MARKER_SIZE, TIMELINE_MARKER_SIZE) + def _add_marker(self, position, ges_marker): - marker = Marker(ges_marker) - x = self.ns_to_pixel(position) - self.offset - MARKER_WIDTH / 2 + marker = self._create_marker(ges_marker) + x = self.ns_to_pixel(position) - self.offset - marker.width / 2 self.layout.put(marker, x, 0) marker.show() self.marker_new = marker @@ -322,7 +316,7 @@ class MarkersBox(Gtk.EventBox, Zoomable, Loggable): self._move_marker(position, ges_marker) def _move_marker(self, position, ges_marker): - x = self.ns_to_pixel(position) - self.offset - MARKER_WIDTH / 2 + x = self.ns_to_pixel(position) - self.offset - ges_marker.ui.width / 2 self.layout.move(ges_marker.ui, x, 0) @@ -359,3 +353,88 @@ class MarkerPopover(Gtk.Popover): with self.app.action_log.started("marker comment", toplevel=True): self.marker.comment = buffer.props.text self.marker.selected = False + + +class ClipMarkersBox(MarkersBox): + def __init__(self, app, ges_elem, hadj=None): + super().__init__(app, hadj=hadj) + self.ges_elem = ges_elem + # ges_elem is a GESSource, but we need a GESClip to convert timestamps + self.ges_clip = self.ges_elem.get_parent() + # Initially hide the box - only show once a marker container is set + self.props.height_request = 0 + self.get_style_context().add_class("ClipMarkersBox") + + def _create_marker(self, ges_marker): + return Marker(ges_marker, "ClipMarker", CLIP_MARKER_WIDTH, CLIP_MARKER_HEIGHT) + + def __internal_to_timeline(self, timestamp): + return self.ges_clip.get_timeline_time_from_internal_time(self.ges_elem, timestamp) + + def __timeline_to_internal(self, timestamp): + return self.ges_clip.get_internal_time_from_timeline_time(self.ges_elem, timestamp) + + def first_marker(self, before: Optional[int] = None, after: Optional[int] = None) -> Optional[int]: + assert (after is not None) != (before is not None) + + if not self.markers_container: + return None + + # Limit search to visible markers + clip_start = self.ges_clip.props.start + clip_end = self.ges_clip.props.start + self.ges_clip.props.duration + + if after is not None: + start = max(after + 1, clip_start) + end = clip_end + else: + start = clip_start + end = min(before, clip_end) + + if start >= end: + return None + + markers_positions = [self.__internal_to_timeline(marker.props.position) + for marker in self.markers_container.get_markers() + if start <= self.__internal_to_timeline(marker.props.position) < end] + if not markers_positions: + return None + + if after is not None: + return min(markers_positions) + else: + return max(markers_positions) + + def add_at_timeline_time(self, position): + if not self.markers_container: + return + + start = self.ges_clip.props.start + inpoint = self.ges_clip.props.in_point + duration = self.ges_clip.props.duration + + # Prevent timestamp conversion failing due to negative result. + if position < start: + return + + internal_end = self.__timeline_to_internal(start + duration) + timestamp = self.__timeline_to_internal(position) + + # Check if marker would land in the 'visible' part of the clip. + if not inpoint <= timestamp <= internal_end: + return + + self.markers_container.add(timestamp) + + @MarkersBox.markers_container.setter + def markers_container(self, ges_markers_container): + MarkersBox.markers_container.fset(self, ges_markers_container) + + # Hide the box when no list is selected. + height = CLIP_MARKER_HEIGHT if ges_markers_container else 0 + self.props.height_request = height + # Let the parent TimelineElement know to update sizes accordingly. + parent_el = self.get_parent() + if parent_el is not None: + parent_el.update_sizes_and_positions() + parent_el.queue_draw() diff --git a/pitivi/timeline/timeline.py b/pitivi/timeline/timeline.py index b92845339..d94dc118f 100644 --- a/pitivi/timeline/timeline.py +++ b/pitivi/timeline/timeline.py @@ -45,7 +45,9 @@ from pitivi.timeline.previewers import Previewer from pitivi.timeline.ruler import TimelineScaleRuler from pitivi.undo.timeline import CommitTimelineFinalizingAction from pitivi.utils.loggable import Loggable +from pitivi.utils.markers import GES_MARKERS_SNAPPABLE from pitivi.utils.misc import asset_get_duration +from pitivi.utils.pipeline import PipelineError from pitivi.utils.timeline import EditingContext from pitivi.utils.timeline import SELECT from pitivi.utils.timeline import Selection @@ -72,6 +74,19 @@ from pitivi.utils.widgets import ZoomBox SEPARATOR_ACCEPTING_DROP_INTERVAL_MS = 1000 +GlobalSettings.add_config_option('markersSnappableByDefault', + section="user-interface", + key="markers-snappable-default", + default=False, + notify=False) + +if GES_MARKERS_SNAPPABLE: + PreferencesDialog.add_toggle_preference('markersSnappableByDefault', + section="timeline", + label=_("Markers magnetic by default"), + description=_( + "Whether markers created on new clips will be snapping targets by default.")) + GlobalSettings.add_config_option('edgeSnapDeadband', section="user-interface", key="edge-snap-deadband", @@ -1909,7 +1924,26 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable): _("Seek forward one second")) # Markers actions. - self.timeline.layout.insert_action_group("markers", self.markers.action_group) + self.add_marker_action = Gio.SimpleAction.new("marker-add", None) + self.add_marker_action.connect("activate", self._add_marker_cb) + navigation_group.add_action(self.add_marker_action) + self.app.shortcuts.add("navigation.marker-add", ["m"], + self.add_marker_action, + _("Add a marker")) + + self.seek_backward_marker_action = Gio.SimpleAction.new("seek-backward-marker", None) + self.seek_backward_marker_action.connect("activate", self._seek_backward_marker_cb) + navigation_group.add_action(self.seek_backward_marker_action) + self.app.shortcuts.add("navigation.seek-backward-marker", ["Left"], + self.seek_backward_marker_action, + _("Seek to the first marker before the playhead")) + + self.seek_forward_marker_action = Gio.SimpleAction.new("seek-forward-marker", None) + self.seek_forward_marker_action.connect("activate", self._seek_forward_marker_cb) + navigation_group.add_action(self.seek_forward_marker_action) + self.app.shortcuts.add("navigation.seek-forward-marker", ["Right"], + self.seek_forward_marker_action, + _("Seek to the first marker after the playhead")) # Viewer actions. self.timeline.layout.insert_action_group("viewer", self.app.gui.editor.viewer.action_group) @@ -2320,3 +2354,72 @@ class TimelineContainer(Gtk.Grid, Zoomable, Loggable): win.set_transient_for(self.app.gui) win.show_all() + + def __get_current_marker_boxes(self): + # Return a list of the selected elements' marker boxes + sources = self.timeline.selection.get_selected_track_elements() + if sources: + return [source.ui.markers for source in sources] + + # Else focus on timeline markers + return [self.markers] + + def __find_closest_marker(self, containers, before=None, after=None): + if not containers: + return None + + position = before if before else after + timestamps = [] + for container in containers: + timestamp = container.first_marker(before, after) + if timestamp is not None: + timestamps.append(timestamp) + + if not timestamps: + return None + + closest_timestamp = min(timestamps, key=lambda timestamp: abs(position - timestamp)) + return closest_timestamp + + def _add_marker_cb(self, action, param): + try: + position = self.app.project_manager.current_project.pipeline.get_position(fails=False) + except PipelineError: + self.warning("Could not get pipeline position") + return + + containers = self.__get_current_marker_boxes() + + with self.app.action_log.started("Added marker", toplevel=True): + for marker_container in containers: + marker_container.add_at_timeline_time(position) + + def _seek_backward_marker_cb(self, action, param): + try: + timeline_position = self.app.project_manager.current_project.pipeline.get_position(fails=False) + except PipelineError: + self.warning("Could not get pipeline position") + return + + containers = self.__get_current_marker_boxes() + position = self.__find_closest_marker(containers, before=timeline_position) + if position is None: + return + + self.app.project_manager.current_project.pipeline.simple_seek(position) + self.app.gui.editor.timeline_ui.timeline.scroll_to_playhead(align=Gtk.Align.CENTER, when_not_in_view=True) + + def _seek_forward_marker_cb(self, action, param): + try: + timeline_position = self.app.project_manager.current_project.pipeline.get_position(fails=False) + except PipelineError: + self.warning("Could not get pipeline position") + return + + containers = self.__get_current_marker_boxes() + position = self.__find_closest_marker(containers, after=timeline_position) + if position is None: + return + + self.app.project_manager.current_project.pipeline.simple_seek(position) + self.app.gui.editor.timeline_ui.timeline.scroll_to_playhead(align=Gtk.Align.CENTER, when_not_in_view=True) diff --git a/pitivi/utils/ui.py b/pitivi/utils/ui.py index 129b31d90..56094139e 100644 --- a/pitivi/utils/ui.py +++ b/pitivi/utils/ui.py @@ -294,10 +294,40 @@ EDITOR_PERSPECTIVE_CSS = """ background-image: url('%(marker_hovered)s'); } + .ClipMarkersBox { + transition: 0.15s ease-out; + opacity: 0.7; + } + + .ClipMarkersBox:hover { + background-color: rgba(0, 0, 0, 0.15); + opacity: 0.85; + } + + .ClipMarkersBox:selected { + background-color: rgb(0, 0, 0); + opacity: 1; + } + + .ClipMarker { + background-image: url('%(clip_marker_unselected)s'); + } + + .ClipMarker:hover { + background-image: url('%(clip_marker_hovered)s'); + } + + .ClipMarker:selected { + background-image: url('%(clip_marker_selected)s'); + } + """ % ({ 'clip_border_width': CLIP_BORDER_WIDTH, 'marker_hovered': os.path.join(get_pixmap_dir(), "marker-hover.png"), 'marker_unselected': os.path.join(get_pixmap_dir(), "marker-unselect.png"), + 'clip_marker_unselected': os.path.join(get_pixmap_dir(), "clip-marker.png"), + 'clip_marker_hovered': os.path.join(get_pixmap_dir(), "clip-marker-hover.png"), + 'clip_marker_selected': os.path.join(get_pixmap_dir(), "clip-marker-select.png"), 'trimbar_focused': os.path.join(get_pixmap_dir(), "trimbar-focused.png"), 'trimbar_normal': os.path.join(get_pixmap_dir(), "trimbar-normal.png")}) @@ -840,6 +870,7 @@ def alter_style_class(style_class, target_widget, css_style): def set_children_state_recurse(widget, state): + """Sets the provided state on all children of the given widget.""" widget.set_state_flags(state, False) for child in widget.get_children(): child.set_state_flags(state, False) @@ -847,7 +878,20 @@ def set_children_state_recurse(widget, state): set_children_state_recurse(child, state) +def set_children_state_except(widget, state, *ignored_types): + """Sets the provided state on all children of the widget, except those of given types.""" + widget.set_state_flags(state, False) + for child in widget.get_children(): + if any(isinstance(child, klass) for klass in ignored_types): + continue + + child.set_state_flags(state, False) + if isinstance(child, Gtk.Container): + set_children_state_except(child, state, ignored_types) + + def unset_children_state_recurse(widget, state): + """Unsets the provided state on all children of the given widget.""" widget.unset_state_flags(state) for child in widget.get_children(): child.unset_state_flags(state) diff --git a/pitivi/utils/widgets.py b/pitivi/utils/widgets.py index 7aec460dd..9a92d1453 100644 --- a/pitivi/utils/widgets.py +++ b/pitivi/utils/widgets.py @@ -1019,11 +1019,11 @@ class GstElementSettingsWidget(Gtk.Box, Loggable): return if active: - track_element.ui_element.show_keyframes(self.element, prop) + track_element.ui.show_keyframes(self.element, prop) binding = self.element.get_control_binding(prop.name) self.__bindings_by_keyframe_button[keyframe_button] = binding else: - track_element.ui_element.show_default_keyframes() + track_element.ui.show_default_keyframes() def __reset_to_default_clicked_cb(self, unused_button, widget, keyframe_button=None): @@ -1037,7 +1037,7 @@ class GstElementSettingsWidget(Gtk.Box, Loggable): track_element = self.__get_track_element_of_same_type( self.element) if track_element: - track_element.ui_element.show_default_keyframes() + track_element.ui.show_default_keyframes() self.__set_keyframe_active(keyframe_button, False) self.__display_controlled(keyframe_button, False) @@ -1046,8 +1046,8 @@ class GstElementSettingsWidget(Gtk.Box, Loggable): def __get_track_element_of_same_type(self, effect): track_type = effect.get_track_type() for track_element in effect.get_parent().get_children(False): - if hasattr(track_element, "ui_element") and \ - track_element.get_track_type() == track_type: + if hasattr(track_element, "ui") and \ + track_element.get_track_type() == track_type and track_element != effect: return track_element self.warning("Failed to find track element of type %s", track_type) return None diff --git a/tests/test_effects.py b/tests/test_effects.py index 72dba9955..a27c464a8 100644 --- a/tests/test_effects.py +++ b/tests/test_effects.py @@ -144,10 +144,10 @@ class EffectsPropertiesManagerTest(common.TestCase): # Control the self.prop property on the timeline prop_keyframe_button.set_active(True) - self.assertEqual(track_element.ui_element._TimelineElement__controlled_property, self.prop) + self.assertEqual(track_element.ui._TimelineElement__controlled_property, self.prop) # Revert to controlling the default property prop_keyframe_button.set_active(False) - self.assertNotEqual(track_element.ui_element._TimelineElement__controlled_property, self.prop) + self.assertNotEqual(track_element.ui._TimelineElement__controlled_property, self.prop) def test_prop_reset(self): """Checks the reset button resets the property.""" diff --git a/tests/test_timeline_markers.py b/tests/test_timeline_markers.py index eb65451b5..87d3ef95d 100644 --- a/tests/test_timeline_markers.py +++ b/tests/test_timeline_markers.py @@ -19,6 +19,7 @@ from unittest import mock from gi.repository import Gdk +from gi.repository import GES from gi.repository import Gtk from pitivi.utils.timeline import Zoomable @@ -173,14 +174,128 @@ class TestMarkers(common.TestCase): marker_box.markers_container.add(12) self.assert_markers(markers, [(10, None), (12, None)]) - self.check_seek(marker_box.seek_forward_marker_action, 9, 10) - self.check_seek(marker_box.seek_forward_marker_action, 10, 12) - self.check_seek(marker_box.seek_forward_marker_action, 11, 12) - self.check_seek(marker_box.seek_forward_marker_action, 12, None) - self.check_seek(marker_box.seek_forward_marker_action, 13, None) - - self.check_seek(marker_box.seek_backward_marker_action, 9, None) - self.check_seek(marker_box.seek_backward_marker_action, 10, None) - self.check_seek(marker_box.seek_backward_marker_action, 11, 10) - self.check_seek(marker_box.seek_backward_marker_action, 12, 10) - self.check_seek(marker_box.seek_backward_marker_action, 13, 12) + self.check_seek(self.timeline_container.seek_forward_marker_action, 9, 10) + self.check_seek(self.timeline_container.seek_forward_marker_action, 10, 12) + self.check_seek(self.timeline_container.seek_forward_marker_action, 11, 12) + self.check_seek(self.timeline_container.seek_forward_marker_action, 12, None) + self.check_seek(self.timeline_container.seek_forward_marker_action, 13, None) + + self.check_seek(self.timeline_container.seek_backward_marker_action, 9, None) + self.check_seek(self.timeline_container.seek_backward_marker_action, 10, None) + self.check_seek(self.timeline_container.seek_backward_marker_action, 11, 10) + self.check_seek(self.timeline_container.seek_backward_marker_action, 12, 10) + self.check_seek(self.timeline_container.seek_backward_marker_action, 13, 12) + + @common.setup_timeline + def test_seeking_with_clips(self): + """Checks the seeking actions with clip markers present.""" + self.timeline.append_layer() + timeline = self.timeline_container.timeline + clip1 = self.add_clip(self.timeline.layers[0], start=0, duration=30) + clip2 = self.add_clip(self.timeline.layers[1], start=10, duration=20) + + markers1 = next(common.get_clip_children( + clip1, GES.TrackType.VIDEO)).ui.markers.markers_container + markers2 = next(common.get_clip_children( + clip2, GES.TrackType.VIDEO)).ui.markers.markers_container + + markers1.add(5) + markers1.add(25) + markers2.add(5) + markers2.add(15) + + forward_seek = self.timeline_container.seek_forward_marker_action + backward_seek = self.timeline_container.seek_backward_marker_action + + timeline.selection.select([clip1]) + self.check_seek(forward_seek, 0, 5) + self.check_seek(forward_seek, 5, 25) + self.check_seek(forward_seek, 25, None) + + timeline.selection.select([clip2]) + self.check_seek(forward_seek, 25, None) + self.check_seek(backward_seek, 25, 15) + self.check_seek(backward_seek, 15, None) + + timeline.selection.select([]) + self.check_seek(forward_seek, 15, None) + self.check_seek(backward_seek, 15, None) + + # When multiple clips are selected, take all their markers into consideration. + timeline.selection.select([clip1, clip2]) + self.check_seek(forward_seek, 15, 25) + self.check_seek(backward_seek, 25, 15) + self.check_seek(backward_seek, 15, 5) + + # Trim first 10 seconds of clip2, 'cutting off' its first marker. + clip2.trim(20) + self.check_seek(forward_seek, 5, 25) + + # Add a marker "outside" clip1 and check if it's correctly ignored. + markers1.add(50) + self.check_seek(forward_seek, 25, None) + + # Add a timeline marker which should be ignored while clips are selected. + timeline_markers = self.timeline_container.markers.markers_container + timeline_markers.add(28) + self.check_seek(forward_seek, 25, None) + + # Unselect clips and seek again. + timeline.selection.select([]) + self.check_seek(forward_seek, 25, 28) + + def perform_at_timeline_position(self, action, position): + pipeline = self.project.pipeline + with mock.patch.object(pipeline, "get_position") as get_position: + get_position.return_value = position + action.activate() + + @common.setup_timeline + def test_add_marker_action(self): + """Checks marker adding shortcut behaviour.""" + self.timeline.append_layer() + timeline = self.timeline_container.timeline + add_action = self.timeline_container.add_marker_action + clip1 = self.add_clip(self.timeline.layers[0], start=0, duration=20) + clip2 = self.add_clip(self.timeline.layers[1], start=10, duration=20) + + timeline_markers = self.timeline_container.markers.markers_container + markers1 = next(common.get_clip_children( + clip1, GES.TrackType.VIDEO)).ui.markers.markers_container + markers2 = next(common.get_clip_children( + clip2, GES.TrackType.VIDEO)).ui.markers.markers_container + + # No clips selected - should add a marker to the timeline. + self.perform_at_timeline_position(add_action, 15) + self.assert_markers(timeline_markers, [(15, None)]) + + # Multiple clips selected - add marker to all of them. + timeline.selection.select([clip1, clip2]) + self.perform_at_timeline_position(add_action, 15) + self.assert_markers(markers1, [(15, None)]) + self.assert_markers(markers2, [(5, None)]) + + # Adding a marker 'outside' of one clip should fail, but still add to other selected clips. + self.perform_at_timeline_position(add_action, 5) + self.assert_markers(markers1, [(5, None), (15, None)]) + self.assert_markers(markers2, [(5, None)]) + + self.perform_at_timeline_position(add_action, 25) + self.assert_markers(markers1, [(5, None), (15, None)]) + self.assert_markers(markers2, [(5, None), (15, None)]) + + # Make sure nothing was added to the timeline. + self.assert_markers(timeline_markers, [(15, None)]) + + # One clip selected - make sure no other clips are affected. + timeline.selection.select([clip1]) + self.perform_at_timeline_position(add_action, 10) + self.assert_markers(markers1, [(5, None), (10, None), (15, None)]) + self.assert_markers(markers2, [(5, None), (15, None)]) + + timeline.selection.select([clip2]) + self.perform_at_timeline_position(add_action, 20) + self.assert_markers(markers1, [(5, None), (10, None), (15, None)]) + self.assert_markers(markers2, [(5, None), (10, None), (15, None)]) + + self.assert_markers(timeline_markers, [(15, None)]) -- GitLab From 62664c036e89f1728f2bd686195ed9c7698f1383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotrek=20Brzezi=C5=84ski?= Date: Sat, 7 Aug 2021 20:32:02 +0200 Subject: [PATCH 3/6] utils: Add MarkerListManager Acts as an abstraction layer between the clip and UI: handles changing the currently active list, its snappability state, as well as exposes convienience methods for adding, removing and retrieving all marker lists of a given clip. --- pitivi/timeline/elements.py | 6 +- pitivi/utils/markers.py | 264 ++++++++++++++++++++++++++++++++++++ tests/common.py | 1 + tests/test_utils_markers.py | 146 ++++++++++++++++++++ 4 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 pitivi/utils/markers.py create mode 100644 tests/test_utils_markers.py diff --git a/pitivi/timeline/elements.py b/pitivi/timeline/elements.py index b6f8c1940..1d027143b 100644 --- a/pitivi/timeline/elements.py +++ b/pitivi/timeline/elements.py @@ -43,6 +43,7 @@ from pitivi.timeline.previewers import TitlePreviewer from pitivi.timeline.previewers import VideoPreviewer from pitivi.undo.timeline import CommitTimelineFinalizingAction from pitivi.utils.loggable import Loggable +from pitivi.utils.markers import MarkerListManager from pitivi.utils.misc import disconnect_all_by_func from pitivi.utils.misc import filename_from_uri from pitivi.utils.pipeline import PipelineError @@ -667,6 +668,8 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable): self.add(self.__background) self.markers = ClipMarkersBox(self.app, self._ges_elem) + self._ges_elem.markers_manager.set_markers_box(self.markers) + self.add(self.markers) self.keyframe_curve = None @@ -688,6 +691,7 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable): self.__previewer.release() if self.markers: + self._ges_elem.markers_manager.set_markers_box(None) self.markers.release() # Public API @@ -1480,7 +1484,7 @@ class SourceClip(Clip): return if not hasattr(ges_timeline_element, "markers_manager"): - ges_timeline_element.markers_manager = MarkerListManager(self.app, ges_timeline_element) + ges_timeline_element.markers_manager = MarkerListManager(self.app.settings, ges_timeline_element) def _remove_child(self, ges_timeline_element): if ges_timeline_element.ui: diff --git a/pitivi/utils/markers.py b/pitivi/utils/markers.py new file mode 100644 index 000000000..7314719c4 --- /dev/null +++ b/pitivi/utils/markers.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +# Pitivi video editor +# Copyright (c) 2021, Piotr Brzeziński +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This program 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program; if not, see . +from gettext import gettext as _ +from typing import List +from typing import Optional +from typing import Tuple + +from gi.repository import GES +from gi.repository import GObject + +from pitivi.settings import GlobalSettings +from pitivi.timeline.markers import MarkersBox + +# FIXME: Remove this once we depend on GES 1.20 +GES_MARKERS_SNAPPABLE = hasattr(GES.MarkerList.new().props, "flags") + +DEFAULT_LIST_KEY = "user_markers" +NAMES_DICT = { + DEFAULT_LIST_KEY: _("User markers"), +} + + +class MarkerListManager(GObject.Object): + """An abstraction layer between UI components and individual GESSources's marker lists. + + Attaches to a single GESSource, initialising a default marker list and tracking the + addition / removal of any other ones. Keeps track of the currently active list, which + is shown to the user if a MarkersBox has been attached via set_markers_box(). + """ + + __gsignals__ = { + # Emitted when a list is added or removed. + "lists-modified": (GObject.SignalFlags.RUN_LAST, None, ()), + # Emitted when the current list changes, along with its metadata key. + "current-list-changed": (GObject.SignalFlags.RUN_LAST, None, (str,)) + } + + def __init__(self, settings: GlobalSettings, ges_source: GES.Source): + GObject.Object.__init__(self) + self._ges_source: Optional[GES.Source] = ges_source + self._settings: GlobalSettings = settings + self._box: Optional[MarkersBox] = None + self._current_key: Optional[str] = None + self.__ensure_default_list_exists() + self._load_previous_or_default() + self._set_default_snappability() + + def set_markers_box(self, markers_box: Optional[MarkersBox]): + """Sets the MarkersBox used to display contents of the current marker list.""" + if self._box == markers_box: + return + + if markers_box and self._ges_source != markers_box.ges_elem: + raise ValueError("Marker box has to be attached to the same GESSource.") + + self._box = markers_box + if not self._box: + return + + self._box.markers_container = self.current_list + + def get_all_keys(self) -> List[str]: + """Returns a list of metadata keys under which a marker list can be found.""" + list_keys = [] + self._ges_source.foreach(MarkerListManager.__clip_meta_foreach_func, list_keys) + return list_keys + + def get_all_keys_with_names(self) -> List[Tuple[str, str]]: + """Returns a list of list keys along with their human-readable names. + + This exists for ease of use with the MarkerProperties component. + """ + list_keys = self.get_all_keys() + # If no name is found just display the key directly. + return [(key, NAMES_DICT.get(key, key)) for key in list_keys] + + def get_all_lists(self) -> List[GES.MarkerList]: + """Returns a list of all existing marker lists.""" + list_keys = self.get_all_keys() + return [self._ges_source.get_marker_list(key) for key in list_keys] + + def list_exists(self, list_key: str) -> bool: + """Returns whether a list under the given metadata key exists.""" + return list_key in self.get_all_keys() + + def add_list(self, key: str, marker_timestamps: Optional[List[int]] = None) -> GES.MarkerList: + """Creates an empty marker list and saves it under the given key. + + The key cannot be empty, can't contain spaces, and another list cannot + already exist under the given key. + A list of timestamps can be provided to automatically add corresponding + markers to the newly created list. + + Emits the "lists-modified" signal after the list is successfully added. + """ + if not key: + raise ValueError("You must provide a key for the list.") + if self._ges_source.get_marker_list(key): + raise ValueError("A list already exists under the given key.") + if " " in key: + raise ValueError("List key cannot contain a space character.") + + marker_list = GES.MarkerList.new() + + if GES_MARKERS_SNAPPABLE and self._settings.markersSnappableByDefault: + marker_list.props.flags |= GES.MarkerFlags.SNAPPABLE + + if marker_timestamps: + for timestamp in marker_timestamps: + marker_list.add(timestamp) + + self._ges_source.set_marker_list(key, marker_list) + self.emit("lists-modified") + return marker_list + + def remove_list(self, key: str): + """Removes the marker list found under the given key. + + If the list being removed is currently active, the default list + will be set as active instead. + + Note: the default "user_markers" cannot be removed. + """ + if not key: + raise ValueError("You must provide a key for the list.") + + if key == DEFAULT_LIST_KEY: + raise ValueError("Cannot remove the default marker list.") + + if key == self.current_list_key: + self._load_default() + + self._ges_source.set_meta(key, None) + self.emit("lists-modified") + + @property + def current_list_key(self) -> Optional[str]: + """Returns the metadata key under which the current list can be found.""" + return self._current_key + + @current_list_key.setter + def current_list_key(self, list_key: str): + """Sets the marker list found under the given key as the currently active one. + + If no list should be active, an empty string needs to be given as the key. + """ + if list_key is None: + raise ValueError("No metadata key has been provided.") + + if self.current_list_key == list_key: + return + + # Don't retrieve the list if the key is an empty string. + new_list = None + if list_key: + new_list = self._ges_source.get_marker_list(list_key) + if not new_list: + raise ValueError("Invalid metadata key has been provided.") + + # Turn snappability off for lists going inactive. + # The was_snappable value is not being serialized, thus + # after reloading it will be set to the default user-preferred value. + if self.current_list: + self.current_list.was_snappable = self.snappable + self.snappable = False + + # Set the new list as active and restore its snappable state if it's not None. + self._current_key = list_key + + if new_list and hasattr(new_list, "was_snappable"): + self.snappable = new_list.was_snappable + if self._box: + self._box.markers_container = new_list + + # This lets us preserve the current list between sessions. + self._ges_source.set_string("last_chosen_list", list_key) + self.emit("current-list-changed", self.current_list_key) + + @property + def current_list(self) -> Optional[GES.MarkerList]: + """Returns the currently active marker list.""" + if self.current_list_key is None: + return None + + return self._ges_source.get_marker_list(self.current_list_key) + + @property + def snappable(self) -> bool: + """Returns whether the current list is considered a snapping target.""" + if not GES_MARKERS_SNAPPABLE: + return False + + if not self.current_list: + return False + + return self.current_list.props.flags & GES.MarkerFlags.SNAPPABLE + + @snappable.setter + def snappable(self, snappable: bool): + """Sets the snappable flag of the current list to a given value.""" + if not GES_MARKERS_SNAPPABLE: + return + + if not self.current_list: + return + + if self.snappable == snappable: + return + + if snappable: + self.current_list.props.flags |= GES.MarkerFlags.SNAPPABLE + else: + self.current_list.props.flags &= ~GES.MarkerFlags.SNAPPABLE + + def _load_previous_or_default(self): + last_list_key = self._ges_source.get_string("last_chosen_list") + + # The default list is guaranteed to exist at this point. + list_key = DEFAULT_LIST_KEY if last_list_key is None else last_list_key + self.current_list_key = list_key + + def _load_default(self): + self.current_list_key = DEFAULT_LIST_KEY + + def _set_default_snappability(self): + """Sets user-chosen snappability state for all lists except for the active one. + + This is assumed to be only called once, after the default / previously chosen + list has been loaded up. + """ + snappable_by_default = self._settings.markersSnappableByDefault + all_lists = self.get_all_lists() + + for marker_list in all_lists: + # Ignore the active list - its flags were loaded from the project file. + if marker_list == self.current_list: + continue + + marker_list.was_snappable = snappable_by_default + + def __ensure_default_list_exists(self): + if self._ges_source.get_marker_list(DEFAULT_LIST_KEY): + return + + self.add_list(DEFAULT_LIST_KEY) + + @staticmethod + def __clip_meta_foreach_func(container, key, value, keys): + if isinstance(value, GES.MarkerList): + keys.append(key) diff --git a/tests/common.py b/tests/common.py index cd99ea432..bba4738cd 100644 --- a/tests/common.py +++ b/tests/common.py @@ -314,6 +314,7 @@ def setup_clipproperties(func): self.transformation_box._new_project_loaded_cb(None, self.project) self.speed_box = self.clipproperties.speed_expander + self.markers_box = self.clipproperties.marker_expander func(self) diff --git a/tests/test_utils_markers.py b/tests/test_utils_markers.py new file mode 100644 index 000000000..dc9c9077c --- /dev/null +++ b/tests/test_utils_markers.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Pitivi video editor +# Copyright (c) 2021, Piotr Brzeziński +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This program 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program; if not, see . +from gi.repository import GES +from gi.repository import Gst + +from pitivi.timeline.markers import ClipMarkersBox +from pitivi.utils.markers import DEFAULT_LIST_KEY +from tests import common + + +class TestMarkerListManager(common.TestCase): + def add_single_clip(self): + clip = GES.TitleClip() + clip.set_start(5 * Gst.SECOND) + clip.set_duration(20 * Gst.SECOND) + self.layer.add_clip(clip) + return clip + + @common.setup_timeline + def test_manager_created_with_default(self): + clip = self.add_single_clip() + source, = clip.get_children(False) + + manager = source.markers_manager + self.assertTrue(manager) + + self.assertTrue(manager.list_exists(DEFAULT_LIST_KEY)) + self.assertEqual(manager.current_list_key, DEFAULT_LIST_KEY) + + @common.setup_timeline + def test_manager_list_add(self): + clip = self.add_single_clip() + source, = clip.get_children(False) + manager = source.markers_manager + + self.assertRaises(ValueError, manager.add_list, DEFAULT_LIST_KEY) + self.assertRaises(ValueError, manager.add_list, "key with spaces") + self.assertRaises(ValueError, manager.add_list, None) + + test_key = "test_list" + markers = [1, 5, 10] + test_list = manager.add_list(test_key, markers) + + self.assertTrue(manager.list_exists(test_key)) + self.assertEqual(test_list, source.get_marker_list(test_key)) + self.assert_markers(test_list, [(pos, None) for pos in markers]) + + @common.setup_timeline + def test_manager_list_remove(self): + clip = self.add_single_clip() + source, = clip.get_children(False) + manager = source.markers_manager + + self.assertRaises(ValueError, manager.remove_list, DEFAULT_LIST_KEY) + self.assertRaises(ValueError, manager.remove_list, None) + + test_key = "test_list" + manager.add_list(test_key) + manager.current_list_key = test_key + + self.assertEqual(manager.current_list_key, test_key) + manager.remove_list(test_key) + self.assertEqual(manager.current_list_key, DEFAULT_LIST_KEY) + self.assertIsNone(source.get_marker_list(test_key)) + + @common.setup_timeline + def test_manager_default_snappability(self): + clip = self.add_single_clip() + source, = clip.get_children(False) + manager = source.markers_manager + + test_key = "test_list" + default_snappable = self.app.settings.markersSnappableByDefault + + manager.add_list(test_key) + manager.current_list_key = test_key + self.assertEqual(manager.snappable, default_snappable) + + @common.setup_timeline + def test_manager_current_list(self): + clip = self.add_single_clip() + source, = clip.get_children(False) + manager = source.markers_manager + + test_key = "test_list" + manager.add_list(test_key) + + # Toggle snappability on the default list, switch to a diff. one, + # turn off snappability there, and test if they're both + # correctly kept between active list changes. + manager.snappable = True + manager.current_list_key = test_key + manager.snappable = False + + manager.current_list_key = DEFAULT_LIST_KEY + self.assertEqual(manager.current_list, source.get_marker_list(DEFAULT_LIST_KEY)) + self.assertTrue(manager.snappable) + manager.current_list_key = test_key + self.assertEqual(manager.current_list, source.get_marker_list(test_key)) + self.assertFalse(manager.snappable) + manager.current_list_key = "" + self.assertIsNone(manager.current_list) + self.assertFalse(manager.snappable) + manager.current_list_key = DEFAULT_LIST_KEY + self.assertEqual(manager.current_list, source.get_marker_list(DEFAULT_LIST_KEY)) + self.assertTrue(manager.snappable) + + @common.setup_timeline + def test_manager_marker_box(self): + clip = self.add_single_clip() + source, = clip.get_children(False) + manager = source.markers_manager + box = ClipMarkersBox(self.app, source) + + test_key = "test_list" + manager.add_list(test_key) + + self.assertIsNone(box.markers_container) + manager.set_markers_box(box) + self.assertEqual(box.markers_container, manager.current_list) + manager.current_list_key = test_key + self.assertEqual(box.markers_container, manager.current_list) + manager.current_list_key = "" + self.assertIsNone(box.markers_container) + + # Disconnect the box, make a few changes and attach again. + manager.set_markers_box(None) + manager.current_list_key = DEFAULT_LIST_KEY + self.assertIsNone(box.markers_container) + manager.current_list_key = test_key + manager.set_markers_box(box) + self.assertEqual(box.markers_container, manager.current_list) -- GitLab From 431eec283fd270432ed1a03658a6c408bfb6ae2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotrek=20Brzezi=C5=84ski?= Date: Sat, 7 Aug 2021 20:35:15 +0200 Subject: [PATCH 4/6] timeline: Observe meta changes on TrackElements --- pitivi/undo/timeline.py | 6 ++++-- tests/test_undo_timeline.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pitivi/undo/timeline.py b/pitivi/undo/timeline.py index 7d03e1670..fdade6d29 100644 --- a/pitivi/undo/timeline.py +++ b/pitivi/undo/timeline.py @@ -139,7 +139,7 @@ class TimelineElementObserver(Loggable): self.action_log.push(action) -class TrackElementObserver(TimelineElementObserver): +class TrackElementObserver(TimelineElementObserver, MetaContainerObserver): """Monitors the props of a track element. Reports UndoableActions. @@ -150,6 +150,7 @@ class TrackElementObserver(TimelineElementObserver): def __init__(self, ges_track_element, action_log): TimelineElementObserver.__init__(self, ges_track_element, action_log) + MetaContainerObserver.__init__(self, ges_track_element, action_log) if isinstance(ges_track_element, GES.BaseEffect): property_names = ("active", "priority",) else: @@ -157,6 +158,7 @@ class TrackElementObserver(TimelineElementObserver): self.gobject_observer = GObjectObserver(ges_track_element, property_names, action_log) def release(self): + MetaContainerObserver.release(self) TimelineElementObserver.release(self) self.gobject_observer.release() @@ -738,7 +740,7 @@ class LayerObserver(MetaContainerObserver, Loggable): self._control_binding_added_cb) track_element.connect("control-binding-removed", self._control_binding_removed_cb) - if isinstance(track_element, (GES.BaseEffect, GES.VideoSource)): + if isinstance(track_element, (GES.BaseEffect, GES.VideoSource, GES.AudioSource)): observer = TrackElementObserver(track_element, self.action_log) self.track_element_observers[track_element] = observer diff --git a/tests/test_undo_timeline.py b/tests/test_undo_timeline.py index 5afc9cc70..60012a854 100644 --- a/tests/test_undo_timeline.py +++ b/tests/test_undo_timeline.py @@ -342,7 +342,7 @@ class TestLayerObserver(common.TestCase): self.layer.add_clip(clip1) stack = self.action_log.undo_stacks[0] - self.assertEqual(len(stack.done_actions), 7, stack.done_actions) + self.assertEqual(len(stack.done_actions), 9, stack.done_actions) self.assertTrue(isinstance(stack.done_actions[0], ClipAdded)) self.assertTrue(clip1 in self.get_timeline_clips()) -- GitLab From 196d800c9bf188e65adb3eb1185b82fefb6e126f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotrek=20Brzezi=C5=84ski?= Date: Sat, 7 Aug 2021 20:36:48 +0200 Subject: [PATCH 5/6] clipproperties: Add marker list properties section --- pitivi/clipproperties.py | 143 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/pitivi/clipproperties.py b/pitivi/clipproperties.py index d847f4e9e..328ee2d32 100644 --- a/pitivi/clipproperties.py +++ b/pitivi/clipproperties.py @@ -45,6 +45,7 @@ from pitivi.effects import HIDDEN_EFFECTS from pitivi.undo.timeline import CommitTimelineFinalizingAction from pitivi.utils.custom_effect_widgets import setup_custom_effect_widgets from pitivi.utils.loggable import Loggable +from pitivi.utils.markers import GES_MARKERS_SNAPPABLE from pitivi.utils.misc import disconnect_all_by_func from pitivi.utils.pipeline import PipelineError from pitivi.utils.timeline import SELECT @@ -124,6 +125,10 @@ class ClipProperties(Gtk.ScrolledWindow, Loggable): self.effect_expander.set_vexpand(False) vbox.pack_start(self.effect_expander, False, False, 0) + self.marker_expander = MarkerProperties(app) + self.marker_expander.set_vexpand(False) + vbox.pack_start(self.marker_expander, False, False, 0) + self.helper_box = self.create_helper_box() self.clips_box.pack_start(self.helper_box, False, False, 0) @@ -134,6 +139,7 @@ class ClipProperties(Gtk.ScrolledWindow, Loggable): self.title_expander.set_source(None) self.color_expander.set_source(None) self.effect_expander.set_clip(None) + self.marker_expander.set_clip(None) self._project = None self._selection = None @@ -233,6 +239,7 @@ class ClipProperties(Gtk.ScrolledWindow, Loggable): self.title_expander.set_source(title_source) self.color_expander.set_source(color_clip_source) self.effect_expander.set_clip(ges_clip) + self.marker_expander.set_clip(ges_clip) self.app.gui.editor.viewer.overlay_stack.select(video_source) @@ -552,10 +559,10 @@ class EffectProperties(Gtk.Expander, Loggable): def __init__(self, app): Gtk.Expander.__init__(self) + Loggable.__init__(self) self.set_expanded(True) self.set_label(_("Effects")) - Loggable.__init__(self) self.app = app self.clip = None @@ -1264,3 +1271,137 @@ class TransformationProperties(Gtk.Expander, Loggable): self.source.connect("control-binding-removed", self._control_bindings_changed) self.set_visible(bool(self.source)) + + +class MarkerProperties(Gtk.Expander, Loggable): + """Widget for managing the marker lists of a clip. + + Attributes: + app (Pitivi): The app. + clip (GES.Clip): The clip being configured. + """ + + TRACK_TYPES = { + GES.TrackType.VIDEO: _("Video"), + GES.TrackType.AUDIO: _("Audio"), + GES.TrackType.TEXT: _("Text"), + GES.TrackType.CUSTOM: _("Custom"), + } + + def __init__(self, app): + Gtk.Expander.__init__(self) + Loggable.__init__(self) + + self.set_expanded(True) + self.set_label(_("Clip markers")) + + self.app = app + self.clip = None + + self.expander_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.add(self.expander_box) + + def set_clip(self, clip): + if self.clip: + for child in self.clip.get_children(False): + if not isinstance(child, GES.Source): + continue + + disconnect_all_by_func(child.markers_manager, self._lists_modified_cb) + disconnect_all_by_func(child.markers_manager, self._current_list_changed_cb) + + for child in self.expander_box.get_children(): + self.expander_box.remove(child) + + self.clip = clip + if not self.clip or not isinstance(self.clip, GES.SourceClip): + self.hide() + return + + self.show() + + for child in self.clip.get_children(False): + # Ignore non-source children, e.g. effects + if not isinstance(child, GES.Source): + continue + + manager = child.markers_manager + + hbox = Gtk.Box(spacing=SPACING) + hbox.set_border_width(SPACING) + + child_type = child.get_track_type() + name = MarkerProperties.TRACK_TYPES[child_type] + label = Gtk.Label(label=name) + hbox.pack_start(label, False, False, 0) + label.show() + + list_store = Gtk.ListStore(str, str) + list_combo = Gtk.ComboBox.new_with_model(list_store) + + renderer_text = Gtk.CellRendererText() + list_combo.pack_start(renderer_text, True) + list_combo.add_attribute(renderer_text, "text", 1) + list_combo.set_id_column(0) + hbox.pack_start(list_combo, True, True, 0) + list_combo.show() + + snap_toggle = Gtk.CheckButton.new_with_label(_("Magnetic")) + hbox.pack_start(snap_toggle, False, False, 0) + if GES_MARKERS_SNAPPABLE: + snap_toggle.show() + + list_combo.connect("changed", self._combo_changed_cb, child, snap_toggle) + snap_toggle.connect("toggled", self._snappable_toggled_cb, manager) + + self._populate_list_combo(manager, list_combo) + manager.connect("lists-modified", self._lists_modified_cb, list_combo) + manager.connect("current-list-changed", self._current_list_changed_cb, list_combo) + + hbox.show() + + # Display audio marker settings below the video ones, + # matching how they're shown on the timeline. + if child_type == GES.TrackType.AUDIO: + self.expander_box.pack_end(hbox, False, False, 0) + else: + self.expander_box.pack_start(hbox, False, False, 0) + + self.expander_box.show() + + def _current_list_changed_cb(self, manager, list_key, list_combo): + list_combo.set_active_id(list_key) + + def _lists_modified_cb(self, manager, list_combo): + self._populate_list_combo(manager, list_combo) + + def _populate_list_combo(self, manager, list_combo): + lists = manager.get_all_keys_with_names() + list_store = list_combo.get_model() + + list_store.clear() + list_store.append(["", _("No markers")]) + for key, name in lists: + list_store.append([key, name]) + + list_key = manager.current_list_key + list_combo.set_active_id(list_key) + + def _combo_changed_cb(self, combo, ges_source, snap_toggle): + tree_iter = combo.get_active_iter() + if tree_iter is None: + return + + model = combo.get_model() + list_key = model[tree_iter][0] + + manager = ges_source.markers_manager + manager.current_list_key = list_key + + snap_toggle.set_active(manager.snappable) + snap_toggle_interactable = bool(list_key != "") + snap_toggle.set_sensitive(snap_toggle_interactable) + + def _snappable_toggled_cb(self, button, manager): + active = button.get_active() + manager.snappable = active -- GitLab From 79401c68395faf51a46c2d97b0a98bfed90280db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotrek=20Brzezi=C5=84ski?= Date: Wed, 18 Aug 2021 20:17:49 +0200 Subject: [PATCH 6/6] plugins: Fix import errors --- plugins/__init__.py | 0 plugins/console/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 plugins/__init__.py create mode 100644 plugins/console/__init__.py diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/console/__init__.py b/plugins/console/__init__.py new file mode 100644 index 000000000..e69de29bb -- GitLab