Commit 5009abaa authored by Garrett Roth's avatar Garrett Roth Committed by Alexandru Băluț
Browse files

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
parent a54b3dd9
Pipeline #262920 passed with stages
in 39 minutes and 4 seconds
......@@ -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>
......
......@@ -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.
......
......@@ -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()
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment