[pitivi] trackerperspective: Allow tracking objects



commit 131048df2cfadf50975eaa1aadc35a3bf2b38e8f
Author: Vivek R <123vivekr gmail com>
Date:   Thu Jul 9 17:31:27 2020 +0530

    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(-)
---
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.38.2 -->
+<interface>
+  <requires lib="gtk+" version="3.22"/>
+  <object class="GtkDrawingArea" id="drawing_area">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="events">GDK_POINTER_MOTION_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | 
GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_STRUCTURE_MASK</property>
+    <signal name="button-press-event" handler="_drawing_area_button_event_cb" swapped="no"/>
+    <signal name="button-release-event" handler="_drawing_area_button_event_cb" swapped="no"/>
+    <signal name="draw" handler="_drawing_area_draw_cb" swapped="no"/>
+    <signal name="enter-notify-event" handler="_drawing_area_enter_notify_event_cb" swapped="no"/>
+    <signal name="leave-notify-event" handler="_drawing_area_leave_notify_event_cb" swapped="no"/>
+    <signal name="motion-notify-event" handler="_drawing_area_motion_notify_event_cb" swapped="no"/>
+  </object>
+  <object class="GtkImage" id="pause_icon">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="tooltip-text" translatable="yes">Pause</property>
+    <property name="icon-name">media-playback-pause-symbolic</property>
+  </object>
+  <object class="GtkImage" id="play_icon">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="tooltip-text" translatable="yes">Play</property>
+    <property name="icon-name">media-playback-start-symbolic</property>
+  </object>
+  <object class="GtkAdjustment" id="pos_adj">
+    <property name="upper">100</property>
+    <property name="step-increment">1</property>
+    <property name="page-increment">10</property>
+    <signal name="value-changed" handler="_adjustment_value_changed_cb" swapped="no"/>
+  </object>
+  <object class="GtkImage" id="seek_backward_icon">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="icon-name">media-seek-backward-symbolic</property>
+  </object>
+  <object class="GtkImage" id="seek_forward_icon">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="icon-name">media-seek-forward-symbolic</property>
+  </object>
+  <template class="ToplevelWidget" parent="GtkBox">
+    <property name="visible">True</property>
+    <property name="can-focus">False</property>
+    <property name="halign">center</property>
+    <property name="margin-start">10</property>
+    <property name="margin-end">10</property>
+    <property name="margin-top">10</property>
+    <property name="margin-bottom">10</property>
+    <property name="spacing">10</property>
+    <child>
+      <object class="GtkBox" id="object_manager_box">
+        <property name="width-request">100</property>
+        <property name="visible">True</property>
+        <property name="can-focus">False</property>
+        <property name="valign">start</property>
+        <property name="orientation">vertical</property>
+        <property name="spacing">10</property>
+        <child>
+          <object class="GtkLabel">
+            <property name="visible">True</property>
+            <property name="can-focus">False</property>
+            <property name="halign">start</property>
+            <property name="label" translatable="yes">Tracked Objects:</property>
+            <property name="justify">center</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkScrolledWindow">
+            <property name="visible">True</property>
+            <property name="can-focus">True</property>
+            <property name="vexpand">True</property>
+            <property name="hscrollbar-policy">never</property>
+            <property name="propagate-natural-width">True</property>
+            <property name="propagate-natural-height">True</property>
+            <child>
+              <object class="GtkViewport">
+                <property name="visible">True</property>
+                <property name="can-focus">False</property>
+                <property name="vexpand">True</property>
+                <property name="hscroll-policy">natural</property>
+                <property name="vscroll-policy">natural</property>
+                <property name="shadow-type">none</property>
+                <child>
+                  <object class="GtkListBox" id="object_listbox">
+                    <property name="visible">True</property>
+                    <property name="can-focus">False</property>
+                    <property name="valign">start</property>
+                    <signal name="selected-rows-changed" handler="_listbox_selected_rows_changed_cb" 
swapped="no"/>
+                    <style>
+                      <class name="background-color: @theme_selected_bg_color;"/>
+                    </style>
+                  </object>
+                </child>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">True</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButtonBox">
+            <property name="visible">True</property>
+            <property name="can-focus">False</property>
+            <property name="homogeneous">True</property>
+            <child>
+              <object class="GtkButton" id="remove_object_button">
+                <property name="label">gtk-remove</property>
+                <property name="visible">True</property>
+                <property name="can-focus">True</property>
+                <property name="receives-default">True</property>
+                <property name="halign">start</property>
+                <property name="use-stock">True</property>
+                <signal name="clicked" handler="_remove_object_button_clicked_cb" swapped="no"/>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkButton" id="add_object_button">
+                <property name="label">gtk-add</property>
+                <property name="visible">True</property>
+                <property name="can-focus">True</property>
+                <property name="receives-default">True</property>
+                <property name="halign">end</property>
+                <property name="use-stock">True</property>
+                <signal name="clicked" handler="_add_object_button_clicked_cb" swapped="no"/>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">2</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkInfoBar" id="howto_add_infobar">
+            <property name="visible">True</property>
+            <property name="can-focus">False</property>
+            <child internal-child="action_area">
+              <object class="GtkButtonBox">
+                <property name="can-focus">False</property>
+                <property name="spacing">6</property>
+                <property name="layout-style">end</property>
+                <child>
+                  <placeholder/>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child internal-child="content_area">
+              <object class="GtkBox">
+                <property name="can-focus">False</property>
+                <property name="spacing">16</property>
+                <child>
+                  <object class="GtkImage">
+                    <property name="visible">True</property>
+                    <property name="can-focus">False</property>
+                    <property name="icon-name">dialog-information-symbolic</property>
+                    <property name="icon_size">5</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkLabel">
+                    <property name="visible">True</property>
+                    <property name="can-focus">False</property>
+                    <property name="label" translatable="yes">Drag&amp;drop on the video to delimit an 
object to be tracked.</property>
+                    <property name="wrap">True</property>
+                    <property name="width-chars">0</property>
+                    <property name="max-width-chars">30</property>
+                  </object>
+                  <packing>
+                    <property name="expand">False</property>
+                    <property name="fill">True</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">3</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">False</property>
+        <property name="position">0</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkSeparator">
+        <property name="visible">True</property>
+        <property name="can-focus">False</property>
+      </object>
+      <packing>
+        <property name="expand">False</property>
+        <property name="fill">False</property>
+        <property name="position">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkBox" id="tracker_box">
+        <property name="visible">True</property>
+        <property name="can-focus">False</property>
+        <property name="hexpand">True</property>
+        <property name="orientation">vertical</property>
+        <child>
+          <object class="GtkAspectFrame" id="aspect_frame">
+            <property name="visible">True</property>
+            <property name="can-focus">False</property>
+            <property name="label-xalign">0</property>
+            <property name="shadow-type">none</property>
+            <property name="obey-child">False</property>
+            <child>
+              <object class="GtkOverlay" id="viewer_overlay">
+                <property name="visible">True</property>
+                <property name="can-focus">False</property>
+                <signal name="realize" handler="_viewer_overlay_realize_cb" swapped="no"/>
+                <child>
+                  <placeholder/>
+                </child>
+              </object>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">0</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox">
+            <property name="visible">True</property>
+            <property name="can-focus">False</property>
+            <property name="margin-start">20</property>
+            <property name="margin-end">20</property>
+            <property name="margin-top">20</property>
+            <property name="margin-bottom">20</property>
+            <property name="orientation">vertical</property>
+            <child>
+              <object class="GtkButtonBox" id="viewer_buttons">
+                <property name="visible">True</property>
+                <property name="can-focus">False</property>
+                <property name="homogeneous">True</property>
+                <property name="layout-style">center</property>
+                <child>
+                  <object class="GtkButton" id="prev_frame_button">
+                    <property name="visible">True</property>
+                    <property name="can-focus">True</property>
+                    <property name="receives-default">True</property>
+                    <property name="tooltip-text" translatable="yes">Go back one frame</property>
+                    <property name="image">seek_backward_icon</property>
+                    <signal name="clicked" handler="_prev_frame_button_clicked_cb" swapped="no"/>
+                  </object>
+                  <packing>
+                    <property name="expand">True</property>
+                    <property name="fill">True</property>
+                    <property name="position">0</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkButton" id="play_pause_button">
+                    <property name="visible">True</property>
+                    <property name="can-focus">True</property>
+                    <property name="receives-default">True</property>
+                    <property name="image">play_icon</property>
+                    <property name="always-show-image">True</property>
+                    <signal name="clicked" handler="_play_pause_button_clicked_cb" swapped="no"/>
+                  </object>
+                  <packing>
+                    <property name="expand">True</property>
+                    <property name="fill">True</property>
+                    <property name="position">1</property>
+                  </packing>
+                </child>
+                <child>
+                  <object class="GtkButton" id="next_frame_button">
+                    <property name="visible">True</property>
+                    <property name="can-focus">True</property>
+                    <property name="receives-default">True</property>
+                    <property name="tooltip-text" translatable="yes">Go forward one frame</property>
+                    <property name="image">seek_forward_icon</property>
+                    <signal name="clicked" handler="_next_frame_button_clicked_cb" swapped="no"/>
+                  </object>
+                  <packing>
+                    <property name="expand">True</property>
+                    <property name="fill">True</property>
+                    <property name="position">2</property>
+                  </packing>
+                </child>
+                <style>
+                  <class name="linked"/>
+                </style>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkScale" id="seeker">
+            <property name="visible">True</property>
+            <property name="can-focus">True</property>
+            <property name="margin-start">15</property>
+            <property name="margin-end">15</property>
+            <property name="margin-top">5</property>
+            <property name="adjustment">pos_adj</property>
+            <property name="show-fill-level">True</property>
+            <property name="draw-value">False</property>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">2</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkBox" id="tracker_details_box">
+            <property name="visible">True</property>
+            <property name="can-focus">False</property>
+            <property name="halign">center</property>
+            <property name="border-width">12</property>
+            <property name="spacing">6</property>
+            <child>
+              <object class="GtkLabel" id="tracking_algorithm_label">
+                <property name="visible">True</property>
+                <property name="can-focus">False</property>
+                <property name="halign">center</property>
+                <property name="label" translatable="yes">Tracking Algorithm</property>
+                <attributes>
+                  <attribute name="weight" value="bold"/>
+                </attributes>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">0</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkComboBox" id="algorithm_combo_box">
+                <property name="visible">True</property>
+                <property name="can-focus">False</property>
+                <property name="margin-right">4</property>
+                <property name="margin-end">4</property>
+                <property name="active">3</property>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">False</property>
+                <property name="padding">6</property>
+                <property name="position">1</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkButton" id="track_button">
+                <property name="label" translatable="yes">Track</property>
+                <property name="visible">True</property>
+                <property name="can-focus">True</property>
+                <property name="receives-default">True</property>
+                <signal name="clicked" handler="_track_button_clicked_cb" swapped="no"/>
+              </object>
+              <packing>
+                <property name="expand">True</property>
+                <property name="fill">True</property>
+                <property name="position">2</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkButton" id="stop_button">
+                <property name="label">gtk-media-stop</property>
+                <property name="visible">True</property>
+                <property name="can-focus">True</property>
+                <property name="receives-default">True</property>
+                <property name="use-stock">True</property>
+                <signal name="clicked" handler="_stop_track_button_clicked_cb" swapped="no"/>
+              </object>
+              <packing>
+                <property name="expand">False</property>
+                <property name="fill">True</property>
+                <property name="position">3</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="expand">False</property>
+            <property name="fill">True</property>
+            <property name="position">3</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="expand">True</property>
+        <property name="fill">True</property>
+        <property name="position">2</property>
+      </packing>
+    </child>
+  </template>
+</interface>
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ț <alexandru balut gmail com>
+#
+# 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 <http://www.gnu.org/licenses/>.
+"""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ț <alexandru balut gmail com>
+#
+# 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 <http://www.gnu.org/licenses/>.
+"""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))


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