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




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

    Add support for time effects

 .pre-commit-config.yaml       |   8 +-
 meson.build                   |   5 +-
 mypy.ini                      |   4 +
 pitivi/clipproperties.py      | 292 ++++++++++++++++++++++++++++++++++++++++--
 pitivi/timeline/previewers.py |  29 +++--
 pitivi/timeline/timeline.py   |   2 +-
 tests/common.py               |  45 +++++--
 tests/test_clipproperties.py  | 149 ++++++++++++++++++++-
 8 files changed, 505 insertions(+), 29 deletions(-)
---
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 60acc7cfc..49d356c98 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -38,7 +38,8 @@ repos:
         args:
           # http://flake8.pycqa.org/en/latest/user/error-codes.html
           # https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes
-          - --ignore=E402,E501,E722,F401,F841,W504
+          # F821 is already catched by pylint and we can diable the check more granuarly there.
+          - --ignore=E402,E501,E722,F401,F841,W504,F821
         exclude: >
           (?x)^(
             pitivi/utils/extract.py|
@@ -72,3 +73,8 @@ repos:
         args:
           - '--server'
           - 'https://gitlab.gnome.org'
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: 'v0.800'
+    hooks:
+      - id: mypy
+        files: '.*pitivi/clipproperties.py$'
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..4eddc850c 100644
--- a/pitivi/clipproperties.py
+++ b/pitivi/clipproperties.py
@@ -18,11 +18,16 @@
 import bisect
 import os
 from gettext import gettext as _
+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
@@ -35,16 +40,20 @@ from pitivi.configure import get_ui_dir
 from pitivi.effects import EffectsPopover
 from pitivi.effects import EffectsPropertiesManager
 from pitivi.effects import HIDDEN_EFFECTS
+from pitivi.project import Project
+from pitivi.project import ProjectManager
 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.misc import disconnect_all_by_func
 from pitivi.utils.pipeline import PipelineError
 from pitivi.utils.timeline import SELECT
+from pitivi.utils.timeline import Selection
 from pitivi.utils.ui import disable_scroll
 from pitivi.utils.ui import EFFECT_TARGET_ENTRY
 from pitivi.utils.ui import PADDING
 from pitivi.utils.ui import SPACING
+from pitivi.utils.widgets import NumericWidget
 
 (COL_ACTIVATED,
  COL_TYPE,
@@ -94,6 +103,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)
@@ -215,6 +228,258 @@ 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(_("Speed"))
+
+        # Global variables related to effects
+        self.app = app
+
+        self._project: Optional[GES.Project] = None
+        self._selection: Optional[Selection] = None
+
+        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]] = {}
+
+        self.app.project_manager.connect_after(
+            "new-project-loaded", self._new_project_loaded_cb)
+
+        grid = Gtk.Grid.new()
+        grid.props.row_spacing = grid.props.column_spacing = grid.props.border_width = SPACING
+        self.add(grid)
+
+        self._speed_setter = NumericWidget(upper=10.0, lower=0.1, default=1.0)
+        self._speed_setter.set_widget_value(float(1.0))
+        self.__add_widget_to_grid(grid, _("Playback speed"), self._speed_setter, 0)
+
+        self.__setting_rate = False
+        self.bind_property("rate",
+                           self._speed_setter.adjustment, "value",
+                           GObject.BindingFlags.BIDIRECTIONAL)
+
+    def __add_widget_to_grid(self, grid: Gtk.Grid, nick: str, widget: Gtk.Widget, y: int) -> None:
+        text = _("%(preference_label)s:") % {"preference_label": nick}
+
+        icon = Gtk.Image()
+        icon.set_from_icon_name("edit-clear-all-symbolic", Gtk.IconSize.MENU)
+        button = Gtk.Button()
+        button.add(icon)
+        button.set_tooltip_text(_("Reset to default value"))
+        button.set_relief(Gtk.ReliefStyle.NONE)
+        button.connect('clicked', lambda _, widget: widget.set_widget_to_default(), widget)
+
+        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 _new_project_loaded_cb(self, unused_project_manager: ProjectManager, project: Project) -> None:
+        if self._selection is not None:
+            self._selection.disconnect_by_func(self._selection_changed_cb)
+            self._selection = None
+        self._project = project
+        if project:
+            self._selection = project.ges_timeline.ui.selection
+            self._selection.connect(
+                'selection-changed', self._selection_changed_cb)
+            self.__set_selected(self._selection.selected)
+        else:
+            self._clip = None
+            self._time_effects = {}
+            self._sources = {}
+
+    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
+
+    @GObject.Property(type=float)
+    def rate(self):
+        for effect, propname in self._time_effects.values():
+            return effect.get_child_property(propname).value
+        return 1.0
+
+    @rate.setter  # type: ignore
+    def rate(self, value: float) -> None:
+        if not self._clip:
+            return
+
+        if value != 1.0:
+            self.__ensure_effects()
+
+        prev_rate = None
+        source, source_duration = self.__get_source_duration()
+        for effect, propname in self._time_effects.values():
+            prev_rate = effect.get_child_property(propname).value
+            if prev_rate == value:
+                return
+        assert prev_rate
+
+        pipeline = self._project.pipeline
+        self.__setting_rate = True
+        prev_snapping_distance = self._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 tckelem in self._clip.get_children(True):
+                tckelem.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
+            self._project.ges_timeline.props.snapping_distance = 0
+            self._clip.edit_full(-1, GES.EditMode.TRIM, GES.Edge.END, new_end)
+            for tckelem in self._clip.get_children(True):
+                tckelem.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_setter.set_widget_value(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
+            self._project.ges_timeline.props.snapping_distance = prev_snapping_distance
+            if is_auto_clamp:
+                for tckelem in self._clip.get_children(True):
+                    tckelem.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")
+
+    def __set_selected(self, selected: set) -> None:
+        if self._clip:
+            self._clip.disconnect_by_func(self.__child_property_changed_cb)
+
+        self._clip = None
+        self._sources = {}
+        self._clip, = selected if len(selected) == 1 else [None]
+
+        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
+
+    def _selection_changed_cb(self, selection):
+        self.__set_selected(selection.selected)
+
+
 class EffectProperties(Gtk.Expander, Loggable):
     """Widget for viewing a list of effects and configuring them.
 
@@ -399,9 +664,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 +675,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 +756,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 +775,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 +786,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 +808,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..0de250a30 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 overal 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..b4e9b0f8e 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -38,6 +38,8 @@ from gi.repository import Gtk
 
 from pitivi.application import Pitivi
 from pitivi.clipproperties import ClipProperties
+from pitivi.clipproperties import SpeedProperties
+from pitivi.clipproperties import TransformationProperties
 from pitivi.editorstate import EditorState
 from pitivi.project import ProjectManager
 from pitivi.settings import GlobalSettings
@@ -196,13 +198,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 +222,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 +232,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)
@@ -289,6 +301,23 @@ def setup_clipproperties(func):
     return wrapped
 
 
+def setup_clip_speed_control_box(func):
+    def wrapped(self):
+        timeline_container = getattr(self, "timeline_container", create_timeline_container())
+        app = timeline_container.app
+        speed_controller = SpeedProperties(app)
+        speed_controller._new_project_loaded_cb(app, self.timeline_container._project)
+
+        # Inject useful vars into the test scope
+        func.__globals__['speed_controller'] = speed_controller
+        func.__globals__['timeline_container'] = timeline_container
+        func.__globals__['app'] = app
+
+        func(self)
+
+    return wrapped
+
+
 class TestCase(unittest.TestCase, Loggable):
     _tracked_types = (Gst.MiniObject, Gst.Element, Gst.Pad, Gst.Caps)
 
diff --git a/tests/test_clipproperties.py b/tests/test_clipproperties.py
index ba6510205..168e4d9fa 100644
--- a/tests/test_clipproperties.py
+++ b/tests/test_clipproperties.py
@@ -15,10 +15,13 @@
 # You should have received a copy of the GNU Lesser General Public
 # 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
+# pylint: disable=protected-access,no-self-use,import-outside-toplevel,no-member,undefined-variable
+import tempfile
 from unittest import mock
 
 from gi.repository import GES
+from gi.repository import Gst
+from gi.repository import Gtk
 
 from tests import common
 
@@ -395,3 +398,147 @@ 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, speed_controller, rate, expected_duration):
+        self.assertEqual(len(speed_controller._time_effects), 2)
+        self.assertEqual(speed_controller.props.rate, rate)
+        self.assertEqual(speed_controller._clip.props.duration, expected_duration)
+        for effect, propname in speed_controller._time_effects.values():
+            self.assertTrue(propname in ["rate", "tempo"], propname)
+            self.assertEqual(effect.get_child_property(propname).value, rate)
+
+        self.assertEqual(speed_controller._speed_setter.get_widget_value(), rate)
+
+    @common.setup_project_with_clips
+    @common.setup_clip_speed_control_box
+    def test_clip_speed(self):
+        clip, = self.layer.get_clips()
+
+        self.project.ges_timeline.props.snapping_distance = Gst.SECOND
+        self.assertEqual(speed_controller._sources, {})
+        self.assertEqual(speed_controller._time_effects, {})
+
+        self.timeline_container.timeline.selection.select([clip])
+
+        self.assertEqual(len(speed_controller._sources), 2, speed_controller._sources)
+        self.assertEqual(speed_controller._time_effects, {})
+
+        clip.props.duration = Gst.SECOND
+        self.assertEqual(speed_controller._clip.props.duration, Gst.SECOND)
+
+        speed_controller._speed_setter.set_widget_value(2.0)
+        self.assert_applied_rate(speed_controller, 2.0, Gst.SECOND / 2)
+
+        speed_controller._speed_setter.set_widget_value(0.5)
+        self.assert_applied_rate(speed_controller, 0.5, Gst.SECOND * 2)
+
+        self.action_log.undo()
+        self.assert_applied_rate(speed_controller, 2.0, Gst.SECOND / 2)
+
+        self.action_log.undo()
+        self.assert_applied_rate(speed_controller, 1.0, Gst.SECOND)
+
+        self.action_log.redo()
+        self.assert_applied_rate(speed_controller, 2.0, Gst.SECOND / 2)
+
+        self.action_log.redo()
+        self.assert_applied_rate(speed_controller, 0.5, Gst.SECOND * 2)
+
+        self.timeline_container.timeline.selection.select([])
+        self.assertEqual(speed_controller._sources, {})
+        self.assertEqual(speed_controller._time_effects, {})
+
+        self.timeline_container.timeline.selection.select([clip])
+        self.assert_applied_rate(speed_controller, 0.5, Gst.SECOND * 2)
+
+        self.action_log.undo()
+        self.assert_applied_rate(speed_controller, 2.0, Gst.SECOND / 2)
+
+        self.timeline_container.timeline.selection.select([])
+        self.assertEqual(speed_controller._sources, {})
+        self.assertEqual(speed_controller._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
+        speed_controller._speed_setter.set_widget_value(0.1)
+        self.assert_applied_rate(speed_controller, 0.5, Gst.SECOND)
+        self.assertEqual(self.project.ges_timeline.props.snapping_distance, Gst.SECOND)
+
+        self.action_log.undo()
+        self.assert_applied_rate(speed_controller, 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(speed_controller._clip, clip1)
+        self.assert_applied_rate(speed_controller, 0.5, Gst.SECOND)
+        self.assertEqual(self.project.ges_timeline.props.snapping_distance, Gst.SECOND)
+
+        speed_controller._speed_setter.set_widget_value(float(1.0))
+        self.assert_applied_rate(speed_controller, 1.0, Gst.SECOND / 2)
+
+        speed_controller._speed_setter.set_widget_value(float(0.5))
+        self.assert_applied_rate(speed_controller, 0.5, Gst.SECOND)
+
+        speed_controller._new_project_loaded_cb(self.app, None)
+        self.assertEqual(speed_controller._sources, {})
+        self.assertEqual(speed_controller._time_effects, {})
+
+        return speed_controller
+
+    @common.setup_project_with_clips
+    @common.setup_clip_speed_control_box
+    def test_load_project_clip_speed(self):
+        clip, = self.layer.get_clips()
+        clip.props.duration = Gst.SECOND
+
+        self.timeline_container.timeline.selection.select([clip])
+        speed_controller._speed_setter.set_widget_value(0.5)
+        self.assert_applied_rate(speed_controller, 0.5, 2 * Gst.SECOND)
+
+        with tempfile.NamedTemporaryFile() as temp_file:
+            uri = Gst.filename_to_uri(temp_file.name)
+            pm = self.app.project_manager
+            self.assertTrue(pm.save_project(uri=uri, backup=False))
+
+            mainloop = common.create_main_loop()
+
+            pm.connect("new-project-loaded", lambda *args: mainloop.quit())
+            pm.connect("closing-project", lambda *args: True)
+            self.assertTrue(pm.close_running_project())
+            pm.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(speed_controller, 0.5, 2 * Gst.SECOND)


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