[pitivi/wip-speed-control: 5/6] Add support for time effects




commit 57c084ab7fa1522cbe2e17b310e752882ae4b851
Author: Thibault Saunier <tsaunier igalia com>
Date:   Tue May 19 23:57:00 2020 -0400

    Add support for time effects
    
    Fixes #632

 .pre-commit-config.yaml       |   7 +-
 meson.build                   |   5 +-
 mypy.ini                      |   4 +
 pitivi/clipproperties.py      | 336 ++++++++++++++++++++++++++++++++++++++++--
 pitivi/timeline/previewers.py |  29 +++-
 pitivi/timeline/timeline.py   |   2 +-
 tests/common.py               |  28 +++-
 tests/test_clipproperties.py  | 158 ++++++++++++++++++--
 8 files changed, 529 insertions(+), 40 deletions(-)
---
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 60acc7cfc..ac3c624ac 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,7 +1,7 @@
 ---
 repos:
   - repo: https://github.com/pre-commit/pre-commit.git
-    rev: v2.9.3
+    rev: v2.10.0
     hooks:
       - id: validate_manifest
   - repo: https://github.com/pre-commit/pre-commit-hooks.git
@@ -44,6 +44,11 @@ repos:
             pitivi/utils/extract.py|
             pitivi/autoaligner.py|
           )$
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: 'v0.800'
+    hooks:
+      - id: mypy
+        files: '.*pitivi/clipproperties.py$'
   - repo: local
     hooks:
       - id: pylint
diff --git a/meson.build b/meson.build
index ff4b721c3..71eceae35 100644
--- a/meson.build
+++ b/meson.build
@@ -4,11 +4,12 @@ host_system = host_machine.system()
 pymod = import('python')
 python = pymod.find_installation(get_option('python'))
 pythonver = python.language_version()
+
 # Workaround for https://github.com/mesonbuild/meson/issues/5629
 # https://gitlab.freedesktop.org/gstreamer/gst-python/issues/28
-python_dep = dependency('python-@0@-embed'.format(pythonver), version: '>= 3.3', required: false)
+python_dep = dependency('python-@0@-embed'.format(pythonver), version: '>= 3.5', required: false)
 if not python_dep.found()
-  python_dep = python.dependency(version: '>= 3.3')
+  python_dep = python.dependency(version: '>= 3.5')
 endif
 
 if get_option('build-gst')
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 000000000..2ce3b1a09
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,4 @@
+[mypy]
+ignore_missing_imports = True
+follow_imports = silent
+show_error_context = True
diff --git a/pitivi/clipproperties.py b/pitivi/clipproperties.py
index 21d580854..fedb4fcb0 100644
--- a/pitivi/clipproperties.py
+++ b/pitivi/clipproperties.py
@@ -18,11 +18,17 @@
 import bisect
 import os
 from gettext import gettext as _
+from typing import Callable
+from typing import Dict
+from typing import Optional
+from typing import Tuple
 
 import cairo
 from gi.repository import Gdk
 from gi.repository import GdkPixbuf
 from gi.repository import GES
+from gi.repository import GLib
+from gi.repository import GObject
 from gi.repository import Gst
 from gi.repository import GstController
 from gi.repository import Gtk
@@ -61,6 +67,8 @@ DEFAULT_FONT_DESCRIPTION = "Sans 36"
 DEFAULT_VALIGNMENT = "absolute"
 DEFAULT_HALIGNMENT = "absolute"
 
+MAX_RATE = 10
+
 
 class ClipProperties(Gtk.ScrolledWindow, Loggable):
     """Widget for configuring the selected clip.
@@ -94,6 +102,10 @@ class ClipProperties(Gtk.ScrolledWindow, Loggable):
         self.transformation_expander.set_vexpand(False)
         vbox.pack_start(self.transformation_expander, False, False, 0)
 
+        self.speed_expander = SpeedProperties(app)
+        self.speed_expander.set_vexpand(False)
+        vbox.pack_start(self.speed_expander, False, False, 0)
+
         self.title_expander = TitleProperties(app)
         self.title_expander.set_vexpand(False)
         vbox.pack_start(self.title_expander, False, False, 0)
@@ -112,6 +124,7 @@ class ClipProperties(Gtk.ScrolledWindow, Loggable):
         disable_scroll(vbox)
 
         self.transformation_expander.set_source(None)
+        self.speed_expander.set_clip(None)
         self.title_expander.set_source(None)
         self.color_expander.set_source(None)
         self.effect_expander.set_clip(None)
@@ -208,6 +221,7 @@ class ClipProperties(Gtk.ScrolledWindow, Loggable):
                     color_clip_source = child
 
         self.transformation_expander.set_source(video_source)
+        self.speed_expander.set_clip(ges_clip)
         self.title_expander.set_source(title_source)
         self.color_expander.set_source(color_clip_source)
         self.effect_expander.set_clip(ges_clip)
@@ -215,6 +229,291 @@ class ClipProperties(Gtk.ScrolledWindow, Loggable):
         self.app.gui.editor.viewer.overlay_stack.select(video_source)
 
 
+def is_time_effect(effect):
+    return bool(effect.get_meta(SpeedProperties.TIME_EFFECT_META))
+
+
+class SpeedProperties(Gtk.Expander, Loggable):
+    """Widget for setting the playback speed and direction of a clip.
+
+    Attributes:
+        app (Pitivi): The app.
+    """
+
+    TIME_EFFECT_META = "ptv::time-effect"
+    TIME_EFFECTS_DEF = {
+        GES.TrackType.VIDEO: ("videorate", "rate"),
+        GES.TrackType.AUDIO: ("pitch", "tempo"),
+    }
+
+    def __init__(self, app: Gtk.Application) -> None:
+        super().__init__()
+        Loggable.__init__(self)
+
+        self.set_expanded(True)
+        self.set_label(_("Time"))
+
+        # Global variables related to effects
+        self.app = app
+
+        self._clip: Optional[GES.Clip] = None
+        # track: source_element
+        self._sources: Dict[GES.Track, GES.Source] = {}
+        # track: (effect, rate_changing_property_name)
+        self._time_effects: Dict[GES.Track, Tuple[GES.BaseEffect, str]] = {}
+
+        grid = Gtk.Grid.new()
+        grid.props.row_spacing = SPACING
+        grid.props.column_spacing = SPACING
+        grid.props.border_width = SPACING
+        self.add(grid)
+
+        self._speed_spinner_adjustment = Gtk.Adjustment()
+        self._speed_spinner_adjustment.props.lower = 1 / MAX_RATE
+        self._speed_spinner_adjustment.props.upper = MAX_RATE
+        self._speed_spinner_adjustment.props.value = 1
+        self._speed_spinner = Gtk.SpinButton(adjustment=self._speed_spinner_adjustment)
+
+        self._speed_scale_adjustment = Gtk.Adjustment()
+        self._speed_scale_adjustment.props.lower = self._rate_to_linear(1 / MAX_RATE)
+        self._speed_scale_adjustment.props.upper = self._rate_to_linear(MAX_RATE)
+        self._speed_scale_adjustment.props.value = 1
+        self._speed_scale = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, self._speed_scale_adjustment)
+        self._speed_scale.set_size_request(width=200, height=-1)
+        self._speed_scale.props.draw_value = False
+
+        for rate in (1 / MAX_RATE, 5 / MAX_RATE, 1, 5, MAX_RATE):
+            linear = self._rate_to_linear(rate)
+            self._speed_scale.add_mark(linear, Gtk.PositionType.BOTTOM, "{}x".format(rate))
+
+        hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+        hbox.pack_start(self._speed_spinner, False, False, PADDING)
+        hbox.pack_start(self._speed_scale, False, False, PADDING)
+        self.__add_widget_to_grid(grid, _("Speed"), hbox, self._reset_button_clicked_cb, 0)
+
+        self.__setting_rate = False
+        self.bind_property("rate",
+                           self._speed_spinner_adjustment, "value",
+                           GObject.BindingFlags.BIDIRECTIONAL)
+        self.bind_property("rate_linear",
+                           self._speed_scale_adjustment, "value",
+                           GObject.BindingFlags.BIDIRECTIONAL)
+
+    def _reset_button_clicked_cb(self, button):
+        self._speed_spinner_adjustment.props.value = 1
+        self._speed_scale_adjustment.props.value = 1
+
+    def __add_widget_to_grid(self, grid: Gtk.Grid, nick: str, widget: Gtk.Widget, reset_func: Callable, y: 
int) -> None:
+        text = _("%(preference_label)s:") % {"preference_label": nick}
+
+        button = Gtk.Button.new_from_icon_name("edit-clear-all-symbolic", Gtk.IconSize.MENU)
+        button.set_tooltip_text(_("Reset to default value"))
+        button.set_relief(Gtk.ReliefStyle.NONE)
+        button.connect("clicked", reset_func)
+
+        label = Gtk.Label(label=text)
+        label.props.yalign = 0.5
+        grid.attach(label, 0, y, 1, 1)
+        grid.attach(widget, 1, y, 1, 1)
+        grid.attach(button, 2, y, 1, 1)
+
+    def __get_source_duration(self) -> Tuple[GES.Source, int]:
+        assert self._clip is not None
+
+        res = (None, Gst.CLOCK_TIME_NONE)
+        for source in self._sources.values():
+            internal_duration = self._clip.get_internal_time_from_timeline_time(source, 
source.props.duration)
+            if internal_duration < res[1]:
+                res = (source, internal_duration)
+
+        return res
+
+    def _current_rate(self) -> float:
+        for effect, propname in self._time_effects.values():
+            return effect.get_child_property(propname).value
+        return 1
+
+    @staticmethod
+    def _rate_to_linear(value: float) -> float:
+        if value < 1:
+            return value * MAX_RATE - (MAX_RATE - 1)
+        else:
+            return value
+
+    @staticmethod
+    def _linear_to_rate(value: float) -> float:
+        if value < 1:
+            return (value + MAX_RATE - 1) / MAX_RATE
+        else:
+            return value
+
+    @GObject.Property(type=float)
+    def rate(self):
+        value = self._current_rate()
+        return value
+
+    @rate.setter  # type: ignore
+    def rate(self, value: float) -> None:
+        self._set_rate(value)
+        self.notify("rate_linear")
+
+    @GObject.Property(type=float)
+    def rate_linear(self):
+        value = self._current_rate()
+        return self._rate_to_linear(value)
+
+    @rate_linear.setter  # type: ignore
+    def rate_linear(self, linear: float) -> None:
+        value = self._linear_to_rate(linear)
+        self._set_rate(value)
+        self.notify("rate")
+
+    def _set_rate(self, value: float):
+        if not self._clip:
+            return
+
+        if value != 1:
+            self.__ensure_effects()
+
+        prev_rate = self._current_rate()
+        if prev_rate == value:
+            return
+
+        project = self.app.project_manager.current_project
+        pipeline = project.pipeline
+        self.__setting_rate = True
+        prev_snapping_distance = project.ges_timeline.props.snapping_distance
+        is_auto_clamp = False
+        try:
+            self.app.action_log.begin("set clip speed",
+                                      finalizing_action=CommitTimelineFinalizingAction(pipeline),
+                                      toplevel=True)
+            is_auto_clamp = True
+            for track_element in self._clip.get_children(True):
+                track_element.set_auto_clamp_control_sources(False)
+
+            source, source_duration = self.__get_source_duration()
+            for effect, propname in self._time_effects.values():
+                effect.set_child_property(propname, value)
+
+            new_end = self._clip.get_timeline_time_from_internal_time(source, self._clip.props.start + 
source_duration)
+
+            # We do not want to snap when setting clip speed
+            project.ges_timeline.props.snapping_distance = 0
+            self._clip.edit_full(-1, GES.EditMode.TRIM, GES.Edge.END, new_end)
+            for track_element in self._clip.get_children(True):
+                track_element.set_auto_clamp_control_sources(True)
+            is_auto_clamp = False
+        except GLib.Error as e:
+            self.app.action_log.rollback()
+            if e.domain == "GES_ERROR":
+                # At this point the GBinding is frozen (to avoid looping)
+                # so even if we notify "rate" at this point, the value wouldn't
+                # be reflected, we need to do it manually
+                self._speed_spinner_adjustment.props.value = prev_rate
+                self._speed_scale_adjustment.props.value = self._rate_to_linear(prev_rate)
+            else:
+                raise e
+        except Exception as e:
+            self.app.action_log.rollback()
+            raise e
+        else:
+            self.app.action_log.commit("set clip speed")
+        finally:
+            self.__setting_rate = False
+            project.ges_timeline.props.snapping_distance = prev_snapping_distance
+            if is_auto_clamp:
+                for track_element in self._clip.get_children(True):
+                    track_element.set_auto_clamp_control_sources(True)
+
+        self.debug("New value is %s", self.props.rate)
+
+    def __child_property_changed_cb(self, element: GES.TimelineElement, obj: GObject.Object, prop: 
GObject.ParamSpec) -> None:
+        if self.__setting_rate or not isinstance(obj, Gst.Element):
+            return
+
+        time_effect_factory_names = [d[0] for d in self.TIME_EFFECTS_DEF.values()]
+        if not obj.get_factory().get_name() in time_effect_factory_names:
+            return
+
+        rate = None
+        for effect, propname in self._time_effects.values():
+            if rate and rate != effect.get_child_property(propname).value:
+                # Do no notify before all children have they new value set
+                return
+            rate = effect.get_child_property(propname).value
+
+        self.notify("rate")
+        self.notify("rate_linear")
+
+    def set_clip(self, clip):
+        if self._clip:
+            self._clip.disconnect_by_func(self.__child_property_changed_cb)
+
+        self._clip = clip
+
+        self._sources = {}
+        if self._clip:
+            for track in self._clip.get_timeline().get_tracks():
+                source = self._clip.find_track_element(track, GES.Source)
+                if source:
+                    self._sources[track] = source
+
+            if not self._sources:
+                self._clip = None
+
+        self._time_effects = self.__get_time_effects(self._clip)
+
+        if self._clip:
+            self._clip.connect("deep-notify", self.__child_property_changed_cb)
+            self.show_all()
+        else:
+            self.hide()
+
+    def __get_time_effects(self, clip):
+        if clip is None:
+            return {}
+
+        time_effects = {}
+        for effect in clip.get_top_effects():
+            if not is_time_effect(effect):
+                continue
+
+            track = effect.get_track()
+            if track in time_effects:
+                self.error("Something is wrong as we have several %s time effects", track)
+                continue
+
+            time_effects[track] = (effect, self.TIME_EFFECTS_DEF[track.props.track_type][1])
+
+        return time_effects
+
+    def __ensure_effects(self):
+        if self._time_effects:
+            return
+
+        rate = None
+        for track, unused_source in self._sources.items():
+            if track not in self._time_effects:
+                bindesc, propname = self.TIME_EFFECTS_DEF[track.props.track_type]
+
+                effect = GES.Effect.new(bindesc)
+                self._time_effects[track] = (effect, propname)
+                effect.set_meta(self.TIME_EFFECT_META, True)
+                self._clip.add_top_effect(effect, 0)
+
+            res, tmprate = effect.get_child_property(propname)
+            assert res
+
+            if rate:
+                if rate != tmprate:
+                    self.error("Rate mismatch, going to reset it to %s", rate)
+                    self.__setting_rate = True
+                    self.set_child_property(propname, rate)
+            else:
+                rate = tmprate
+
+
 class EffectProperties(Gtk.Expander, Loggable):
     """Widget for viewing a list of effects and configuring them.
 
@@ -279,6 +578,9 @@ class EffectProperties(Gtk.Expander, Loggable):
             self.effect_popover.search_entry.set_text("")
 
     def _create_effect_row(self, effect):
+        if is_time_effect(effect):
+            return None
+
         effect_info = self.app.effects.get_info(effect.props.bin_description)
 
         vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
@@ -348,8 +650,10 @@ class EffectProperties(Gtk.Expander, Loggable):
         for effect in self.clip.get_top_effects():
             if effect.props.bin_description in HIDDEN_EFFECTS:
                 continue
+
             effect_row = self._create_effect_row(effect)
-            self.effects_listbox.add(effect_row)
+            if effect_row:
+                self.effects_listbox.add(effect_row)
 
         self.effects_listbox.show_all()
 
@@ -364,6 +668,9 @@ class EffectProperties(Gtk.Expander, Loggable):
 
     def _add_effect_row(self, effect):
         row = self._create_effect_row(effect)
+        if not row:
+            return
+
         self.effects_listbox.add(row)
         self.effects_listbox.show_all()
 
@@ -399,9 +706,10 @@ class EffectProperties(Gtk.Expander, Loggable):
         if self.clip:
             self.clip.disconnect_by_func(self._track_element_added_cb)
             self.clip.disconnect_by_func(self._track_element_removed_cb)
-            for track_element in self.clip.get_children(recursive=True):
-                if isinstance(track_element, GES.BaseEffect):
-                    self._disconnect_from_track_element(track_element)
+            for effect in self.clip.get_top_effects():
+                if is_time_effect(effect):
+                    continue
+                self._disconnect_from_track_element(effect)
 
         self.clip = clip
         if self.clip:
@@ -409,6 +717,8 @@ class EffectProperties(Gtk.Expander, Loggable):
             self.clip.connect("child-removed", self._track_element_removed_cb)
             for track_element in self.clip.get_children(recursive=True):
                 if isinstance(track_element, GES.BaseEffect):
+                    if is_time_effect(track_element):
+                        continue
                     self._connect_to_track_element(track_element)
 
             self._update_listbox()
@@ -488,7 +798,10 @@ class EffectProperties(Gtk.Expander, Loggable):
         self.effects_listbox.drag_unhighlight_row()
         self.effects_listbox.drag_unhighlight()
 
-    def _drag_data_received_cb(self, widget, drag_context, unused_x, y, selection_data, unused_info, 
timestamp):
+    def __get_time_effects(self):
+        return [effect for effect in self.clip.get_top_effects() if is_time_effect(effect)]
+
+    def _drag_data_received_cb(self, widget, drag_context, x, y, selection_data, unused_info, timestamp):
         if not self.clip:
             # Indicate that a drop will not be accepted.
             Gdk.drag_status(drag_context, 0, timestamp)
@@ -504,7 +817,9 @@ class EffectProperties(Gtk.Expander, Loggable):
             # An effect dragged probably from the effects list.
             factory_name = str(selection_data.get_data(), "UTF-8")
 
-            self.debug("Effect dragged at position %s", drop_index)
+            top_effect_index = drop_index + len(self.__get_time_effects())
+            self.debug("Effect dragged at position %s - computed top effect index %s",
+                       drop_index, top_effect_index)
             effect_info = self.app.effects.get_info(factory_name)
             pipeline = self.app.project_manager.current_project.pipeline
             with self.app.action_log.started("add effect",
@@ -513,7 +828,7 @@ class EffectProperties(Gtk.Expander, Loggable):
                                              toplevel=True):
                 effect = self.clip.ui.add_effect(effect_info)
                 if effect:
-                    self.clip.set_top_effect_index(effect, drop_index)
+                    self.clip.set_top_effect_index(effect, top_effect_index)
 
         elif drag_context.get_suggested_action() == Gdk.DragAction.MOVE:
             # An effect dragged from the same listbox to change its position.
@@ -535,15 +850,18 @@ class EffectProperties(Gtk.Expander, Loggable):
             # Noop.
             return
 
+        time_effects = self.__get_time_effects()
+        effect_index = source_index + len(time_effects)
+        wanted_index = drop_index + len(time_effects)
         effects = clip.get_top_effects()
-        effect = effects[source_index]
+        effect = effects[effect_index]
         pipeline = self.app.project_manager.current_project.pipeline
 
         with self.app.action_log.started("move effect",
                                          finalizing_action=CommitTimelineFinalizingAction(
                                              pipeline),
                                          toplevel=True):
-            clip.set_top_effect_index(effect, drop_index)
+            clip.set_top_effect_index(effect, wanted_index)
 
 
 class TransformationProperties(Gtk.Expander, Loggable):
diff --git a/pitivi/timeline/previewers.py b/pitivi/timeline/previewers.py
index 98ab9a894..f2652a141 100644
--- a/pitivi/timeline/previewers.py
+++ b/pitivi/timeline/previewers.py
@@ -865,18 +865,21 @@ class VideoPreviewer(Gtk.Layout, AssetPreviewer, Zoomable):
 
     def _update_thumbnails(self):
         """Updates the thumbnail widgets for the clip at the current zoom."""
-        if not self.thumb_width:
+        if not self.thumb_width or not self.ges_elem.get_track() or not self.ges_elem.props.active:
             # The thumb_width will be available when pipeline has been started
             return
 
         thumbs = {}
         queue = []
         interval = self.thumb_interval(self.thumb_width)
-        element_left = quantize(self.ges_elem.props.in_point, interval)
-        element_right = self.ges_elem.props.in_point + self.ges_elem.props.duration
+
         y = (self.props.height_request - self.thumb_height) / 2
-        for position in range(element_left, element_right, interval):
-            x = Zoomable.ns_to_pixel(position) - self.ns_to_pixel(self.ges_elem.props.in_point)
+        clip = self.ges_elem.get_parent()
+        for position in range(clip.props.start, clip.props.start + clip.props.duration, interval):
+            x = Zoomable.ns_to_pixel(position)
+
+            # Convert position in the timeline to the internal position in the source element
+            position = clip.get_internal_time_from_timeline_time(self.ges_elem, position)
             try:
                 thumb = self.thumbs.pop(position)
                 self.move(thumb, x, y)
@@ -1164,6 +1167,8 @@ class AudioPreviewer(Gtk.Layout, Previewer, Zoomable, Loggable):
         # The samples range used when self.surface has been created.
         self._surface_start_ns = 0
         self._surface_end_ns = 0
+        # The playback rate from last time the surface was updated.
+        self._rate = 1.0
 
         # Guard against malformed URIs
         self.wavefile = None
@@ -1269,17 +1274,22 @@ class AudioPreviewer(Gtk.Layout, Previewer, Zoomable, Loggable):
         return False
 
     def do_draw(self, context):
-        if not self.samples:
+        if not self.samples or not self.ges_elem.get_track() or not self.ges_elem.props.active:
             # Nothing to draw.
             return
 
         # The area we have to refresh is determined by the start and end
         # calculated in the context of the asset duration.
         rect = Gdk.cairo_get_clip_rectangle(context)[1]
+        clip = self.ges_elem.get_parent()
         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)
 
         zoom = self.get_current_zoom_level()
         height = self.get_allocation().height - 2 * CLIP_BORDER_WIDTH
@@ -1288,6 +1298,7 @@ class AudioPreviewer(Gtk.Layout, Previewer, Zoomable, Loggable):
                 height != self.surface.get_height() or \
                 zoom != self._surface_zoom_level or \
                 start_ns < self._surface_start_ns or \
+                rate != self._rate or \
                 end_ns > self._surface_end_ns:
             if self.surface:
                 self.surface.finish()
@@ -1298,9 +1309,11 @@ class AudioPreviewer(Gtk.Layout, Previewer, Zoomable, Loggable):
             extra = self.pixel_to_ns(WAVEFORM_SURFACE_EXTRA_PX)
             self._surface_start_ns = max(0, start_ns - extra)
             self._surface_end_ns = min(end_ns + extra, max_duration)
+            self._rate = rate
 
-            range_start = min(max(0, int(self._surface_start_ns / SAMPLE_DURATION)), len(self.samples))
-            range_end = min(max(0, int(self._surface_end_ns / SAMPLE_DURATION)), len(self.samples))
+            sample_duration = SAMPLE_DURATION / rate
+            range_start = min(max(0, int(self._surface_start_ns / sample_duration)), len(self.samples))
+            range_end = min(max(0, int(self._surface_end_ns / sample_duration)), len(self.samples))
             samples = self.samples[range_start:range_end]
             surface_width = self.ns_to_pixel(self._surface_end_ns - self._surface_start_ns)
             self.surface = renderer.fill_surface(samples, surface_width, height)
diff --git a/pitivi/timeline/timeline.py b/pitivi/timeline/timeline.py
index fb2e8036a..930474ad1 100644
--- a/pitivi/timeline/timeline.py
+++ b/pitivi/timeline/timeline.py
@@ -473,7 +473,7 @@ class Timeline(Gtk.EventBox, Zoomable, Loggable):
             self.ges_timeline.ui = None
             self.ges_timeline = None
 
-        if self._project:
+        if self._project and self._project.pipeline:
             self._project.pipeline.disconnect_by_func(self._position_cb)
 
         self._project = project
diff --git a/tests/common.py b/tests/common.py
index 50d7b96fb..af888460b 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -196,13 +196,23 @@ def setup_project_with_clips(func, assets_names=None):
     def wrapper(self):
         with cloned_sample(*list(assets_names)):
             self.app = create_pitivi()
-            self.project = self.app.project_manager.new_blank_project()
-            self.timeline = self.project.ges_timeline
-            self.layer = self.timeline.append_layer()
+
+            self.timeline_container = TimelineContainer(self.app, 
editor_state=self.app.gui.editor.editor_state)
+
+            def loaded_cb(unused_pm, project):
+                self.timeline_container.set_project(project)
+                self.project = project
+                self.timeline = project.ges_timeline
+                layers = self.timeline.get_layers()
+                if layers:
+                    self.layer = layers[0]
+                else:
+                    self.layer = self.timeline.append_layer()
+
+            self.app.project_manager.connect("new-project-loaded", loaded_cb)
+            self.app.project_manager.new_blank_project()
             self.action_log = self.app.action_log
             project = self.app.project_manager.current_project
-            self.timeline_container = TimelineContainer(self.app, 
editor_state=self.app.gui.editor.editor_state)
-            self.timeline_container.set_project(project)
 
             timeline = self.timeline_container.timeline
             timeline.app.project_manager.current_project = project
@@ -210,7 +220,7 @@ def setup_project_with_clips(func, assets_names=None):
             uris = collections.deque([get_sample_uri(fname) for fname in assets_names])
             mainloop = create_main_loop()
 
-            def loaded_cb(project, timeline):
+            def project_loaded_cb(project, timeline):
                 project.add_uris([uris.popleft()])
 
             def progress_cb(project, progress, estimated_time):
@@ -220,11 +230,11 @@ def setup_project_with_clips(func, assets_names=None):
                     else:
                         mainloop.quit()
 
-            project.connect_after("loaded", loaded_cb)
+            project.connect_after("loaded", project_loaded_cb)
             project.connect_after("asset-loading-progress", progress_cb)
             mainloop.run()
 
-            project.disconnect_by_func(loaded_cb)
+            project.disconnect_by_func(project_loaded_cb)
             project.disconnect_by_func(progress_cb)
 
             assets = project.list_assets(GES.UriClip)
@@ -281,6 +291,8 @@ def setup_clipproperties(func):
         self.transformation_box = self.clipproperties.transformation_expander
         self.transformation_box._new_project_loaded_cb(None, self.project)
 
+        self.speed_box = self.clipproperties.speed_expander
+
         func(self)
 
         del self.transformation_box
diff --git a/tests/test_clipproperties.py b/tests/test_clipproperties.py
index ba6510205..9ec5863fd 100644
--- a/tests/test_clipproperties.py
+++ b/tests/test_clipproperties.py
@@ -16,9 +16,11 @@
 # License along with this program; if not, see <http://www.gnu.org/licenses/>.
 """Tests for the pitivi.clipproperties module."""
 # pylint: disable=protected-access,no-self-use,import-outside-toplevel,no-member
+import tempfile
 from unittest import mock
 
 from gi.repository import GES
+from gi.repository import Gst
 
 from tests import common
 
@@ -30,17 +32,15 @@ class TransformationPropertiesTest(common.TestCase):
     @common.setup_clipproperties
     def test_spin_buttons_read(self):
         """Checks the spin buttons update when the source properties change."""
-        # Create transformation box
-        transformation_box = self.transformation_box
-        timeline = transformation_box.app.gui.editor.timeline_ui.timeline
-        spin_buttons = transformation_box.spin_buttons
+        timeline = self.transformation_box.app.gui.editor.timeline_ui.timeline
+        spin_buttons = self.transformation_box.spin_buttons
 
         # Add a clip and select it
         clip = self.add_clips_simple(timeline, 1)[0]
         timeline.selection.select([clip])
 
         # Check that spin buttons display the correct values by default
-        source = transformation_box.source
+        source = self.transformation_box.source
         self.assertIsNotNone(source)
         for prop in ["posx", "posy", "width", "height"]:
             self.assertIn(prop, spin_buttons)
@@ -61,7 +61,6 @@ class TransformationPropertiesTest(common.TestCase):
     @common.setup_clipproperties
     def test_spin_buttons_write(self):
         """Checks the spin buttons changing updates the source properties."""
-        # Create transformation box
         timeline = self.transformation_box.app.gui.editor.timeline_ui.timeline
         spin_buttons = self.transformation_box.spin_buttons
 
@@ -96,7 +95,6 @@ class TransformationPropertiesTest(common.TestCase):
     @common.setup_clipproperties
     def test_spin_buttons_source_change(self):
         """Checks the spin buttons update when the selected clip changes."""
-        # Create transformation box
         timeline = self.transformation_box.app.gui.editor.timeline_ui.timeline
         spin_buttons = self.transformation_box.spin_buttons
 
@@ -130,7 +128,6 @@ class TransformationPropertiesTest(common.TestCase):
     @common.setup_clipproperties
     def test_keyframes_activate(self):
         """Checks transformation properties keyframes activation."""
-        # Create transformation box
         timeline = self.transformation_box.app.gui.editor.timeline_ui.timeline
 
         # Add a clip and select it
@@ -166,7 +163,6 @@ class TransformationPropertiesTest(common.TestCase):
     @common.setup_clipproperties
     def test_keyframes_add(self):
         """Checks keyframe creation."""
-        # Create transformation box
         timeline = self.transformation_box.app.gui.editor.timeline_ui.timeline
         pipeline = timeline._project.pipeline
         spin_buttons = self.transformation_box.spin_buttons
@@ -200,7 +196,6 @@ class TransformationPropertiesTest(common.TestCase):
     @common.setup_clipproperties
     def test_keyframes_navigation(self):
         """Checks keyframe navigation."""
-        # Create transformation box
         timeline = self.transformation_box.app.gui.editor.timeline_ui.timeline
         pipeline = timeline._project.pipeline
 
@@ -250,7 +245,6 @@ class TransformationPropertiesTest(common.TestCase):
     @common.setup_clipproperties
     def test_reset_to_default(self):
         """Checks "reset to default" button."""
-        # Create transformation box
         timeline = self.transformation_box.app.gui.editor.timeline_ui.timeline
 
         # Add a clip and select it
@@ -395,3 +389,145 @@ class ClipPropertiesTest(common.TestCase):
 
         self.timeline_container.timeline.selection.select(clips)
         self.assertEqual(len(self.action_log.undo_stacks), 1)
+
+
+class SpeedPropertiesTest(common.TestCase):
+    """Tests for the TransformationProperties widget."""
+
+    def assert_applied_rate(self, rate, expected_duration):
+        self.assertEqual(len(self.speed_box._time_effects), 2)
+        self.assertEqual(self.speed_box.props.rate, rate)
+        self.assertEqual(self.speed_box._clip.props.duration, expected_duration)
+        for effect, propname in self.speed_box._time_effects.values():
+            self.assertTrue(propname in ["rate", "tempo"], propname)
+            self.assertEqual(effect.get_child_property(propname).value, rate)
+
+        self.assertEqual(self.speed_box._speed_spinner_adjustment.props.value, rate)
+
+    @common.setup_project_with_clips
+    @common.setup_clipproperties
+    def test_clip_speed(self):
+        clip, = self.layer.get_clips()
+
+        self.project.ges_timeline.props.snapping_distance = Gst.SECOND
+        self.assertEqual(self.speed_box._sources, {})
+        self.assertEqual(self.speed_box._time_effects, {})
+
+        self.timeline_container.timeline.selection.select([clip])
+
+        self.assertEqual(len(self.speed_box._sources), 2, self.speed_box._sources)
+        self.assertEqual(self.speed_box._time_effects, {})
+
+        clip.props.duration = Gst.SECOND
+        self.assertEqual(self.speed_box._clip.props.duration, Gst.SECOND)
+
+        self.speed_box._speed_spinner_adjustment.props.value = 2.0
+        self.assert_applied_rate(2.0, Gst.SECOND / 2)
+
+        self.speed_box._speed_spinner_adjustment.props.value = 0.5
+        self.assert_applied_rate(0.5, Gst.SECOND * 2)
+
+        self.action_log.undo()
+        self.assert_applied_rate(2.0, Gst.SECOND / 2)
+
+        self.action_log.undo()
+        self.assert_applied_rate(1.0, Gst.SECOND)
+
+        self.action_log.redo()
+        self.assert_applied_rate(2.0, Gst.SECOND / 2)
+
+        self.action_log.redo()
+        self.assert_applied_rate(0.5, Gst.SECOND * 2)
+
+        self.timeline_container.timeline.selection.select([])
+        self.assertEqual(self.speed_box._sources, {})
+        self.assertEqual(self.speed_box._time_effects, {})
+
+        self.timeline_container.timeline.selection.select([clip])
+        self.assert_applied_rate(0.5, Gst.SECOND * 2)
+
+        self.action_log.undo()
+        self.assert_applied_rate(2.0, Gst.SECOND / 2)
+
+        self.timeline_container.timeline.selection.select([])
+        self.assertEqual(self.speed_box._sources, {})
+        self.assertEqual(self.speed_box._time_effects, {})
+
+        self.action_log.undo()
+        self.assertEqual(clip.get_child_property("GstVideoRate::rate").value, 1.0)
+        self.assertEqual(clip.get_child_property("tempo").value, 1.0)
+
+        self.action_log.redo()
+        self.assertEqual(clip.get_child_property("GstVideoRate::rate").value, 2.0)
+        self.assertEqual(clip.get_child_property("tempo").value, 2.0)
+
+        self.action_log.redo()
+        self.assertEqual(clip.get_child_property("GstVideoRate::rate").value, 0.5)
+        self.assertEqual(clip.get_child_property("tempo").value, 0.5)
+
+        self.timeline_container.timeline.selection.select([clip])
+        self.project.pipeline.get_position = mock.Mock(return_value=Gst.SECOND)
+        self.timeline_container.split_action.emit("activate", None)
+
+        clip1, clip2 = self.layer.get_clips()
+        self.assertEqual(clip1.props.start, 0)
+        self.assertEqual(clip1.props.duration, Gst.SECOND)
+        self.assertEqual(clip2.props.start, Gst.SECOND)
+        self.assertEqual(clip2.props.duration, Gst.SECOND)
+        self.assertEqual(self.project.ges_timeline.props.snapping_distance, Gst.SECOND)
+
+        # 0.1 would lead to clip1 totally overlapping clip2, ensure it is a noop
+        self.speed_box._speed_spinner_adjustment.props.value = 0.1
+        self.assert_applied_rate(0.5, Gst.SECOND)
+        self.assertEqual(self.project.ges_timeline.props.snapping_distance, Gst.SECOND)
+
+        self.action_log.undo()
+        self.assert_applied_rate(0.5, Gst.SECOND * 2)
+
+        # Undoing should undo the split
+        clip1, = self.layer.get_clips()
+
+        # redo the split
+        self.action_log.redo()
+        clip1, clip2 = self.layer.get_clips()
+        self.assertEqual(self.speed_box._clip, clip1)
+        self.assert_applied_rate(0.5, Gst.SECOND)
+        self.assertEqual(self.project.ges_timeline.props.snapping_distance, Gst.SECOND)
+
+        self.speed_box._speed_spinner_adjustment.props.value = 1.0
+        self.assert_applied_rate(1.0, Gst.SECOND / 2)
+
+        self.speed_box._speed_spinner_adjustment.props.value = 0.5
+        self.assert_applied_rate(0.5, Gst.SECOND)
+
+        self.speed_box.set_clip(None)
+        self.assertEqual(self.speed_box._sources, {})
+        self.assertEqual(self.speed_box._time_effects, {})
+
+    @common.setup_project_with_clips
+    @common.setup_clipproperties
+    def test_load_project_clip_speed(self):
+        clip, = self.layer.get_clips()
+        clip.props.duration = Gst.SECOND
+
+        self.timeline_container.timeline.selection.select([clip])
+        self.speed_box._speed_spinner_adjustment.props.value = 0.5
+        self.assert_applied_rate(0.5, 2 * Gst.SECOND)
+
+        with tempfile.NamedTemporaryFile() as temp_file:
+            uri = Gst.filename_to_uri(temp_file.name)
+            project_manager = self.app.project_manager
+            self.assertTrue(project_manager.save_project(uri=uri, backup=False))
+
+            mainloop = common.create_main_loop()
+
+            project_manager.connect("new-project-loaded", lambda *args: mainloop.quit())
+            project_manager.connect("closing-project", lambda *args: True)
+            self.assertTrue(project_manager.close_running_project())
+            project_manager.load_project(uri)
+            mainloop.run()
+
+        self.assertTrue(self.layer.get_clips()[0] != clip, "%s == %s" % (clip, clip))
+        clip, = self.layer.get_clips()
+        self.timeline_container.timeline.selection.select([clip])
+        self.assert_applied_rate(0.5, 2 * Gst.SECOND)


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]