[pitivi] timeline: Added keyframe lock feature
- From: Alexandru Băluț <alexbalut src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [pitivi] timeline: Added keyframe lock feature
- Date: Thu, 4 Mar 2021 20:52:17 +0000 (UTC)
commit 5009abaa878793d416ddd584448274dd3b91536b
Author: Garrett Roth <groth2 cse unl edu>
Date: Wed Apr 29 10:56:44 2020 -0500
timeline: Added keyframe lock feature
Added a requested feature that will lock a
keyframe to the X or Y axis when holding
Left CTRL.
This feature was added as a quality of life
upgrade to make keyframes easier to move
over the keyframe curve.
Added fields to keyframe curve class and
additional logic to determine what axis to
lock to.
Fixes #2025
help/C/keyframecurves.page | 2 +-
pitivi/timeline/elements.py | 88 ++++++++++++++++++++++++++++-------------
tests/test_timeline_elements.py | 86 +++++++++++++++++++++++++++++++++++++++-
3 files changed, 147 insertions(+), 29 deletions(-)
---
diff --git a/help/C/keyframecurves.page b/help/C/keyframecurves.page
index 18a6194b6..0d0db81b3 100644
--- a/help/C/keyframecurves.page
+++ b/help/C/keyframecurves.page
@@ -60,7 +60,7 @@
<p>Remove a keyframe by double-clicking on it.</p>
</item>
<item>
- <p>Adjust the time and value of a keyframe by moving it with the mouse.</p>
+ <p>Adjust the time and value of a keyframe by moving it with the mouse. When moving the keyframe,
hold down <key>Ctrl</key> to lock the keyframe horizontally or vertically.</p>
</item>
<item>
<p>Click-and-drag on a segment of a curve between two keyframes to adjust the vertical position of
the segment.</p>
diff --git a/pitivi/timeline/elements.py b/pitivi/timeline/elements.py
index 4bd771675..350ad5b4a 100644
--- a/pitivi/timeline/elements.py
+++ b/pitivi/timeline/elements.py
@@ -26,8 +26,12 @@ from gi.repository import GObject
from gi.repository import Gst
from gi.repository import GstController
from gi.repository import Gtk
-from matplotlib.backends.backend_gtk3cairo import FigureCanvasGTK3Cairo as FigureCanvas
+from matplotlib.axes import Axes
+from matplotlib.backend_bases import MouseButton
+from matplotlib.backends.backend_gtk3cairo import FigureCanvasGTK3Cairo
+from matplotlib.collections import PathCollection
from matplotlib.figure import Figure
+from matplotlib.lines import Line2D
from pitivi.configure import get_pixmap_dir
from pitivi.effects import ALLOWED_ONLY_ONCE_EFFECTS
@@ -73,7 +77,7 @@ def get_pspec(element_factory_name, propname):
return [prop for prop in element.list_properties() if prop.name == propname][0]
-class KeyframeCurve(FigureCanvas, Loggable):
+class KeyframeCurve(FigureCanvasGTK3Cairo, Loggable):
YLIM_OVERRIDES = {}
__YLIM_OVERRIDES_VALUES = [("volume", "volume", (0.0, 0.2))]
@@ -92,7 +96,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
def __init__(self, timeline, binding, ges_elem):
figure = Figure()
- FigureCanvas.__init__(self, figure)
+ FigureCanvasGTK3Cairo.__init__(self, figure)
Loggable.__init__(self)
self._ges_elem = ges_elem
@@ -113,7 +117,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
self._line_ys = []
# facecolor to None for transparency
- self._ax = figure.add_axes([0, 0, 1, 1], facecolor='None')
+ self._ax: Axes = figure.add_axes([0, 0, 1, 1], facecolor='None')
# Clear the Axes object.
self._ax.cla()
self._ax.grid(False)
@@ -129,17 +133,17 @@ class KeyframeCurve(FigureCanvas, Loggable):
# The PathCollection object holding the keyframes dots.
sizes = [50]
- self._keyframes = self._ax.scatter([], [], marker='D', s=sizes,
- c=KEYFRAME_NODE_COLOR, zorder=2)
+ self._keyframes: PathCollection = self._ax.scatter([], [], marker='D', s=sizes,
+ c=KEYFRAME_NODE_COLOR, zorder=2)
# matplotlib weirdness, simply here to avoid a warning ..
self._keyframes.set_picker(True)
# The Line2D object holding the lines between keyframes.
- self.__line = self._ax.plot([], [],
- alpha=KEYFRAME_LINE_ALPHA,
- c=KEYFRAME_LINE_COLOR,
- linewidth=KEYFRAME_LINE_HEIGHT, zorder=1)[0]
+ self.__line: Line2D = self._ax.plot([], [],
+ alpha=KEYFRAME_LINE_ALPHA,
+ c=KEYFRAME_LINE_COLOR,
+ linewidth=KEYFRAME_LINE_HEIGHT, zorder=1)[0]
self._update_plots()
# Drag and drop logic
@@ -147,6 +151,14 @@ class KeyframeCurve(FigureCanvas, Loggable):
self._dragged = False
# The inpoint of the clicked keyframe.
self._offset = None
+ # The initial keyframe value when a keyframe is being moved.
+ self._initial_value = 0
+ # The initial keyframe timestamp when a keyframe is being moved.
+ self._initial_timestamp = 0
+ # The initial event.x when a keyframe is being moved.
+ self._initial_x = 0
+ # The initial event.y when a keyframe is being moved.
+ self._initial_y = 0
# The (offset, value) of both keyframes of the clicked keyframe line.
self.__clicked_line = ()
# Whether the mouse events go to the keyframes logic.
@@ -154,9 +166,10 @@ class KeyframeCurve(FigureCanvas, Loggable):
self.__hovered = False
- self.connect("motion-notify-event", self.__gtk_motion_event_cb)
+ self.connect("motion-notify-event", self.__motion_notify_event_cb)
self.connect("event", self._event_cb)
self.connect("notify::height-request", self.__height_request_cb)
+ self.connect("button_release_event", self._button_release_event_cb)
self.mpl_connect('button_press_event', self._mpl_button_press_event_cb)
self.mpl_connect('button_release_event', self._mpl_button_release_event_cb)
@@ -164,7 +177,8 @@ class KeyframeCurve(FigureCanvas, Loggable):
def release(self):
disconnect_all_by_func(self, self.__height_request_cb)
- disconnect_all_by_func(self, self.__gtk_motion_event_cb)
+ disconnect_all_by_func(self, self.__motion_notify_event_cb)
+ disconnect_all_by_func(self, self._button_release_event_cb)
disconnect_all_by_func(self, self._control_source_changed_cb)
def _connect_sources(self):
@@ -190,17 +204,14 @@ class KeyframeCurve(FigureCanvas, Loggable):
self._ax.set_xlim(self._line_xs[0], self._line_xs[-1])
self.__compute_ylim()
- arr = numpy.array((self._line_xs, self._line_ys))
- arr = arr.transpose()
+ arr = numpy.array((self._line_xs, self._line_ys)).transpose()
self._keyframes.set_offsets(arr)
self.__line.set_xdata(self._line_xs)
self.__line.set_ydata(self._line_ys)
self.queue_draw()
- # Private methods
def __compute_ylim(self):
height = self.props.height_request
-
if height <= 0:
return
@@ -254,12 +265,11 @@ class KeyframeCurve(FigureCanvas, Loggable):
assert res
self.__source.set(offset, value)
- # Callbacks
def _control_source_changed_cb(self, unused_control_source, unused_timed_value):
self._update_plots()
self._timeline.ges_timeline.get_parent().commit_timeline()
- def __gtk_motion_event_cb(self, unused_widget, unused_event):
+ def __motion_notify_event_cb(self, unused_widget, unused_event):
# We need to do this here, because Matplotlib's callbacks can't stop
# signal propagation.
if self.handling_motion:
@@ -273,7 +283,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
return False
def _mpl_button_press_event_cb(self, event):
- if event.button != 1:
+ if event.button != MouseButton.LEFT:
return
result = self._keyframes.contains(event)
@@ -281,7 +291,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
# A keyframe has been clicked.
keyframe_index = result[1]['ind'][0]
offsets = self._keyframes.get_offsets()
- offset = offsets[keyframe_index][0]
+ offset, value = offsets[keyframe_index]
# pylint: disable=protected-access
if event.guiEvent.type == Gdk.EventType._2BUTTON_PRESS:
@@ -303,7 +313,11 @@ class KeyframeCurve(FigureCanvas, Loggable):
# Remember the clicked frame for drag&drop.
self._timeline.app.action_log.begin("Move keyframe",
toplevel=True)
+ self._initial_x = event.x
+ self._initial_y = event.y
self._offset = offset
+ self._initial_timestamp = offset
+ self._initial_value = value
self.handling_motion = True
return
@@ -327,9 +341,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
# The mouse event is in the figure boundaries.
if self._offset is not None:
self._dragged = True
- keyframe_ts = self.__compute_keyframe_new_timestamp(event)
- ydata = max(self.__ylim_min, min(event.ydata, self.__ylim_max))
-
+ keyframe_ts, ydata = self.__compute_keyframe_position(event)
self._move_keyframe(int(self._offset), keyframe_ts, ydata)
self._offset = keyframe_ts
self._update_tooltip(event)
@@ -360,7 +372,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
self._timeline.get_window().set_cursor(cursor)
def _mpl_button_release_event_cb(self, event):
- if event.button != 1:
+ if event.button != MouseButton.LEFT:
return
# In order to make sure we seek to the exact position where we added a
@@ -377,8 +389,7 @@ class KeyframeCurve(FigureCanvas, Loggable):
# seek exactly on the keyframe.
if self._dragged:
if event.ydata is not None:
- keyframe_ts = self.__compute_keyframe_new_timestamp(event)
- ydata = max(self.__ylim_min, min(event.ydata, self.__ylim_max))
+ keyframe_ts, ydata = self.__compute_keyframe_position(event)
self._move_keyframe(int(self._offset), keyframe_ts, ydata)
self.debug("Keyframe released")
self._timeline.app.action_log.commit("Move keyframe")
@@ -394,8 +405,18 @@ class KeyframeCurve(FigureCanvas, Loggable):
self.handling_motion = False
self._offset = None
self.__clicked_line = ()
+
+ def _button_release_event_cb(self, unused_widget, event):
+ if not event.get_button() == (True, 1):
+ return False
+
+ dragged = self._dragged
self._dragged = False
+ # Return True to stop signal propagation, otherwise the clip will be
+ # unselected.
+ return dragged
+
def _update_tooltip(self, event):
"""Sets or clears the tooltip showing info about the hovered line."""
markup = None
@@ -420,6 +441,19 @@ class KeyframeCurve(FigureCanvas, Loggable):
"{:.3f}".format(value))
self.set_tooltip_markup(markup)
+ def __compute_keyframe_position(self, event):
+ keyframe_ts = self.__compute_keyframe_new_timestamp(event)
+ ydata = max(self.__ylim_min, min(event.ydata, self.__ylim_max))
+ if self._timeline.get_parent().control_mask:
+ delta_x = abs(event.x - self._initial_x)
+ delta_y = abs(event.y - self._initial_y)
+ if delta_x > delta_y:
+ ydata = self._initial_value
+ else:
+ keyframe_ts = self._initial_timestamp
+
+ return keyframe_ts, ydata
+
def __compute_keyframe_new_timestamp(self, event):
# The user can not change the timestamp of the first
# and last keyframes.
@@ -518,7 +552,7 @@ class MultipleKeyframeCurve(KeyframeCurve):
pass
def _mpl_button_release_event_cb(self, event):
- if event.button == 1:
+ if event.button == MouseButton.LEFT:
if self._offset is not None and not self._dragged:
# A keyframe was clicked but not dragged, so we
# should select it by seeking to its position.
diff --git a/tests/test_timeline_elements.py b/tests/test_timeline_elements.py
index edc8f41d7..39b448825 100644
--- a/tests/test_timeline_elements.py
+++ b/tests/test_timeline_elements.py
@@ -23,6 +23,7 @@ from gi.repository import Gdk
from gi.repository import GES
from gi.repository import Gst
from gi.repository import Gtk
+from matplotlib.backend_bases import MouseButton
from matplotlib.backend_bases import MouseEvent
from pitivi.timeline.elements import GES_TYPE_UI_TYPE
@@ -163,7 +164,6 @@ class TestKeyframeCurve(common.TestCase):
values = [item.timestamp for item in control_source.get_all()]
self.assertIn(inpoint + offset, values)
- # Remove keyframes by simulating mouse double-clicks.
for offset_px in offsets_px:
offset = Zoomable.pixel_to_ns(start_px + offset_px) - start
xdata, ydata = inpoint + offset, 1
@@ -201,6 +201,90 @@ class TestKeyframeCurve(common.TestCase):
values = [item.timestamp for item in control_source.get_all()]
self.assertNotIn(inpoint + offset, values)
+ def test_axis_lock(self):
+ """Checks keyframes moving."""
+ timeline_container = common.create_timeline_container()
+ timeline_container.app.action_log = UndoableActionLog()
+ timeline = timeline_container.timeline
+ timeline.get_window = mock.Mock()
+ pipeline = timeline._project.pipeline
+ ges_layer = timeline.ges_timeline.append_layer()
+ ges_clip = self.add_clip(ges_layer, 0, duration=Gst.SECOND)
+
+ start = ges_clip.props.start
+ inpoint = ges_clip.props.in_point
+ duration = ges_clip.props.duration
+ timeline.selection.select([ges_clip])
+
+ ges_video_source = ges_clip.find_track_element(None, GES.VideoSource)
+ binding = ges_video_source.get_control_binding("alpha")
+ control_source = binding.props.control_source
+ keyframe_curve = ges_video_source.ui.keyframe_curve
+ values = [item.timestamp for item in control_source.get_all()]
+ self.assertEqual(values, [inpoint, inpoint + duration])
+
+ # Add a keyframe.
+ position = start + int(duration / 2)
+ with mock.patch.object(pipeline, "get_position") as get_position:
+ get_position.return_value = position
+ timeline_container._keyframe_cb(None, None)
+
+ # Start dragging the keyframe.
+ x, y = keyframe_curve._ax.transData.transform((position, 1))
+ event = MouseEvent(
+ name="button_press_event",
+ canvas=keyframe_curve,
+ x=x,
+ y=y,
+ button=MouseButton.LEFT
+ )
+ event.guiEvent = Gdk.Event.new(Gdk.EventType.BUTTON_PRESS)
+ self.assertIsNone(keyframe_curve._offset)
+ keyframe_curve._mpl_button_press_event_cb(event)
+ self.assertIsNotNone(keyframe_curve._offset)
+
+ # Drag and make sure x and y are not locked.
+ timeline_container.control_mask = False
+ event = mock.Mock(
+ x=x + 1,
+ y=y + 1,
+ xdata=position + 1,
+ ydata=0.9,
+ )
+ with mock.patch.object(keyframe_curve,
+ "_move_keyframe") as _move_keyframe:
+ keyframe_curve._mpl_motion_event_cb(event)
+ # Check the keyframe is moved exactly where the cursor is.
+ _move_keyframe.assert_called_once_with(position, position + 1, 0.9)
+
+ # Drag locked horizontally.
+ timeline_container.control_mask = True
+ event = mock.Mock(
+ x=x + 1,
+ y=y + 2,
+ xdata=position + 2,
+ ydata=0.8,
+ )
+ with mock.patch.object(keyframe_curve,
+ "_move_keyframe") as _move_keyframe:
+ keyframe_curve._mpl_motion_event_cb(event)
+ # Check the keyframe is kept on the same timestamp.
+ _move_keyframe.assert_called_once_with(position + 1, position, 0.8)
+
+ # Drag locked vertically.
+ timeline_container.control_mask = True
+ event = mock.Mock(
+ x=x + 2,
+ y=y + 1,
+ xdata=position + 3,
+ ydata=0.7,
+ )
+ with mock.patch.object(keyframe_curve,
+ "_move_keyframe") as _move_keyframe:
+ keyframe_curve._mpl_motion_event_cb(event)
+ # Check the keyframe is kept on the same value.
+ _move_keyframe.assert_called_once_with(position, position + 3, 1)
+
def test_no_clip_selected(self):
"""Checks nothing happens when no clip is selected."""
timeline_container = common.create_timeline_container()
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]