From 131048df2cfadf50975eaa1aadc35a3bf2b38e8f Mon Sep 17 00:00:00 2001
From: Vivek R <123vivekr@gmail.com>
Date: Thu, 9 Jul 2020 17:31:27 +0530
Subject: [PATCH 1/3] trackerperspective: Allow tracking objects
The objects are tracked automatically using the cvtracker element from gst-plugins-bad.
The automatic tracking can be adjusted manually using the new tracker perspective.
---
data/ui/trackerperspective.ui | 444 ++++++++++++++++++++
pitivi/check.py | 59 ++-
pitivi/clipproperties.py | 20 +
pitivi/timeline/elements.py | 11 +-
pitivi/trackerperspective.py | 679 +++++++++++++++++++++++++++++++
pitivi/utils/pipeline.py | 2 +-
pitivi/utils/ui.py | 8 +
pitivi/viewer/viewer.py | 1 +
tests/test_trackerperspective.py | 74 ++++
9 files changed, 1275 insertions(+), 23 deletions(-)
create mode 100644 data/ui/trackerperspective.ui
create mode 100644 pitivi/trackerperspective.py
create mode 100644 tests/test_trackerperspective.py
diff --git a/data/ui/trackerperspective.ui b/data/ui/trackerperspective.ui
new file mode 100644
index 000000000..3c87234ea
--- /dev/null
+++ b/data/ui/trackerperspective.ui
@@ -0,0 +1,444 @@
+
+
+
+
+
+
+
+
+
+
+
+ True
+ False
+ center
+ 10
+ 10
+ 10
+ 10
+ 10
+
+
+
+ False
+ False
+ 0
+
+
+
+
+ True
+ False
+
+
+ False
+ False
+ 1
+
+
+
+
+ True
+ False
+ True
+ vertical
+
+
+ True
+ False
+ 0
+ none
+ False
+
+
+ True
+ False
+
+
+
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ 20
+ 20
+ 20
+ 20
+ vertical
+
+
+ True
+ False
+ True
+ center
+
+
+ True
+ True
+ True
+ Go back one frame
+ seek_backward_icon
+
+
+
+ True
+ True
+ 0
+
+
+
+
+ True
+ True
+ True
+ play_icon
+ True
+
+
+
+ True
+ True
+ 1
+
+
+
+
+ True
+ True
+ True
+ Go forward one frame
+ seek_forward_icon
+
+
+
+ True
+ True
+ 2
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ False
+ True
+ 1
+
+
+
+
+ True
+ True
+ 15
+ 15
+ 5
+ pos_adj
+ True
+ False
+
+
+ False
+ True
+ 2
+
+
+
+
+ True
+ False
+ center
+ 12
+ 6
+
+
+ True
+ False
+ center
+ Tracking Algorithm
+
+
+
+
+
+ False
+ True
+ 0
+
+
+
+
+ True
+ False
+ 4
+ 4
+ 3
+
+
+ False
+ False
+ 6
+ 1
+
+
+
+
+ Track
+ True
+ True
+ True
+
+
+
+ True
+ True
+ 2
+
+
+
+
+ gtk-media-stop
+ True
+ True
+ True
+ True
+
+
+
+ False
+ True
+ 3
+
+
+
+
+ False
+ True
+ 3
+
+
+
+
+ True
+ True
+ 2
+
+
+
+
diff --git a/pitivi/check.py b/pitivi/check.py
index 8ca67d9c3..69e74723a 100644
--- a/pitivi/check.py
+++ b/pitivi/check.py
@@ -24,6 +24,7 @@ Package maintainers should look at the bottom section of this file.
import os
import sys
from gettext import gettext as _
+from typing import List
MISSING_SOFT_DEPS = {}
VIDEOSINK_FACTORY = None
@@ -72,7 +73,7 @@ class Dependency:
if self.version_required is None:
self.satisfied = True
else:
- formatted_version = self._format_version(self.component)
+ formatted_version = self._get_version(self.component)
self.version_installed = _version_to_string(formatted_version)
if formatted_version >= _string_to_list(self.version_required):
@@ -88,18 +89,19 @@ class Dependency:
"""
raise NotImplementedError
- def _format_version(self, module):
- """Formats the version of the component.
+ def _get_version(self, module) -> List[int]: # pylint: disable=unused-argument
+ """Gets the version of the component.
Args:
module: The component returned by _try_importing_component.
Returns:
- List[int]: The version number of the component.
+ List[int]: The version number of the component or an empty list
+ if it does not have any.
- For example "1.2.10" should return [1, 2, 10].
+ For example "1.2.10" returns [1, 2, 10].
"""
- raise NotImplementedError
+ return []
def __bool__(self):
return self.satisfied
@@ -109,10 +111,10 @@ class Dependency:
return ""
if not self.component:
- # Translators: %s is a Python module name or another os component
+ # Translators: %s is a Python module name or another OS component
message = _("- %s not found on the system") % self.modulename
else:
- # Translators: %s is a Python module name or another os component
+ # Translators: %s is a Python module name or another OS component
message = _("- %s version %s is installed but Pitivi requires at least version %s") % (
self.modulename, self.version_installed, self.version_required)
@@ -187,10 +189,10 @@ class GstPluginDependency(Dependency):
return ""
if not self.component:
- # Translators: %s is a Python module name or another os component
+ # Translators: %s is a Python module name or another OS component
message = _("- %s GStreamer plug-in not found on the system") % self.modulename
else:
- # Translators: %s is a Python module name or another os component
+ # Translators: %s is a Python module name or another OS component
message = _("- %s Gstreamer plug-in version %s is installed but Pitivi requires at least version %s") % (
self.modulename, self.version_installed, self.version_required)
@@ -200,15 +202,42 @@ class GstPluginDependency(Dependency):
return message
+class GstElementDependency(Dependency):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ def _try_importing_component(self):
+ try:
+ from gi.repository import Gst
+ except ImportError:
+ return None
+ Gst.init(None)
+
+ return Gst.ElementFactory.find(self.modulename)
+
+ def __repr__(self):
+ if self.satisfied:
+ return ""
+
+ # Translators: %s is a Python module name or another OS component
+ message = _("- %s GStreamer element not found on the system") % self.modulename
+
+ if self.additional_message is not None:
+ message += "\n -> " + self.additional_message
+
+ return message
+
+
class GstDependency(GIDependency):
- def _format_version(self, module):
+ def _get_version(self, module):
return list(module.version())
class GtkDependency(GIDependency):
- def _format_version(self, module):
+ def _get_version(self, module):
return [module.MAJOR_VERSION, module.MINOR_VERSION, module.MICRO_VERSION]
@@ -217,7 +246,7 @@ class CairoDependency(ClassicDependency):
def __init__(self, version_required):
ClassicDependency.__init__(self, "cairo", version_required)
- def _format_version(self, module):
+ def _get_version(self, module):
return _string_to_list(module.cairo_version_string())
@@ -286,7 +315,7 @@ class GICheck(ClassicDependency):
def __init__(self, version_required):
ClassicDependency.__init__(self, "gi", version_required)
- def _format_version(self, module):
+ def _get_version(self, module):
return list(module.version_info)
@@ -462,4 +491,6 @@ SOFT_DEPENDENCIES = (
additional_message=_("enables a watchdog in the GStreamer pipeline."
" Use to detect errors happening in GStreamer"
" and recover from them")),
+ GstElementDependency("cvtracker", version_required=None,
+ additional_message=_("enables object tracking")),
ClassicDependency("librosa", additional_message=_("enables beat detection functionality")))
diff --git a/pitivi/clipproperties.py b/pitivi/clipproperties.py
index 6680c9008..05e5a8f53 100644
--- a/pitivi/clipproperties.py
+++ b/pitivi/clipproperties.py
@@ -33,6 +33,7 @@ from gi.repository import Gst
from gi.repository import GstController
from gi.repository import Gtk
+from pitivi.check import MISSING_SOFT_DEPS
from pitivi.clip_properties.alignment import AlignmentEditor
from pitivi.clip_properties.color import ColorProperties
from pitivi.clip_properties.compositing import CompositingProperties
@@ -44,6 +45,7 @@ from pitivi.configure import in_devel
from pitivi.effects import EffectsPopover
from pitivi.effects import EffectsPropertiesManager
from pitivi.effects import HIDDEN_EFFECTS
+from pitivi.trackerperspective import TrackerPerspective
from pitivi.undo.timeline import CommitTimelineFinalizingAction
from pitivi.utils.custom_effect_widgets import setup_custom_effect_widgets
from pitivi.utils.loggable import Loggable
@@ -593,11 +595,21 @@ class EffectProperties(Gtk.Expander, Loggable):
self.add_effect_button.set_popover(self.effect_popover)
self.add_effect_button.props.halign = Gtk.Align.CENTER
+ self.object_tracker_box = Gtk.ButtonBox()
+ self.object_tracker_box.props.halign = Gtk.Align.CENTER
+
+ if "cvtracker" not in MISSING_SOFT_DEPS:
+ self.track_object_button = Gtk.Button(_("Track Object"))
+ self.track_object_button.connect("clicked", self.__track_object_button_clicked_cb)
+ self.track_object_button.props.halign = Gtk.Align.CENTER
+ self.object_tracker_box.pack_start(self.track_object_button, False, False, 0)
+
self.drag_dest_set(Gtk.DestDefaults.DROP, [EFFECT_TARGET_ENTRY],
Gdk.DragAction.COPY)
self.expander_box.pack_start(self.effects_listbox, False, False, 0)
self.expander_box.pack_start(self.add_effect_button, False, False, PADDING)
+ self.expander_box.pack_start(self.object_tracker_box, False, False, PADDING)
self.add(self.expander_box)
@@ -610,6 +622,12 @@ class EffectProperties(Gtk.Expander, Loggable):
self.show_all()
+ def __track_object_button_clicked_cb(self, button):
+ tracker = TrackerPerspective(self.app, self.clip.asset)
+ self.app.project_manager.current_project.pipeline.pause()
+ tracker.setup_ui()
+ self.app.gui.show_perspective(tracker)
+
def _add_effect_button_cb(self, button):
# MenuButton interacts directly with the popover, bypassing our subclassed method
if button.props.active:
@@ -758,6 +776,8 @@ class EffectProperties(Gtk.Expander, Loggable):
if is_time_effect(track_element):
continue
self._connect_to_track_element(track_element)
+ if isinstance(track_element, GES.VideoUriSource) and not clip.asset.is_image():
+ self.track_object_button.show()
self._update_listbox()
self.show()
diff --git a/pitivi/timeline/elements.py b/pitivi/timeline/elements.py
index c01b93dab..6a47c2698 100644
--- a/pitivi/timeline/elements.py
+++ b/pitivi/timeline/elements.py
@@ -53,7 +53,10 @@ from pitivi.utils.timeline import SELECT_ADD
from pitivi.utils.timeline import Selected
from pitivi.utils.timeline import UNSELECT
from pitivi.utils.timeline import Zoomable
+from pitivi.utils.ui import CURSORS
+from pitivi.utils.ui import DRAG_CURSOR
from pitivi.utils.ui import EFFECT_TARGET_ENTRY
+from pitivi.utils.ui import NORMAL_CURSOR
from pitivi.utils.ui import set_state_flags_recurse
KEYFRAME_LINE_HEIGHT = 2
@@ -63,14 +66,6 @@ KEYFRAME_NODE_COLOR = "#F57900" # "Tango" medium orange
SELECTED_KEYFRAME_NODE_COLOR = "#204A87" # "Tango" dark sky blue
HOVERED_KEYFRAME_NODE_COLOR = "#3465A4" # "Tango" medium sky blue
-CURSORS = {
- GES.Edge.EDGE_START: Gdk.Cursor.new(Gdk.CursorType.LEFT_SIDE),
- GES.Edge.EDGE_END: Gdk.Cursor.new(Gdk.CursorType.RIGHT_SIDE)
-}
-
-NORMAL_CURSOR = Gdk.Cursor.new(Gdk.CursorType.LEFT_PTR)
-DRAG_CURSOR = Gdk.Cursor.new(Gdk.CursorType.HAND1)
-
def get_pspec(element_factory_name, propname):
element = Gst.ElementFactory.make(element_factory_name)
diff --git a/pitivi/trackerperspective.py b/pitivi/trackerperspective.py
new file mode 100644
index 000000000..39c18dd3d
--- /dev/null
+++ b/pitivi/trackerperspective.py
@@ -0,0 +1,679 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2020, Vivek R <123vivekr@gmail.com>
+# Copyright (c) 2022, Alex Băluț
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program; if not, see .
+"""Pitivi's Tracker perspective."""
+import json
+import os
+import uuid
+from gettext import gettext as _
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Tuple
+
+import cairo
+import numpy
+from gi.repository import Gdk
+from gi.repository import GES
+from gi.repository import Gio
+from gi.repository import GObject
+from gi.repository import Gst
+from gi.repository import GstVideo
+from gi.repository import Gtk
+
+from pitivi.configure import get_ui_dir
+from pitivi.perspective import Perspective
+from pitivi.utils.loggable import Loggable
+from pitivi.utils.pipeline import AssetPipeline
+from pitivi.utils.pipeline import SimplePipeline
+from pitivi.utils.ui import fix_infobar
+from pitivi.utils.ui import NORMAL_CURSOR
+from pitivi.utils.ui import PADDING
+from pitivi.utils.ui import SPACING
+
+# The meta of an Asset holding all the tracked objects data version 1.
+ASSET_TRACKED_OBJECTS_META = "pitivi::tracker_data::1"
+
+
+# TODO: Replace with bisect.bisect_left when we use Python 3.10.
+def bisect_left(values, val, key):
+ low = 0
+ high = len(values)
+ while low < high:
+ mid = (low + high) // 2
+ if key(values[mid]) < val:
+ low = mid + 1
+ else:
+ high = mid
+ return low
+
+
+class ObjectManager():
+ """Manager of an Asset's tracked objects.
+
+ Attributes:
+ asset (GES.Asset): The Asset in which the objects are being tracked.
+ objects (List[Tuple[int, str, str]]): The objects stored as
+ (index, object_id, name) tuples.
+ values (Dict[str, List[Tuple[int, Tuple[float, float, float, float]]]]):
+ The tracking data for each object is kept as a list of
+ (timestamp, rectangle) tuples, always ordered.
+ """
+
+ def __init__(self, asset: GES.Asset):
+ self.asset: GES.Asset = asset
+ # The list of objects kept in a persistent order.
+ # (index, object_id, name)
+ self.objects: List[Tuple[int, str, str]] = []
+ # object_id -> [(timestamp, (x, y, w, h)), ...]
+ self.values: Dict[str, List[Tuple[int, Tuple[float, float, float, float]]]] = {}
+
+ dump_str = self.asset.get_string(ASSET_TRACKED_OBJECTS_META)
+ if dump_str:
+ data = json.loads(dump_str)
+ objects, values = data
+ # Convert lists back to tuples.
+ self.objects = [tuple(o) for o in objects]
+ self.values = {object_id: [(position, tuple(area)) for (position, area) in values_list]
+ for object_id, values_list in values.items()}
+
+ def save(self):
+ data = [self.objects, self.values]
+ dump_str = json.dumps(data)
+ if self.asset.check_meta_registered(ASSET_TRACKED_OBJECTS_META):
+ self.asset.set_string(ASSET_TRACKED_OBJECTS_META, dump_str)
+ else:
+ self.asset.register_meta_string(GES.MetaFlag.READWRITE, ASSET_TRACKED_OBJECTS_META, dump_str)
+
+ def update_object(self, object_id: str, start_pos: int, roi_data: Dict[int, Tuple[float, float, float, float]]):
+ """Updates the values from the specified position to the end."""
+ object_values: List[Tuple[int, Tuple[float, float, float, float]]] = self.values[object_id]
+ index: int = bisect_left(object_values, start_pos, key=lambda x: x[0])
+ object_values = object_values[:index]
+ object_values.extend(sorted(roi_data.items()))
+ self.values[object_id] = object_values
+
+ def update_object_position(self, object_id: str, position: int, area: Tuple[float, float, float, float]):
+ object_values: List[Tuple[int, Tuple[float, float, float, float]]] = self.values[object_id]
+ index: int = bisect_left(object_values, position, key=lambda x: x[0])
+ if index < len(object_values) and object_values[index][0] == position:
+ # There is already an area for this position; replace it.
+ object_values[index] = (position, area)
+ else:
+ object_values.insert(index, (position, area))
+
+ def add_object(self, index: int, object_id: str, name: str):
+ self.objects.append((index, object_id, name))
+ self.values[object_id] = []
+
+ def remove_object(self, object_id: str):
+ del self.values[object_id]
+ for i, (_index, some_object_id, _name) in enumerate(self.objects):
+ if object_id == some_object_id:
+ del self.objects[i]
+ break
+
+ def interpolate(self, object_id: str, position: int) -> Optional[Tuple[float, float, float, float]]:
+ object_values: List[Tuple[int, Tuple[float, float, float, float]]] = self.values[object_id]
+ if not object_values:
+ return None
+
+ index: int = bisect_left(object_values, position, key=lambda x: x[0])
+ if index == 0:
+ # Return the first area.
+ return object_values[0][1]
+ elif index < len(object_values):
+ # Return the interpolated area.
+ xp = (object_values[index - 1][0], object_values[index][0])
+ yps = zip(object_values[index - 1][1], object_values[index][1])
+ return tuple(numpy.interp(position, xp, yp) for yp in yps)
+ else:
+ # Return the last area.
+ return object_values[-1][1]
+
+ def greatest_index(self) -> int:
+ if self.objects:
+ return max(index for index, _object_id, _name in self.objects)
+ else:
+ return 0
+
+
+class TrackedObjectItem(GObject.GObject):
+ """Data for displaying a Tracked Object in the list.
+
+ Attributes:
+ object_id (str): Identifier to be passed externally where the data
+ might have to be reused.
+ name (str): The name for display.
+ index (int): Internal identifier for sorting the objects in the
+ order they have been created.
+ """
+
+ def __init__(self, object_id: str, index: int, name: str):
+ GObject.GObject.__init__(self)
+ self.object_id: str = object_id
+ self.index: int = index
+ self.name: str = name
+
+
+class TrackedObjectRow(Gtk.ListBoxRow):
+ """Represents a tracked object to be selected in a list."""
+
+ def __init__(self, object_id: str, name: str):
+ Gtk.ListBoxRow.__init__(self)
+ self.object_id: str = object_id
+ self.name: str = name
+
+ label = Gtk.Label(name)
+ label.props.margin = SPACING
+ label.props.margin_end = PADDING
+ label.props.margin_start = PADDING
+ label.props.halign = Gtk.Align.START
+ label.show()
+ self.add(label)
+
+
+@Gtk.Template(filename=os.path.join(get_ui_dir(), "trackerperspective.ui"))
+class ToplevelWidget(Gtk.Box, Loggable):
+ """Toplevel widget of the Tracker perspective."""
+
+ __gtype_name__ = "ToplevelWidget"
+
+ add_object_button = Gtk.Template.Child()
+ algorithm_combo_box = Gtk.Template.Child()
+ aspect_frame = Gtk.Template.Child()
+ drawing_area = Gtk.Template.Child()
+ howto_add_infobar = Gtk.Template.Child()
+ next_frame_button = Gtk.Template.Child()
+ object_listbox = Gtk.Template.Child()
+ object_manager_box = Gtk.Template.Child()
+ pause_icon = Gtk.Template.Child()
+ play_icon = Gtk.Template.Child()
+ play_pause_button = Gtk.Template.Child()
+ pos_adj = Gtk.Template.Child()
+ prev_frame_button = Gtk.Template.Child()
+ remove_object_button = Gtk.Template.Child()
+ seeker = Gtk.Template.Child()
+ stop_button = Gtk.Template.Child()
+ track_button = Gtk.Template.Child()
+ viewer_buttons = Gtk.Template.Child()
+ viewer_overlay = Gtk.Template.Child()
+
+ def __init__(self, app, asset: GES.Asset):
+ Gtk.Box.__init__(self)
+ Loggable.__init__(self)
+
+ self.app = app
+ self.asset: GES.Asset = asset
+ self.object_manager: ObjectManager = ObjectManager(self.asset)
+
+ info = asset.get_info()
+ video_streams = info.get_video_streams()
+ stream = video_streams[0]
+ self.source_width = stream.get_natural_width()
+ self.source_height = stream.get_natural_height()
+ self.videorate = self.app.project_manager.current_project.videorate
+
+ self.pipeline = AssetPipeline(self.asset.props.id)
+ self.pipeline.connect("error", self._pipeline_error_cb)
+ self.pipeline.activate_position_listener(50)
+ self.pipeline.connect("position", self._pipeline_position_cb)
+ self.pipeline.connect("eos", self._pipeline_eos_cb)
+ self.pipeline.connect("state-change", self._pipeline_state_change_cb)
+ self.step = (self.videorate.denom / self.videorate.num) * Gst.SECOND
+
+ # The area selected by the user with drag&drop.
+ # The coordinates are in screen pixels.
+ self.x1: Optional[float] = None # pylint: disable=invalid-name
+ self.y1: Optional[float] = None # pylint: disable=invalid-name
+ self.x2: Optional[float] = None # pylint: disable=invalid-name
+ self.y2: Optional[float] = None # pylint: disable=invalid-name
+ # The size of the viewer when the user selected the area.
+ self.drawing_area_width: Optional[int] = None
+ self.drawing_area_height: Optional[int] = None
+
+ self.current_object: Optional[str] = None
+
+ self.sink_widget = None
+ self.tracker_pipeline: Optional[SimplePipeline] = None
+ self.tracker_sink_widget = None
+ # Data gathered during the current tracking operation.
+ self.roi_data: Optional[Dict[int, Tuple[float, float, float, float]]] = None
+ # Position where the last tracking has been started.
+ self.start_pos: Optional[int] = None
+
+ # Setup ListBox with the tracked objects
+ self.tracked_objects_store = Gio.ListStore()
+ for index, object_id, name in self.object_manager.objects:
+ self.tracked_objects_store.append(TrackedObjectItem(object_id, index, name))
+ self.object_listbox.bind_model(self.tracked_objects_store, self.create_tracked_object_row_func)
+
+ fix_infobar(self.howto_add_infobar)
+
+ # Setup Viewer
+ _, self.sink_widget = self.pipeline.create_sink()
+ self.aspect_frame.set(
+ xalign=0.5, yalign=0.5, ratio=self.source_width / self.source_height, obey_child=False)
+ self.viewer_overlay.add(self.sink_widget)
+ self.viewer_overlay.add_overlay(self.drawing_area)
+ self.viewer_overlay.show_all()
+
+ # Setup Seeker
+ self.seeker.props.adjustment.set_upper(self.asset.props.duration)
+ self.seeker.props.adjustment.set_step_increment(self.step)
+
+ # Setup algorithm ComboBox
+ cell = Gtk.CellRendererText()
+ self.algorithm_combo_box.set_model(self.__get_tracking_algorithms())
+ self.algorithm_combo_box.pack_start(cell, False)
+ self.algorithm_combo_box.add_attribute(cell, "text", 0)
+
+ self._setup(None)
+
+ @Gtk.Template.Callback()
+ def _viewer_overlay_realize_cb(self, widget):
+ self.pipeline.pause()
+
+ # Playback methods
+
+ def _pipeline_error_cb(self, pipeline, message, detail):
+ self.warning("pipeline error: %s (%s)", message, detail)
+ self.pipeline.set_simple_state(Gst.State.NULL)
+
+ def __update_adjustment(self, position: int):
+ """Updates the UI without triggering callbacks."""
+ self.pos_adj.handler_block_by_func(self._adjustment_value_changed_cb)
+ try:
+ self.pos_adj.set_value(position)
+ finally:
+ self.pos_adj.handler_unblock_by_func(self._adjustment_value_changed_cb)
+
+ def _pipeline_position_cb(self, pipline, position):
+ self.__update_adjustment(position)
+
+ def _pipeline_eos_cb(self, pipeline):
+ pipeline.simple_seek(0)
+
+ def _pipeline_state_change_cb(self, pipeline, state, prev_state):
+ self.log("Pipeline state %s -> %s", prev_state, state)
+ if pipeline.playing():
+ icon = self.pause_icon
+ else:
+ icon = self.play_icon
+
+ self.play_pause_button.set_image(icon)
+ self.track_button.props.sensitive = False
+
+ @Gtk.Template.Callback()
+ def _play_pause_button_clicked_cb(self, button):
+ self.pipeline.toggle_playback()
+ self.__reset_selected_area()
+
+ @Gtk.Template.Callback()
+ def _next_frame_button_clicked_cb(self, button):
+ self._seek(1)
+
+ @Gtk.Template.Callback()
+ def _prev_frame_button_clicked_cb(self, button):
+ self._seek(-1)
+
+ def _seek(self, direction: int):
+ state = self.pipeline.get_simple_state()
+ if state == Gst.State.PLAYING:
+ self.pipeline.pause()
+ elif state == Gst.State.PAUSED:
+ self.pipeline.seek_relative(self.step * direction)
+
+ @Gtk.Template.Callback()
+ def _adjustment_value_changed_cb(self, adjustment):
+ """Handle a seek performed by the user interacting with the UI."""
+ if self.pipeline.get_simple_state() != Gst.State.PAUSED:
+ self.pipeline.pause()
+
+ # Block the pipeline's "position" signal to prevent a callback loop.
+ self.pipeline.handler_block_by_func(self._pipeline_position_cb)
+ try:
+ self.pipeline.simple_seek(adjustment.props.value)
+ finally:
+ self.pipeline.handler_unblock_by_func(self._pipeline_position_cb)
+
+ # Bounding box callbacks
+
+ @Gtk.Template.Callback()
+ def _drawing_area_button_event_cb(self, widget, event):
+ res, button = event.get_button()
+ if not res or button != 1:
+ return
+
+ if self.pipeline.get_simple_state() != Gst.State.PAUSED:
+ return
+
+ if event.get_event_type() == Gdk.EventType.BUTTON_PRESS:
+ self.x1 = event.x
+ self.y1 = event.y
+ self.drawing_area_width = self.drawing_area.get_allocated_width
+ self.drawing_area_height = self.drawing_area.get_allocated_height
+ elif event.get_event_type() == Gdk.EventType.BUTTON_RELEASE:
+ self.x2 = event.x
+ self.y2 = event.y
+
+ # Convert the viewer coordinates to video coordinates.
+ factor: float = 1 / self.video_to_viewer_factor()
+ x = min(self.x1, self.x2) * factor
+ y = min(self.y1, self.y2) * factor
+ w = abs(self.x2 - self.x1) * factor
+ h = abs(self.y2 - self.y1) * factor
+ if w and h:
+ # Apply the selected area to the object.
+ if not self.current_object:
+ self._create_empty_object()
+ position = self.pipeline.get_position()
+ self.object_manager.update_object_position(
+ self.current_object, position, (x, y, w, h))
+
+ # Allow tracking.
+ self.track_button.props.sensitive = True
+
+ self.__reset_selected_area()
+
+ def _create_empty_object(self):
+ """Generates a new object and adds it to the object_manager."""
+ index = 1 + self.object_manager.greatest_index()
+ object_id = uuid.uuid4().hex
+ name = _("Object {}").format(index)
+
+ self.current_object = object_id
+ self.object_manager.add_object(index, object_id, name)
+
+ self.tracked_objects_store.append(TrackedObjectItem(object_id, index, name))
+ last_row_index = self.tracked_objects_store.get_n_items() - 1
+ last_row = self.object_listbox.get_row_at_index(last_row_index)
+ self.object_listbox.select_row(last_row)
+
+ def __reset_selected_area(self):
+ self.x1, self.y1 = None, None
+ self.x2, self.y2 = None, None
+ self.drawing_area_width, self.drawing_area_height = None, None
+
+ @Gtk.Template.Callback()
+ def _drawing_area_enter_notify_event_cb(self, widget, event):
+ self.app.gui.get_window().set_cursor(Gdk.Cursor(Gdk.CursorType.CROSSHAIR))
+
+ @Gtk.Template.Callback()
+ def _drawing_area_leave_notify_event_cb(self, widget, event):
+ self.app.gui.get_window().set_cursor(NORMAL_CURSOR)
+
+ @Gtk.Template.Callback()
+ def _drawing_area_motion_notify_event_cb(self, widget, event):
+ if self.x1 is not None:
+ self.x2 = event.x
+ self.y2 = event.y
+ self.drawing_area.queue_draw()
+
+ @Gtk.Template.Callback()
+ def _drawing_area_draw_cb(self, drawing_area, cr):
+ """Handler responsible for drawing the selection rectangle."""
+ if self.x2 is not None:
+ # Draw the area being delimited by the user on the viewer.
+ x, y = self.x1, self.y1
+ w, h = self.x2 - self.x1, self.y2 - self.y1
+ elif self.current_object:
+ # Draw the area tracked previously for the current position.
+ position = self.pipeline.get_position(fails=False)
+ video_coords = self.object_manager.interpolate(self.current_object, position)
+ if not video_coords:
+ return
+
+ x, y, w, h = video_coords
+
+ # Translate from video coordinates to viewer coordinates.
+ factor: float = self.video_to_viewer_factor()
+ cr.scale(factor, factor)
+ else:
+ # Nothing to draw.
+ return
+
+ cr.set_operator(cairo.OPERATOR_OVER)
+
+ cr.set_source_rgba(1, 1, 0.6, 0.8) # Yellow
+ cr.rectangle(x, y, w, h)
+ cr.stroke()
+
+ cr.set_source_rgba(0.6, 0.6, 0.6, 0.5) # Light gray
+ cr.rectangle(x, y, w, h)
+ cr.fill()
+
+ def video_to_viewer_factor(self) -> float:
+ if self.source_width > self.source_height:
+ viewer_width = self.drawing_area.get_allocated_width()
+ return viewer_width / self.source_width
+ else:
+ viewer_height = self.drawing_area.get_allocated_height()
+ return viewer_height / self.source_height
+
+ @Gtk.Template.Callback()
+ def _add_object_button_clicked_cb(self, button):
+ # If no object is selected then the user will be able to
+ # delimit an area to create an object.
+ self.object_listbox.unselect_all()
+
+ @Gtk.Template.Callback()
+ def _remove_object_button_clicked_cb(self, button):
+ row = self.object_listbox.get_selected_row()
+ index = row.get_index()
+ tracked_object = self.tracked_objects_store.get_item(index)
+ self.tracked_objects_store.remove(index)
+
+ self.remove_object_button.props.sensitive = False
+ self.current_object = None
+
+ self.object_manager.remove_object(tracked_object.object_id)
+ self.seeker.clear_marks()
+
+ @Gtk.Template.Callback()
+ def _stop_track_button_clicked_cb(self, button):
+ self.tracker_pipeline.pause()
+ self.__stop_tracker()
+
+ def _setup(self, object_id: Optional[str]):
+ """Sets up the UI for creating or updating a tracked object."""
+ self.seeker.clear_marks()
+
+ self._setup_tracking_ui(started=False)
+
+ self.__reset_selected_area()
+
+ self.current_object = object_id
+ has_object = bool(object_id)
+ if has_object:
+ timed_data = self.object_manager.values[object_id]
+ if timed_data:
+ seek_pos, _area = timed_data[0]
+ self.seeker.add_mark(seek_pos, Gtk.PositionType.BOTTOM, None)
+ self.pipeline.simple_seek(seek_pos)
+
+ self.add_object_button.props.sensitive = has_object
+ self.remove_object_button.props.sensitive = has_object
+ # Hide the infobar by making it transparent. This way the left column
+ # of widgets has a stable width, as the infobar is the widest widget.
+ self.howto_add_infobar.props.opacity = 1 if not has_object else 0
+
+ # Object list box methods
+
+ def create_tracked_object_row_func(self, item: TrackedObjectItem) -> TrackedObjectRow:
+ return TrackedObjectRow(item.object_id, item.name)
+
+ @Gtk.Template.Callback()
+ def _listbox_selected_rows_changed_cb(self, listbox: Gtk.ListBox):
+ row: TrackedObjectRow = listbox.get_selected_row()
+ self._setup(row.object_id if row else None)
+
+ # Tracker methods
+
+ @Gtk.Template.Callback()
+ def _track_button_clicked_cb(self, button):
+ self._setup_tracking_ui(started=True)
+
+ # Build the object tracking pipeline.
+ algorithm = self.algorithm_combo_box.get_active()
+ self.start_pos = self.pipeline.get_position()
+ x, y, w, h = self.object_manager.interpolate(self.current_object, self.start_pos)
+ self.roi_data = {}
+ _pipeline = Gst.parse_launch(
+ "uridecodebin uri={} ! videoconvert ! \
+ cvtracker object-initial-x={} object-initial-y={} object-initial-width={} \
+ object-initial-height={} algorithm={} draw-rect=true ! tee name=t ! \
+ queue ! videoconvert ! gtksink name=gtksink t. \
+ ! fakesink name=sink signal-handoffs=TRUE"
+ .format(self.asset.props.id, int(x), int(y), int(w), int(h), algorithm))
+
+ self.seeker.add_mark(self.start_pos, Gtk.PositionType.BOTTOM, None)
+
+ # Connect to fakesink to get the tracking data.
+ fakesink = _pipeline.get_by_name("sink")
+ fakesink.connect("handoff", self.__tracker_handoff_cb, self.roi_data)
+
+ # Set up a widget to show the video as the object is being tracked.
+ video_sink = _pipeline.get_by_name("gtksink")
+ self.tracker_sink_widget = video_sink.props.widget
+ self.viewer_overlay.add_overlay(self.tracker_sink_widget)
+
+ # Create a high-level pipeline to get position updates.
+ self.tracker_pipeline = SimplePipeline(_pipeline)
+ self.tracker_pipeline.activate_position_listener(50)
+ self.tracker_pipeline.connect("position", self.__tracker_position_cb)
+
+ # Connect to the bus of the pipeline to find out when the stream ends.
+ bus = _pipeline.get_bus()
+ bus.connect("message", self.__tracker_bus_message_cb)
+ bus.add_signal_watch()
+
+ # Start the tracking pipeline.
+ self.tracker_pipeline.simple_seek(self.start_pos)
+ self.tracker_pipeline.play()
+
+ def _setup_tracking_ui(self, started: bool):
+ """Sets up the widgets depending on the specified tracking status."""
+ self.drawing_area.props.visible = not started
+ self.object_manager_box.props.sensitive = not started
+ self.algorithm_combo_box.props.sensitive = not started
+ self.viewer_buttons.props.sensitive = not started
+
+ self.track_button.props.visible = not started
+ self.track_button.props.sensitive = False
+ self.stop_button.props.visible = started
+
+ def __get_tracking_algorithms(self) -> Gtk.ListStore:
+ listmodel = Gtk.ListStore(str)
+ element = Gst.ElementFactory.make("cvtracker", "tracker")
+ properties = element.list_properties()
+ for prop in properties:
+ if prop.name == "algorithm":
+ for unused_key, algorithm in prop.enum_class.__enum_values__.items():
+ listmodel.append([algorithm.value_nick])
+ break
+
+ return listmodel
+
+ def __stop_tracker(self):
+ position = self.tracker_pipeline.get_position(fails=False)
+ self.pipeline.simple_seek(position)
+
+ self.log("Waiting for the tracker_pipeline to stop")
+ self.tracker_pipeline.connect("state-change", self.__pipeline_state_change_cb)
+ self.tracker_pipeline.stop()
+
+ self.object_manager.update_object(self.current_object, self.start_pos, self.roi_data)
+ self.start_pos = None
+ self.roi_data = None
+
+ def __pipeline_state_change_cb(self, pipeline, state, prev_state):
+ if state != Gst.State.READY:
+ return
+
+ self.log("Tracker_pipeline stopped")
+ self.tracker_pipeline.disconnect_by_func(self.__pipeline_state_change_cb)
+ self.tracker_pipeline.release()
+ self.tracker_pipeline = None
+
+ self.viewer_overlay.remove(self.tracker_sink_widget)
+
+ self._setup_tracking_ui(started=False)
+
+ def __tracker_position_cb(self, pipeline, position):
+ if position >= self.start_pos:
+ if not self.tracker_sink_widget.props.visible:
+ self.tracker_sink_widget.show()
+
+ self.__update_adjustment(position)
+
+ def __tracker_bus_message_cb(self, bus, message):
+ if message.type == Gst.MessageType.EOS:
+ self.__stop_tracker()
+
+ def __tracker_handoff_cb(self, element, buffer, pad, roi_data: Dict[int, Tuple[float, float, float, float]]):
+ video_roi = GstVideo.buffer_get_video_region_of_interest_meta_id(buffer, 0)
+ if video_roi:
+ roi_data[buffer.pts] = (video_roi.x, video_roi.y, video_roi.w, video_roi.h)
+ else:
+ self.log("lost tracker at: %s", buffer.pts / Gst.SECOND)
+
+
+class TrackerPerspective(Perspective):
+ """Pitivi's Tracker Perspective.
+
+ Allows the user to track multiple objects
+ and manually correct the obtained track data.
+
+ Attributes:
+ app (Pitivi): The app.
+ asset (GES.UriClipAsset): Asset to be used.
+ """
+
+ def __init__(self, app, asset):
+ super().__init__()
+ self.app = app
+ self.asset = asset
+
+ def __create_headerbar(self):
+ headerbar = Gtk.HeaderBar()
+ headerbar.set_show_close_button(False)
+
+ back_button = Gtk.Button.new_from_icon_name(
+ "go-previous-symbolic", Gtk.IconSize.SMALL_TOOLBAR)
+ back_button.set_always_show_image(True)
+ back_button.set_tooltip_text(_("Go back"))
+ back_button.connect("clicked", self.__back_button_clicked_cb)
+ back_button.set_margin_right(4 * PADDING)
+ headerbar.pack_start(back_button)
+ headerbar.props.title = os.path.basename(self.asset.props.id)
+ headerbar.show_all()
+
+ return headerbar
+
+ def setup_ui(self):
+ self.toplevel_widget = ToplevelWidget(self.app, self.asset)
+ self.headerbar = self.__create_headerbar()
+
+ def __back_button_clicked_cb(self, button):
+ self.toplevel_widget.object_manager.save()
+ self.toplevel_widget.pipeline.release()
+ self.app.gui.show_perspective(self.app.gui.editor)
+
+ def refresh(self):
+ """Refreshes the perspective."""
+ self.toplevel_widget.play_pause_button.grab_focus()
diff --git a/pitivi/utils/pipeline.py b/pitivi/utils/pipeline.py
index 74223f229..354e8c909 100644
--- a/pitivi/utils/pipeline.py
+++ b/pitivi/utils/pipeline.py
@@ -57,7 +57,7 @@ class SimplePipeline(GObject.Object, Loggable):
- State changes
- Position seeking
- Position querying
- - Along with an periodic callback (optional)
+ - Along with a periodic callback (optional)
Signals:
state-change: The state of the pipeline changed.
diff --git a/pitivi/utils/ui.py b/pitivi/utils/ui.py
index 7ccb5e80e..4303cf793 100644
--- a/pitivi/utils/ui.py
+++ b/pitivi/utils/ui.py
@@ -75,6 +75,14 @@ TOUCH_INPUT_SOURCES = (Gdk.InputSource.TOUCHPAD,
Gdk.InputSource.TRACKPOINT,
Gdk.InputSource.TABLET_PAD)
+CURSORS = {
+ GES.Edge.EDGE_START: Gdk.Cursor.new(Gdk.CursorType.LEFT_SIDE),
+ GES.Edge.EDGE_END: Gdk.Cursor.new(Gdk.CursorType.RIGHT_SIDE)
+}
+
+NORMAL_CURSOR = Gdk.Cursor.new(Gdk.CursorType.LEFT_PTR)
+DRAG_CURSOR = Gdk.Cursor.new(Gdk.CursorType.HAND1)
+
def get_month_format_string():
"""Returns the appropriate format string for month name in time.strftime() function."""
diff --git a/pitivi/viewer/viewer.py b/pitivi/viewer/viewer.py
index fdf6a7129..042bddb66 100644
--- a/pitivi/viewer/viewer.py
+++ b/pitivi/viewer/viewer.py
@@ -665,6 +665,7 @@ class ViewerContainer(Gtk.Box, Loggable):
self.warning("State change reported for previous trim preview pipeline")
trim_pipeline.disconnect_by_func(self._state_change_cb)
return
+
# First the pipeline goes from READY to PAUSED, and then it goes
# from PAUSED to PAUSED, and this is a good moment.
if prev_state == Gst.State.PAUSED and state == Gst.State.PAUSED:
diff --git a/tests/test_trackerperspective.py b/tests/test_trackerperspective.py
new file mode 100644
index 000000000..2d5e8905a
--- /dev/null
+++ b/tests/test_trackerperspective.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Pitivi video editor
+# Copyright (c) 2022, Alex Băluț
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program; if not, see .
+"""Tests for the pitivi.trackerperspective module."""
+# pylint: disable=protected-access
+from gi.repository import GES
+
+from pitivi.trackerperspective import ObjectManager
+from tests import common
+
+
+class TestObjectManager(common.TestCase):
+ """Tests for the ObjectManager class."""
+
+ def test_load_save(self):
+ asset = GES.UriClipAsset.request_sync(common.get_sample_uri("tears_of_steel.webm"))
+ object_manager1 = ObjectManager(asset)
+ object_manager1.add_object(1, "object1", "Object 1")
+ object_manager1.update_object_position("object1", 100, (10, 20, 30, 40))
+ object_manager1.save()
+
+ object_manager2 = ObjectManager(asset)
+ self.assertListEqual(object_manager2.objects, [(1, "object1", "Object 1")])
+ self.assertDictEqual(object_manager2.values, {"object1": [(100, (10, 20, 30, 40))]})
+
+ object_manager2.add_object(2, "object2", "Object 2")
+ object_manager2.update_object_position("object2", 200, (20, 30, 40, 50))
+ object_manager2.save()
+
+ object_manager3 = ObjectManager(asset)
+ self.assertListEqual(object_manager3.objects, [(1, "object1", "Object 1"), (2, "object2", "Object 2")])
+ self.assertDictEqual(object_manager2.values, {"object1": [(100, (10, 20, 30, 40))],
+ "object2": [(200, (20, 30, 40, 50))]})
+
+ def test_update_object_position(self):
+ asset = GES.UriClipAsset.request_sync(common.get_sample_uri("tears_of_steel.webm"))
+ object_manager = ObjectManager(asset)
+ object_manager.add_object(1, "object1", "Object 1")
+ object_manager.update_object_position("object1", 200, (20, 30, 40, 50))
+ object_manager.update_object_position("object1", 100, (10, 20, 30, 40))
+ object_manager.update_object_position("object1", 300, (30, 40, 50, 60))
+
+ self.assertDictEqual(object_manager.values, {"object1": [(100, (10, 20, 30, 40)),
+ (200, (20, 30, 40, 50)),
+ (300, (30, 40, 50, 60))]})
+
+ def test_interpolate(self):
+ asset = GES.UriClipAsset.request_sync(common.get_sample_uri("tears_of_steel.webm"))
+ object_manager = ObjectManager(asset)
+ object_manager.add_object(1, "object1", "Object 1")
+ object_manager.update_object_position("object1", 200, (20, 30, 40, 50))
+ object_manager.update_object_position("object1", 100, (10, 20, 30, 40))
+ object_manager.update_object_position("object1", 300, (30, 40, 50, 60))
+
+ self.assertTupleEqual(object_manager.interpolate("object1", 99), (10, 20, 30, 40))
+ self.assertTupleEqual(object_manager.interpolate("object1", 100), (10, 20, 30, 40))
+ self.assertTupleEqual(object_manager.interpolate("object1", 150), (15, 25, 35, 45))
+ self.assertTupleEqual(object_manager.interpolate("object1", 200), (20, 30, 40, 50))
+ self.assertTupleEqual(object_manager.interpolate("object1", 275), (27.5, 37.5, 47.5, 57.5))
+ self.assertTupleEqual(object_manager.interpolate("object1", 300), (30, 40, 50, 60))
+ self.assertTupleEqual(object_manager.interpolate("object1", 301), (30, 40, 50, 60))
--
GitLab
From c6c41c31285d123b90934d4b29514f934217cd4a Mon Sep 17 00:00:00 2001
From: Vivek R <123vivekr@gmail.com>
Date: Sat, 8 Aug 2020 10:50:53 +0530
Subject: [PATCH 2/3] trackerperspective: Allow covering tracked object to hide
it
This adds a feature to apply a cover effect to a tracked object inside a
video clip. The effect is a videotestsrc with a solid foreground color.
Fixes #1942
---
data/ui/customwidgets/pitivi:object_effect.ui | 48 +++++++
pitivi/clipproperties.py | 65 ++++-----
pitivi/dialogs/prefs.py | 2 +-
pitivi/effects.py | 23 +++-
pitivi/greeterperspective.py | 2 +-
pitivi/trackerperspective.py | 123 +++++++++++++++++-
pitivi/utils/custom_effect_widgets.py | 73 ++++++++++-
pitivi/utils/ui.py | 26 ++--
tests/test_trackerperspective.py | 45 +++++++
9 files changed, 349 insertions(+), 58 deletions(-)
create mode 100644 data/ui/customwidgets/pitivi:object_effect.ui
diff --git a/data/ui/customwidgets/pitivi:object_effect.ui b/data/ui/customwidgets/pitivi:object_effect.ui
new file mode 100644
index 000000000..8888a91a6
--- /dev/null
+++ b/data/ui/customwidgets/pitivi:object_effect.ui
@@ -0,0 +1,48 @@
+
+
+
+
+
+ True
+ False
+ start
+ 12
+ 12
+ 12
+ 12
+ 10
+
+
+ True
+ False
+ start
+ Color:
+
+
+ True
+ True
+ 0
+
+
+
+
+ False
+ True
+ True
+ True
+ start
+ 2
+ True
+ True
+ Pick the clip color
+ rgb(255,255,255)
+
+
+
+ False
+ True
+ 1
+
+
+
+
diff --git a/pitivi/clipproperties.py b/pitivi/clipproperties.py
index 05e5a8f53..cb906bc3d 100644
--- a/pitivi/clipproperties.py
+++ b/pitivi/clipproperties.py
@@ -45,9 +45,10 @@ from pitivi.configure import in_devel
from pitivi.effects import EffectsPopover
from pitivi.effects import EffectsPropertiesManager
from pitivi.effects import HIDDEN_EFFECTS
-from pitivi.trackerperspective import TrackerPerspective
+from pitivi.trackerperspective import CoverObjectPopover
from pitivi.undo.timeline import CommitTimelineFinalizingAction
-from pitivi.utils.custom_effect_widgets import setup_custom_effect_widgets
+from pitivi.utils.custom_effect_widgets import create_custom_prop_widget_cb
+from pitivi.utils.custom_effect_widgets import create_custom_widget_cb
from pitivi.utils.loggable import Loggable
from pitivi.utils.misc import disconnect_all_by_func
from pitivi.utils.pipeline import PipelineError
@@ -573,7 +574,9 @@ class EffectProperties(Gtk.Expander, Loggable):
self.clip = None
self.effects_properties_manager = EffectsPropertiesManager(app)
- setup_custom_effect_widgets(self.effects_properties_manager)
+ # Set up the effects manager to be able to create custom UI.
+ self.effects_properties_manager.connect("create_widget", create_custom_widget_cb)
+ self.effects_properties_manager.connect("create_property_widget", create_custom_prop_widget_cb)
self.drag_lines_pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
os.path.join(get_pixmap_dir(), "grip-lines-solid.svg"),
@@ -598,11 +601,11 @@ class EffectProperties(Gtk.Expander, Loggable):
self.object_tracker_box = Gtk.ButtonBox()
self.object_tracker_box.props.halign = Gtk.Align.CENTER
+ self.cover_popover: Optional[Gtk.Popover] = None
+ self.cover_object_button: Optional[Gtk.MenuButton] = None
if "cvtracker" not in MISSING_SOFT_DEPS:
- self.track_object_button = Gtk.Button(_("Track Object"))
- self.track_object_button.connect("clicked", self.__track_object_button_clicked_cb)
- self.track_object_button.props.halign = Gtk.Align.CENTER
- self.object_tracker_box.pack_start(self.track_object_button, False, False, 0)
+ self.cover_object_button = Gtk.MenuButton(_("Cover Object"))
+ self.object_tracker_box.pack_start(self.cover_object_button, False, False, 0)
self.drag_dest_set(Gtk.DestDefaults.DROP, [EFFECT_TARGET_ENTRY],
Gdk.DragAction.COPY)
@@ -618,40 +621,36 @@ class EffectProperties(Gtk.Expander, Loggable):
self.connect("drag-leave", self._drag_leave_cb)
self.connect("drag-data-received", self._drag_data_received_cb)
- self.add_effect_button.connect("toggled", self._add_effect_button_cb)
+ self.add_effect_button.connect("toggled", self._add_effect_button_toggled_cb)
+ if self.cover_object_button:
+ self.cover_object_button.connect("toggled", self._cover_object_button_toggled_cb)
self.show_all()
- def __track_object_button_clicked_cb(self, button):
- tracker = TrackerPerspective(self.app, self.clip.asset)
- self.app.project_manager.current_project.pipeline.pause()
- tracker.setup_ui()
- self.app.gui.show_perspective(tracker)
-
- def _add_effect_button_cb(self, button):
+ def _add_effect_button_toggled_cb(self, button):
# MenuButton interacts directly with the popover, bypassing our subclassed method
if button.props.active:
self.effect_popover.search_entry.set_text("")
+ def _cover_object_button_toggled_cb(self, button):
+ if button.props.active:
+ self.cover_popover.update_object_list()
+
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)
- row_drag_icon = Gtk.Image.new_from_pixbuf(self.drag_lines_pixbuf)
-
toggle = Gtk.CheckButton()
toggle.props.active = effect.props.active
+ effect_info = self.app.effects.get_info(effect)
effect_label = Gtk.Label(effect_info.human_name)
effect_label.set_tooltip_text(effect_info.description)
# Set up revealer + expander
- effect_config_ui = self.effects_properties_manager.get_effect_configuration_ui(
- effect)
+ effect_config_ui = self.effects_properties_manager.get_effect_configuration_ui(effect)
config_ui_revealer = Gtk.Revealer()
config_ui_revealer.add(effect_config_ui)
@@ -668,6 +667,7 @@ class EffectProperties(Gtk.Expander, Loggable):
remove_effect_button.props.margin_right = PADDING
row_widgets_box = Gtk.Box()
+ row_drag_icon = Gtk.Image.new_from_pixbuf(self.drag_lines_pixbuf)
row_widgets_box.pack_start(row_drag_icon, False, False, PADDING)
row_widgets_box.pack_start(toggle, False, False, PADDING)
row_widgets_box.pack_start(expander, True, True, PADDING)
@@ -769,6 +769,7 @@ class EffectProperties(Gtk.Expander, Loggable):
self.clip = clip
if self.clip:
+ cover_object_button_show = False
self.clip.connect("child-added", self._track_element_added_cb)
self.clip.connect("child-removed", self._track_element_removed_cb)
for track_element in self.clip.get_children(recursive=True):
@@ -777,12 +778,15 @@ class EffectProperties(Gtk.Expander, Loggable):
continue
self._connect_to_track_element(track_element)
if isinstance(track_element, GES.VideoUriSource) and not clip.asset.is_image():
- self.track_object_button.show()
-
+ cover_object_button_show = True
+ if self.cover_object_button:
+ self.cover_object_button.set_visible(cover_object_button_show)
+ if cover_object_button_show:
+ self.cover_popover = CoverObjectPopover(self.app, self.clip)
+ self.cover_object_button.set_popover(self.cover_popover)
self._update_listbox()
- self.show()
- else:
- self.hide()
+
+ self.props.visible = bool(self.clip)
def _track_element_added_cb(self, unused_clip, track_element):
if isinstance(track_element, GES.BaseEffect):
@@ -831,7 +835,7 @@ class EffectProperties(Gtk.Expander, Loggable):
def _drag_data_get_cb(self, eventbox, drag_context, selection_data, unused_info, unused_timestamp):
row = eventbox.get_parent()
- effect_info = self.app.effects.get_info(row.effect.props.bin_description)
+ effect_info = self.app.effects.get_info(row.effect)
effect_name = effect_info.human_name
data = bytes(effect_name, "UTF-8")
@@ -1116,9 +1120,10 @@ class TransformationProperties(Gtk.Expander, Loggable):
control_source = GstController.InterpolationControlSource()
control_source.props.mode = GstController.InterpolationMode.LINEAR
self.__own_bindings_change = True
- self.source.set_control_source(
- control_source, prop, "direct-absolute")
- self.__own_bindings_change = False
+ try:
+ self.source.set_control_source(control_source, prop, "direct-absolute")
+ finally:
+ self.__own_bindings_change = False
self.__set_default_keyframes_values(control_source, prop)
binding = self.source.get_control_binding(prop)
diff --git a/pitivi/dialogs/prefs.py b/pitivi/dialogs/prefs.py
index de7217656..2f673e2ac 100644
--- a/pitivi/dialogs/prefs.py
+++ b/pitivi/dialogs/prefs.py
@@ -402,7 +402,7 @@ class PreferencesDialog(Loggable):
self.content_box.bind_model(self.list_store, self._create_widget_func, None)
self.content_box.set_header_func(self._add_header_func, None)
- self.content_box.connect("row_activated", self.__row_activated_cb)
+ self.content_box.connect("row-activated", self.__row_activated_cb)
self.content_box.set_selection_mode(Gtk.SelectionMode.NONE)
self.content_box.props.margin = PADDING * 3
self.content_box.props.halign = Gtk.Align.CENTER
diff --git a/pitivi/effects.py b/pitivi/effects.py
index 7c71def48..d61ae68b1 100644
--- a/pitivi/effects.py
+++ b/pitivi/effects.py
@@ -32,6 +32,8 @@ import subprocess
import sys
import threading
from gettext import gettext as _
+from typing import Optional
+from typing import Union
import cairo
from gi.repository import Gdk
@@ -45,6 +47,8 @@ from gi.repository import Gtk
from pitivi.configure import get_pixmap_dir
from pitivi.configure import get_ui_dir
from pitivi.settings import GlobalSettings
+from pitivi.trackerperspective import EFFECT_TRACKED_OBJECT_ID_META
+from pitivi.trackerperspective import EFFECT_TRACKED_OBJECT_NAME_META
from pitivi.utils.loggable import Loggable
from pitivi.utils.ui import disable_scroll
from pitivi.utils.ui import EFFECT_TARGET_ENTRY
@@ -172,7 +176,6 @@ class EffectInfo:
def __init__(self, effect_name, media_type, categories,
human_name, description):
- object.__init__(self)
self.effect_name = effect_name
self.media_type = media_type
self.categories = categories
@@ -308,15 +311,29 @@ class EffectsManager(Loggable):
self.error("Can not use GL effects: %s", e)
HIDDEN_EFFECTS.extend(self.gl_effects)
- def get_info(self, bin_description):
+ def get_info(self, effect: Union[str, GES.Effect]) -> Optional[EffectInfo]:
"""Gets the info for an effect which can be applied.
Args:
- bin_description (str): The bin_description defining the effect.
+ effect: The effect itself or the bin_description defining it.
Returns:
EffectInfo: The info corresponding to the name, or None.
"""
+ if isinstance(effect, GES.Effect):
+ tracked_object_id = effect.get_string(EFFECT_TRACKED_OBJECT_ID_META)
+ if tracked_object_id:
+ tracked_object_name = effect.get_string(EFFECT_TRACKED_OBJECT_NAME_META)
+ # Translators: How the video effect which covers/hides a
+ # tracked object is listed. The {} is entered by the user
+ # and denotes the tracked object.
+ human_name = _("{} cover").format(tracked_object_name)
+ description = _("Object cover effect")
+ return EffectInfo(None, None, None, human_name, description)
+ bin_description = effect.props.bin_description
+ else:
+ bin_description = effect
+
name = EffectInfo.name_from_bin_description(bin_description)
return self._effects.get(name)
diff --git a/pitivi/greeterperspective.py b/pitivi/greeterperspective.py
index cb1b63d72..b822460d8 100644
--- a/pitivi/greeterperspective.py
+++ b/pitivi/greeterperspective.py
@@ -145,7 +145,7 @@ class GreeterPerspective(Perspective):
self.__recent_projects_listbox = builder.get_object("recent_projects_listbox")
self.__recent_projects_listbox.set_selection_mode(Gtk.SelectionMode.NONE)
self.__recent_projects_listbox.connect(
- "row_activated", self.__projects_row_activated_cb)
+ "row-activated", self.__projects_row_activated_cb)
self.__recent_projects_listbox.connect(
"button-press-event", self.__projects_button_press_cb)
diff --git a/pitivi/trackerperspective.py b/pitivi/trackerperspective.py
index 39c18dd3d..b12bcaf23 100644
--- a/pitivi/trackerperspective.py
+++ b/pitivi/trackerperspective.py
@@ -32,6 +32,7 @@ from gi.repository import GES
from gi.repository import Gio
from gi.repository import GObject
from gi.repository import Gst
+from gi.repository import GstController
from gi.repository import GstVideo
from gi.repository import Gtk
@@ -48,6 +49,11 @@ from pitivi.utils.ui import SPACING
# The meta of an Asset holding all the tracked objects data version 1.
ASSET_TRACKED_OBJECTS_META = "pitivi::tracker_data::1"
+# The meta of an Effect holding the object_id of the tracked object.
+EFFECT_TRACKED_OBJECT_ID_META = "pitivi:tracked_object_id"
+# The meta of an Effect holding the name of the tracked object.
+EFFECT_TRACKED_OBJECT_NAME_META = "pitivi:tracked_object_name"
+
# TODO: Replace with bisect.bisect_left when we use Python 3.10.
def bisect_left(values, val, key):
@@ -475,7 +481,7 @@ class ToplevelWidget(Gtk.Box, Loggable):
def _remove_object_button_clicked_cb(self, button):
row = self.object_listbox.get_selected_row()
index = row.get_index()
- tracked_object = self.tracked_objects_store.get_item(index)
+ tracked_object: TrackedObjectItem = self.tracked_objects_store.get_item(index)
self.tracked_objects_store.remove(index)
self.remove_object_button.props.sensitive = False
@@ -677,3 +683,118 @@ class TrackerPerspective(Perspective):
def refresh(self):
"""Refreshes the perspective."""
self.toplevel_widget.play_pause_button.grab_focus()
+
+
+class CoverObjectPopover(Gtk.Popover, Loggable):
+ """Popover for selecting an object to cover."""
+
+ # The representation of the effect providing the cover.
+ _EFFECT_PIPELINE = "video videotestsrc pattern=solid-color foreground-color=0xff000000 ! framepositioner name=positioner ! gescompositor"
+
+ def __init__(self, app, clip: GES.Clip):
+ Gtk.Popover.__init__(self)
+ Loggable.__init__(self)
+
+ self.app = app
+
+ self.clip: GES.Clip = clip
+ self.object_manager: Optional[ObjectManager] = None
+
+ self.listbox = Gtk.ListBox()
+ self.listbox.connect("row-activated", self.__row_activated_cb)
+
+ self.scroll_window = Gtk.ScrolledWindow()
+ self.scroll_window.add(self.listbox)
+ self.scroll_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ self.scroll_window.props.max_content_height = 350
+ self.scroll_window.props.propagate_natural_height = True
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin=PADDING)
+ vbox.pack_start(self.scroll_window, True, True, 0)
+ vbox.show_all()
+
+ self.add(vbox)
+
+ def update_object_list(self):
+ """Updates the list of not yet covered objects."""
+ self.object_manager = ObjectManager(self.clip.asset)
+
+ for row in self.listbox.get_children():
+ self.listbox.remove(row)
+
+ # Check which tracked objects have already been covered.
+ covered_objects = []
+ for effect in self.clip.get_top_effects():
+ tracked_object_id = effect.get_string(EFFECT_TRACKED_OBJECT_ID_META)
+ if tracked_object_id:
+ covered_objects.append(tracked_object_id)
+
+ # Allow selecting the not-yet-covered objects.
+ for _index, object_id, name in self.object_manager.objects:
+ if object_id not in covered_objects:
+ self.listbox.add(TrackedObjectRow(object_id, name))
+
+ # Allow tracking new objects.
+ button_row = Gtk.ListBoxRow(selectable=False)
+ track_objects_button = Gtk.Button(_("Track objects"))
+ track_objects_button.connect("clicked", self.__track_objects_button_clicked_cb)
+ button_row.add(track_objects_button)
+ self.listbox.add(button_row)
+
+ self.listbox.show_all()
+
+ def __row_activated_cb(self, listbox: Gtk.ListBox, row: TrackedObjectRow):
+ self._create_effect(row.object_id, row.name)
+
+ self.popdown()
+
+ def __effect_control_binding_added_cb(self, track_element, binding, object_id):
+ control_source = binding.props.control_source
+ timed_data = self.object_manager.values[object_id]
+ for timestamp, (x, y, w, h) in timed_data:
+ if binding.name == "posx":
+ value = x
+ elif binding.name == "posy":
+ value = y
+ elif binding.name == "width":
+ value = w
+ elif binding.name == "height":
+ value = h
+ else:
+ break
+
+ control_source.set(timestamp, value)
+
+ def __clip_child_added_cb(self, clip, track_element, object_id):
+ if not isinstance(track_element, GES.Effect):
+ return
+
+ clip.disconnect_by_func(self.__clip_child_added_cb)
+
+ track_element.connect("control-binding-added",
+ self.__effect_control_binding_added_cb,
+ object_id)
+ try:
+ for prop in ("posx", "posy", "width", "height"):
+ control_source = GstController.InterpolationControlSource()
+ control_source.props.mode = GstController.InterpolationMode.NONE
+ track_element.set_control_source(control_source, prop, "direct-absolute")
+ finally:
+ track_element.disconnect_by_func(self.__effect_control_binding_added_cb)
+
+ def _create_effect(self, object_id: str, name: str):
+ effect = GES.Effect.new(self._EFFECT_PIPELINE)
+ effect.register_meta_string(GES.MetaFlag.READABLE, EFFECT_TRACKED_OBJECT_ID_META, object_id)
+ effect.register_meta_string(GES.MetaFlag.READABLE, EFFECT_TRACKED_OBJECT_NAME_META, name)
+
+ self.log("Waiting for effect to be added to the clip")
+ self.clip.connect("child-added", self.__clip_child_added_cb, object_id)
+ self.clip.add_top_effect(effect, 0)
+
+ self.app.project_manager.current_project.pipeline.commit_timeline()
+
+ def __track_objects_button_clicked_cb(self, button):
+ tracker = TrackerPerspective(self.app, self.clip.asset)
+ self.app.project_manager.current_project.pipeline.pause()
+ tracker.setup_ui()
+ self.app.gui.show_perspective(tracker)
diff --git a/pitivi/utils/custom_effect_widgets.py b/pitivi/utils/custom_effect_widgets.py
index 44b5a60ed..5c2e27157 100644
--- a/pitivi/utils/custom_effect_widgets.py
+++ b/pitivi/utils/custom_effect_widgets.py
@@ -23,6 +23,7 @@ from gi.repository import Gdk
from gi.repository import Gtk
from pitivi import configure
+from pitivi.trackerperspective import EFFECT_TRACKED_OBJECT_ID_META
from pitivi.utils.loggable import Loggable
from pitivi.utils.ui import create_model
from pitivi.utils.widgets import ColorPickerButton
@@ -31,12 +32,6 @@ from pitivi.utils.widgets import ColorPickerButton
CUSTOM_WIDGETS_DIR = os.path.join(configure.get_ui_dir(), "customwidgets")
-def setup_custom_effect_widgets(effect_prop_manager):
- """Sets up the specified effects manager to be able to create custom UI."""
- effect_prop_manager.connect("create_widget", create_custom_widget_cb)
- effect_prop_manager.connect("create_property_widget", create_custom_prop_widget_cb)
-
-
def setup_from_ui_file(element_setting_widget, path):
"""Creates and connects the UI for a widget."""
# Load the ui file using builder
@@ -59,6 +54,11 @@ def create_custom_prop_widget_cb(unused_effect_prop_manager, effect_widget, effe
def create_custom_widget_cb(effect_prop_manager, effect_widget, effect):
"""Creates custom effect UI."""
+ tracked_object_id = effect.get_string(EFFECT_TRACKED_OBJECT_ID_META)
+ if tracked_object_id:
+ widget = object_cover_effect_widget(effect_prop_manager, effect_widget, effect)
+ return widget
+
effect_name = effect.get_property("bin-description")
path = os.path.join(CUSTOM_WIDGETS_DIR, effect_name + ".ui")
@@ -83,6 +83,67 @@ def create_custom_widget_cb(effect_prop_manager, effect_widget, effect):
return widget
+def object_cover_effect_widget(effect_prop_manager, element_setting_widget, element):
+ """Creates the UI for the `Object cover` effect."""
+ builder = setup_from_ui_file(element_setting_widget, os.path.join(CUSTOM_WIDGETS_DIR, "pitivi:object_effect.ui"))
+ base_table = builder.get_object("base_table")
+
+ def set_foreground_color(color):
+ from pitivi.undo.timeline import CommitTimelineFinalizingAction
+ pipeline = effect_prop_manager.app.project_manager.current_project.pipeline
+ action_log = effect_prop_manager.app.action_log
+ with action_log.started("Effect property change",
+ finalizing_action=CommitTimelineFinalizingAction(pipeline),
+ toplevel=True):
+ element.set_child_property("foreground-color", color)
+
+ def color_picker_value_changed_cb(widget: ColorPickerButton):
+ """Handles the selection of a color with the color picker."""
+ argb = widget.calculate_argb()
+ set_foreground_color(argb)
+
+ color_picker_button = ColorPickerButton()
+ base_table.add(color_picker_button)
+ handler_value_changed = color_picker_button.connect("value-changed", color_picker_value_changed_cb)
+
+ def color_button_color_set_cb(button: Gtk.ColorButton):
+ """Handles the selection of a color with the color button."""
+ color = button.get_rgba()
+ red = int(color.red * 255)
+ green = int(color.green * 255)
+ blue = int(color.blue * 255)
+ argb = (0xFF << 24) + (red << 16) + (green << 8) + blue
+ set_foreground_color(argb)
+
+ color_button = builder.get_object("color_button")
+ handler_color_set = color_button.connect("color-set", color_button_color_set_cb)
+
+ def update_ui():
+ res, argb = element.get_child_property("foreground-color")
+ assert res
+ color = Gdk.RGBA()
+ color.red = ((argb >> 16) & 0xFF) / 255
+ color.green = ((argb >> 8) & 0xFF) / 255
+ color.blue = ((argb >> 0) & 0xFF) / 255
+ color.alpha = ((argb >> 24) & 0xFF) / 255
+ color_button.set_rgba(color)
+
+ update_ui()
+
+ def notify_foreground_color_cb(self, element, param_spec):
+ color_picker_button.handler_block(handler_value_changed)
+ color_button.handler_block(handler_color_set)
+ try:
+ update_ui()
+ finally:
+ color_picker_button.handler_unblock(handler_value_changed)
+ color_button.handler_block(handler_color_set)
+
+ element.connect("notify::foreground-color", notify_foreground_color_cb)
+
+ return base_table
+
+
def create_alpha_widget(effect_prop_manager, element_setting_widget, element):
"""Creates the UI for the `alpha` effect."""
builder = setup_from_ui_file(element_setting_widget, os.path.join(CUSTOM_WIDGETS_DIR, "alpha.ui"))
diff --git a/pitivi/utils/ui.py b/pitivi/utils/ui.py
index 4303cf793..cf591ddbe 100644
--- a/pitivi/utils/ui.py
+++ b/pitivi/utils/ui.py
@@ -437,20 +437,18 @@ def gtk_style_context_get_color(context, state):
return color
-def argb_to_gdk_rgba(color_int):
- return Gdk.RGBA(color_int / 256 ** 2 % 256 / 255.,
- color_int / 256 ** 1 % 256 / 255.,
- color_int / 256 ** 0 % 256 / 255.,
- color_int / 256 ** 3 % 256 / 255.)
+def argb_to_gdk_rgba(argb: int) -> Gdk.RGBA:
+ return Gdk.RGBA(((argb >> 16) & 0xFF) / 255,
+ ((argb >> 8) & 0xFF) / 255,
+ ((argb >> 0) & 0xFF) / 255,
+ ((argb >> 24) & 0xFF) / 255)
-def gdk_rgba_to_argb(color):
- color_int = 0
- color_int += int(color.alpha * 255) * 256 ** 3
- color_int += int(color.red * 255) * 256 ** 2
- color_int += int(color.green * 255) * 256 ** 1
- color_int += int(color.blue * 255) * 256 ** 0
- return color_int
+def gdk_rgba_to_argb(color: Gdk.RGBA) -> int:
+ return ((int(color.alpha * 255) << 24) +
+ (int(color.red * 255) << 16) +
+ (int(color.green * 255) << 8) +
+ int(color.blue * 255))
def pack_color_32(red, green, blue, alpha=0xFFFF):
@@ -497,10 +495,6 @@ def unpack_color_64(value):
return red, green, blue, alpha
-def hex_to_rgb(value):
- return tuple(float(int(value[i:i + 2], 16)) / 255.0 for i in range(0, 6, 2))
-
-
def set_cairo_color(context, color):
if isinstance(color, Gdk.RGBA):
cairo_color = (float(color.red), float(color.green), float(color.blue))
diff --git a/tests/test_trackerperspective.py b/tests/test_trackerperspective.py
index 2d5e8905a..2a0af20a6 100644
--- a/tests/test_trackerperspective.py
+++ b/tests/test_trackerperspective.py
@@ -16,12 +16,57 @@
# License along with this program; if not, see .
"""Tests for the pitivi.trackerperspective module."""
# pylint: disable=protected-access
+from unittest import skipUnless
+
from gi.repository import GES
+from pitivi.check import MISSING_SOFT_DEPS
from pitivi.trackerperspective import ObjectManager
from tests import common
+class TestCoverObjectPopover(common.TestCase):
+ """Tests for the CoverObjectPopover class."""
+
+ @skipUnless("cvtracker" not in MISSING_SOFT_DEPS, "cvtracker element missing")
+ @common.setup_project_with_clips(assets_names=["tears_of_steel.webm"])
+ @common.setup_clipproperties
+ def test_cover(self):
+ clip, = self.layer.get_clips()
+ self.click_clip(clip, expect_selected=True)
+
+ expander = self.clipproperties.effect_expander
+ expander.cover_object_button.clicked()
+ self.assertTrue(expander.cover_popover.props.visible)
+ # Only one row containing the Track Objects button should exist.
+ self.assertEqual(len(expander.cover_popover.listbox.get_children()), 1)
+
+ expander.cover_object_button.clicked()
+ self.assertFalse(expander.cover_popover.props.visible)
+
+ object_manager = ObjectManager(clip.asset)
+ object_manager.add_object(1, "object1", "Object 1")
+ object_manager.update_object_position("object1", 0, (10, 20, 30, 40))
+ object_manager.add_object(2, "object2", "Object 2")
+ object_manager.update_object_position("object2", 1, (20, 30, 40, 50))
+ object_manager.save()
+
+ expander.cover_object_button.clicked()
+ self.assertTrue(expander.cover_popover.props.visible)
+ # Two rows for two object and one for Track Objects.
+ self.assertEqual(len(expander.cover_popover.listbox.get_children()), 3)
+
+ self.assertEqual(len(clip.get_top_effects()), 0)
+ expander.cover_popover.listbox.get_row_at_index(0).emit("activate")
+ self.assertFalse(expander.cover_popover.props.visible)
+ self.assertEqual(len(clip.get_top_effects()), 1)
+
+ expander.cover_object_button.clicked()
+ self.assertTrue(expander.cover_popover.props.visible)
+ # One row for the uncovered object and one for the Track Objects button.
+ self.assertEqual(len(expander.cover_popover.listbox.get_children()), 2)
+
+
class TestObjectManager(common.TestCase):
"""Tests for the ObjectManager class."""
--
GitLab
From 67311317e7344f9e0b3508e18b1061f4c72b8e87 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexandru=20B=C4=83lu=C8=9B?=
Date: Tue, 3 May 2022 08:57:55 +0200
Subject: [PATCH 3/3] undo: Fix cyclic import
```
Cyclic import (pitivi.undo.markers -> pitivi.undo.undo) (cyclic-import)
```
---
pitivi/undo/markers.py | 66 +++++++++++++++++++++++++++++++++++++++--
pitivi/undo/project.py | 2 +-
pitivi/undo/timeline.py | 2 +-
pitivi/undo/undo.py | 59 ------------------------------------
4 files changed, 65 insertions(+), 64 deletions(-)
diff --git a/pitivi/undo/markers.py b/pitivi/undo/markers.py
index be45f52b6..52128d36e 100644
--- a/pitivi/undo/markers.py
+++ b/pitivi/undo/markers.py
@@ -15,13 +15,73 @@
# You should have received a copy of the GNU Lesser General Public
# License along with this program; if not, see .
"""Undo/redo logic for markers."""
+from typing import Dict
+
+from gi.repository import GES
+from gi.repository import GObject
from gi.repository import Gst
-from pitivi.undo.undo import MetaContainerObserver
from pitivi.undo.undo import UndoableAutomaticObjectAction
from pitivi.utils.loggable import Loggable
+class MetaChangedAction(UndoableAutomaticObjectAction):
+
+ def __init__(self, meta_container, item, current_value, new_value):
+ UndoableAutomaticObjectAction.__init__(self, meta_container)
+ self.item = item
+ self.old_value = current_value
+ self.new_value = new_value
+
+ def __repr__(self):
+ return " %s>" % (self.auto_object, self.item, self.old_value, self.new_value)
+
+ def do(self):
+ self.auto_object.set_meta(self.item, self.new_value)
+
+ def undo(self):
+ self.auto_object.set_meta(self.item, self.old_value)
+
+
+class MetaContainerObserver(GObject.Object):
+ """Monitor for MetaContainer changes.
+
+ Attributes:
+ meta_container (GES.MetaContainer): The object to be monitored.
+ action_log (UndoableActionLog): The action log where to report actions.
+ """
+
+ def __init__(self, meta_container: GES.MetaContainer, action_log):
+ self.meta_container: GES.MetaContainer = meta_container
+ self.action_log = action_log
+
+ self.metas = {}
+
+ self.marker_list_observers = {}
+
+ def set_meta(unused_meta_container, item, value):
+ self.__update_meta(item, value)
+ meta_container.foreach(set_meta)
+
+ meta_container.connect("notify-meta", self._notify_meta_cb)
+
+ def _notify_meta_cb(self, meta_container, item, value):
+ current_value = self.metas.get(item)
+ action = MetaChangedAction(meta_container, item, current_value, value)
+ self.__update_meta(item, value)
+ self.action_log.push(action)
+
+ def release(self):
+ self.meta_container.disconnect_by_func(self._notify_meta_cb)
+ self.meta_container = None
+
+ def __update_meta(self, item, value):
+ self.metas[item] = value
+ if isinstance(self.metas[item], GES.MarkerList):
+ observer = MarkerListObserver(self.metas[item], self.action_log)
+ self.marker_list_observers[self.metas[item]] = observer
+
+
class MarkerListObserver(Loggable):
"""Monitors a MarkerList and reports UndoableActions.
@@ -32,12 +92,12 @@ class MarkerListObserver(Loggable):
action_log (UndoableActionLog): The action log where to report actions.
"""
- def __init__(self, ges_marker_list, action_log):
+ def __init__(self, ges_marker_list: GES.MarkerList, action_log):
Loggable.__init__(self)
self.action_log = action_log
- self.marker_observers = {}
+ self.marker_observers: Dict[GES.Marker, MetaContainerObserver] = {}
ges_marker_list.connect("marker-added", self._marker_added_cb)
ges_marker_list.connect("marker-removed", self._marker_removed_cb)
diff --git a/pitivi/undo/project.py b/pitivi/undo/project.py
index beba2240c..ce2f052a2 100644
--- a/pitivi/undo/project.py
+++ b/pitivi/undo/project.py
@@ -19,9 +19,9 @@ from gi.repository import GES
from gi.repository import GObject
from gi.repository import Gst
+from pitivi.undo.markers import MetaContainerObserver
from pitivi.undo.timeline import TimelineObserver
from pitivi.undo.undo import Action
-from pitivi.undo.undo import MetaContainerObserver
from pitivi.undo.undo import UndoableAction
diff --git a/pitivi/undo/timeline.py b/pitivi/undo/timeline.py
index fdade6d29..5dad27290 100644
--- a/pitivi/undo/timeline.py
+++ b/pitivi/undo/timeline.py
@@ -19,9 +19,9 @@ from gi.repository import GObject
from gi.repository import Gst
from pitivi.effects import PROPS_TO_IGNORE
+from pitivi.undo.markers import MetaContainerObserver
from pitivi.undo.undo import FinalizingAction
from pitivi.undo.undo import GObjectObserver
-from pitivi.undo.undo import MetaContainerObserver
from pitivi.undo.undo import UndoableAction
from pitivi.undo.undo import UndoableAutomaticObjectAction
from pitivi.utils.loggable import Loggable
diff --git a/pitivi/undo/undo.py b/pitivi/undo/undo.py
index 000ef1bdd..edc61f222 100644
--- a/pitivi/undo/undo.py
+++ b/pitivi/undo/undo.py
@@ -17,7 +17,6 @@
"""Undo/redo."""
import contextlib
-from gi.repository import GES
from gi.repository import GObject
from pitivi.utils.loggable import Loggable
@@ -430,64 +429,6 @@ class UndoableActionLog(GObject.Object, Loggable):
return False
-class MetaChangedAction(UndoableAutomaticObjectAction):
-
- def __init__(self, meta_container, item, current_value, new_value):
- UndoableAutomaticObjectAction.__init__(self, meta_container)
- self.item = item
- self.old_value = current_value
- self.new_value = new_value
-
- def __repr__(self):
- return " %s>" % (self.auto_object, self.item, self.old_value, self.new_value)
-
- def do(self):
- self.auto_object.set_meta(self.item, self.new_value)
-
- def undo(self):
- self.auto_object.set_meta(self.item, self.old_value)
-
-
-class MetaContainerObserver(GObject.Object):
- """Monitor for MetaContainer changes.
-
- Attributes:
- meta_container (GES.MetaContainer): The object to be monitored.
- action_log (UndoableActionLog): The action log where to report actions.
- """
-
- def __init__(self, meta_container, action_log):
- self.meta_container = meta_container
- self.action_log = action_log
-
- self.metas = {}
-
- self.marker_list_observers = {}
-
- def set_meta(unused_meta_container, item, value):
- self.__update_meta(item, value)
- meta_container.foreach(set_meta)
-
- meta_container.connect("notify-meta", self._notify_meta_cb)
-
- def _notify_meta_cb(self, meta_container, item, value):
- current_value = self.metas.get(item)
- action = MetaChangedAction(meta_container, item, current_value, value)
- self.__update_meta(item, value)
- self.action_log.push(action)
-
- def release(self):
- self.meta_container.disconnect_by_func(self._notify_meta_cb)
- self.meta_container = None
-
- def __update_meta(self, item, value):
- self.metas[item] = value
- if isinstance(self.metas[item], GES.MarkerList):
- from pitivi.undo.markers import MarkerListObserver
- observer = MarkerListObserver(self.metas[item], self.action_log)
- self.marker_list_observers[self.metas[item]] = observer
-
-
class PropertyChangedAction(UndoableAutomaticObjectAction):
def __init__(self, gobject, field_name, old_value, new_value):
--
GitLab