diff --git a/data/ui/customwidgets/pitivi:object_effect.ui b/data/ui/customwidgets/pitivi:object_effect.ui new file mode 100644 index 0000000000000000000000000000000000000000..8888a91a6572643b22adc938d29b70126340b5bd --- /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/data/ui/trackerperspective.ui b/data/ui/trackerperspective.ui new file mode 100644 index 0000000000000000000000000000000000000000..3c87234ea787eb2dbd52284b958c2dcb43e7f142 --- /dev/null +++ b/data/ui/trackerperspective.ui @@ -0,0 +1,444 @@ + + + + + + True + False + GDK_POINTER_MOTION_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_STRUCTURE_MASK + + + + + + + + + True + False + Pause + media-playback-pause-symbolic + + + True + False + Play + media-playback-start-symbolic + + + 100 + 1 + 10 + + + + True + False + media-seek-backward-symbolic + + + True + False + media-seek-forward-symbolic + + + diff --git a/pitivi/check.py b/pitivi/check.py index 8ca67d9c3db14889168cb6a8776fd065c5952cef..69e74723a433b4ed6cdf1b4f87027552ed4b0ec6 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 6680c90080aa287810d89c547a69f3a22d3c5659..cb906bc3d4d4725c80fb91f36d94f2081103ecb5 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,8 +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 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 @@ -571,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"), @@ -593,11 +598,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 + + self.cover_popover: Optional[Gtk.Popover] = None + self.cover_object_button: Optional[Gtk.MenuButton] = None + if "cvtracker" not in MISSING_SOFT_DEPS: + 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) 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) @@ -606,34 +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 _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) @@ -650,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) @@ -751,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): @@ -758,11 +777,16 @@ 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(): + 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): @@ -811,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") @@ -1096,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 de7217656e841022ce9dc5e33f88039159d49e2c..2f673e2acc75df1085031fc66bc96d939c7374f5 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 7c71def48b358d0672522dc9874f402d81c7a780..d61ae68b1cd72b74fcda05b490df7ccaa5cdc35f 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 cb1b63d7263abc069315a7ea6647be38f739844f..b822460d82d3b73148635c1a27eeafdef632d3da 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/timeline/elements.py b/pitivi/timeline/elements.py index c01b93dabc21529862b861d128a83a72d0576c3c..6a47c26983b55e5b99b3aafb1ae22b780e7314ed 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 0000000000000000000000000000000000000000..b12bcaf236cbb902dbcbf966395107d2d48884a9 --- /dev/null +++ b/pitivi/trackerperspective.py @@ -0,0 +1,800 @@ +# -*- 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 GstController +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" + +# 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): + 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: TrackedObjectItem = 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() + + +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/undo/markers.py b/pitivi/undo/markers.py index be45f52b612292f6d2209e802247602b8c9dab37..52128d36e1952c3bc9131148a0a5b59c3c74ceba 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 beba2240c8473a59135092388b284d8a237556f2..ce2f052a202946a3c19cde3109a270e8d2b953c1 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 fdade6d299008ebf221e2cb735317da6eb71da5a..5dad2729095cb1434027fd3a62a14ce202412a9e 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 000ef1bdd899a2ff53d2f1e7cb4bd37d9b646b33..edc61f222423f839ab67e55919de96e29aabe597 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): diff --git a/pitivi/utils/custom_effect_widgets.py b/pitivi/utils/custom_effect_widgets.py index 44b5a60ed5fe1dc52ab3d33f10c9da1e90aec5d5..5c2e27157894e3670f13a8aed987c4909d090898 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/pipeline.py b/pitivi/utils/pipeline.py index 74223f229240b069feafe434e4cda29eeee4df24..354e8c90988e0e0ba8fe92115b8bd08b21f4c5ec 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 7ccb5e80e8f8ca6f1ae92541897512a9e9a055ba..cf591ddbeca082d0dabe87c5435a31f96c06a2f7 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.""" @@ -429,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): @@ -489,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/pitivi/viewer/viewer.py b/pitivi/viewer/viewer.py index fdf6a712915ec34a245a93066c9e05b199a51f48..042bddb66670bd93c7e1ed11fa2863ecad28c829 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 0000000000000000000000000000000000000000..2a0af20a6152f344836bdc3994156e08ba9939e1 --- /dev/null +++ b/tests/test_trackerperspective.py @@ -0,0 +1,119 @@ +# -*- 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 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.""" + + 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))