[pitivi/wip-speed-control: 1/2] Add support for time effects
- From: Alexandru Băluț <alexbalut src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [pitivi/wip-speed-control: 1/2] Add support for time effects
- Date: Sun, 31 Jan 2021 08:04:43 +0000 (UTC)
commit 85f32007f713e7571be60aa0cd07d6a1c32c1b1e
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 | 9 +-
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, 530 insertions(+), 41 deletions(-)
---
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 60acc7cfc..3eda858de 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
@@ -59,7 +64,7 @@ repos:
bin/pitivi.in
)$
- repo: https://github.com/adrienverge/yamllint.git
- rev: v1.25.0
+ rev: v1.26.0
hooks:
- id: yamllint
args:
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]