[pitivi/wip-speed-control: 6/8] Add support for time effects
- From: Alexandru Băluț <alexbalut src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [pitivi/wip-speed-control: 6/8] Add support for time effects
- Date: Wed, 27 Jan 2021 20:59:26 +0000 (UTC)
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]