diff --git a/data/pixmaps/clip-marker-hover.png b/data/pixmaps/clip-marker-hover.png
new file mode 100644
index 0000000000000000000000000000000000000000..eb9216a8a6bf91621ec1f99f15ad04c2c72f6fba
Binary files /dev/null and b/data/pixmaps/clip-marker-hover.png differ
diff --git a/data/pixmaps/clip-marker-select.png b/data/pixmaps/clip-marker-select.png
new file mode 100644
index 0000000000000000000000000000000000000000..1a36bb9ed20f8c72e7231080064fa40c89ef76a6
Binary files /dev/null and b/data/pixmaps/clip-marker-select.png differ
diff --git a/data/pixmaps/clip-marker.png b/data/pixmaps/clip-marker.png
new file mode 100644
index 0000000000000000000000000000000000000000..f061dc8f5495a7ce74be9ff264e7d6d90be348cf
Binary files /dev/null and b/data/pixmaps/clip-marker.png differ
diff --git a/pitivi/check.py b/pitivi/check.py
index 9c429b26d8703e2767a6f2b6b82c2ac724ffb3c6..74abbc0ae8d487fad84fbe965d2bc9c03d22e12e 100644
--- a/pitivi/check.py
+++ b/pitivi/check.py
@@ -426,7 +426,7 @@ def initialize_modules():
# a specific version requirement, they have the "None" value.
GST_API_VERSION = "1.0"
-GST_VERSION = "1.17.90" # FIXME Remove check in proxy.py once we bump to > 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 59bb166c9709430be4f9c016961d2cff9db6f1a6..328ee2d3237cb1d935afb393a1ec4ee441a1bb3e 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
@@ -1036,12 +1043,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):
@@ -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
diff --git a/pitivi/timeline/elements.py b/pitivi/timeline/elements.py
index ad0c8076a1585a6efda9dd866326c8489b122f11..1d027143b969a6ccabe79777b05120860220c686 100644
--- a/pitivi/timeline/elements.py
+++ b/pitivi/timeline/elements.py
@@ -35,12 +35,15 @@ 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
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
@@ -50,7 +53,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 +657,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 +667,11 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
if self.__background:
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
self.__controlled_property = None
self.show_all()
@@ -685,22 +690,19 @@ class TimelineElement(Gtk.Layout, Zoomable, Loggable):
if self.__previewer:
self.__previewer.release()
+ if self.markers:
+ self._ges_elem.markers_manager.set_markers_box(None)
+ 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 +773,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 +808,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 +1303,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 +1409,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 +1475,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.settings, 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 ae6c0f6dc7da6bc932e6df33f7513e4aa42fb7f3..024c2a4a43d1400aa6793133e79e4d4b80f2f4c9 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/previewers.py b/pitivi/timeline/previewers.py
index f2652a141ce0db41c19c229f68996aee591c22b8..bb27f817c3f985f15e591819e71a0a9d6ba99075 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
diff --git a/pitivi/timeline/timeline.py b/pitivi/timeline/timeline.py
index b928453399205a2bfb845e7fed4aa1e27da674f6..d94dc118f9d27a13f3cd79694a2ac8eacc22b289 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/undo/timeline.py b/pitivi/undo/timeline.py
index 7d03e167060a62df3936e7006fbcaf53a886d211..fdade6d299008ebf221e2cb735317da6eb71da5a 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/pitivi/utils/markers.py b/pitivi/utils/markers.py
new file mode 100644
index 0000000000000000000000000000000000000000..7314719c47505bce931ddcd81c74928554d4e1c0
--- /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/pitivi/utils/ui.py b/pitivi/utils/ui.py
index 129b31d909a707a871ae657c1c1f076de27bb559..56094139ec0f80c4201ab57636862a7dde8421d9 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 7aec460dd000ed3dc2839298cf686f848f5adfff..9a92d1453c7f20828994219eff2e9ab73819a51c 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/plugins/__init__.py b/plugins/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/plugins/console/__init__.py b/plugins/console/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/common.py b/tests/common.py
index cd99ea4322e701bf34db5610f068fef6ad1c99bb..bba4738cd0f999c29b76097c04937f512ea570cb 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_effects.py b/tests/test_effects.py
index 72dba995530ff52ca89a6dcbe3da78f557872f9f..a27c464a8dd01f794340017328c1af1bb3b2e18a 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 eb65451b579a7cfa3a9fa0ac657ec3fae605dcad..87d3ef95d39393c6db449e76bed7a5d0cf91d13d 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)])
diff --git a/tests/test_undo_timeline.py b/tests/test_undo_timeline.py
index 5afc9cc70c1243fe9fb54bdba3f978d701858063..60012a854ef1909c7fa8497a676b6ad6008a08bd 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())
diff --git a/tests/test_utils_markers.py b/tests/test_utils_markers.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc9c9077c389254c469e4162085702f92e7931d5
--- /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)