diff --git a/pitivi/mediafilespreviewer.py b/pitivi/mediafilespreviewer.py index a2f266df3eb42869dc8acd0002a616abf0cc44f1..090f6000a1a85a4f515607e845f8370c25e40db4 100644 --- a/pitivi/mediafilespreviewer.py +++ b/pitivi/mediafilespreviewer.py @@ -84,7 +84,7 @@ class PreviewWidget(Gtk.Grid, Loggable): self.error_message = None # playbin for play pics - self.player = AssetPipeline(clip=None, name="preview-player") + self.player = AssetPipeline(name="preview-player") self.player.connect('eos', self._pipelineEosCb) self.player.connect('error', self._pipelineErrorCb) self.player._bus.connect('message::tag', self._tag_found_cb) @@ -253,7 +253,7 @@ class PreviewWidget(Gtk.Grid, Loggable): else: self.current_preview_type = 'video' self.preview_image.hide() - self.player.setClipUri(self.current_selected_uri) + self.player.uri = self.current_selected_uri self.player.setState(Gst.State.PAUSED) self.pos_adj.props.upper = duration video_width = video.get_square_width() @@ -289,7 +289,7 @@ class PreviewWidget(Gtk.Grid, Loggable): beautify_stream(audio), _("Duration: %s") % pretty_duration]) self.player.setState(Gst.State.NULL) - self.player.setClipUri(self.current_selected_uri) + self.player.uri = self.current_selected_uri self.player.setState(Gst.State.PAUSED) self.play_button.show() self.seeker.show() diff --git a/pitivi/utils/pipeline.py b/pitivi/utils/pipeline.py index 44757de0d2bd507a8251d36948d3cdf2cf46da92..3f9f6eaa26937141d4cc24545bb9ff4c12d35f6d 100644 --- a/pitivi/utils/pipeline.py +++ b/pitivi/utils/pipeline.py @@ -506,24 +506,35 @@ class SimplePipeline(GObject.Object, Loggable): class AssetPipeline(SimplePipeline): - """Pipeline for playing a single clip.""" + """Pipeline for playing a single asset. - def __init__(self, clip=None, name=None): + Attributes: + uri (str): The low-level pipeline. + """ + + def __init__(self, uri=None, name=None): ges_pipeline = Gst.ElementFactory.make("playbin", name) SimplePipeline.__init__(self, ges_pipeline) - self.clip = clip - if self.clip: - self.setClipUri(self.clip.props.uri) + self.__uri = None + if uri: + self.uri = uri def create_sink(self): video_sink, sink_widget = SimplePipeline.create_sink(self) self._pipeline.set_property("video_sink", video_sink) - return video_sink, sink_widget - def setClipUri(self, uri): + @property + def uri(self): + # We could maybe get it using `self._pipeline.get_property`, but + # after setting the state to Gst.State.PAUSED, it becomes None. + return self.__uri + + @uri.setter + def uri(self, uri): self._pipeline.set_property("uri", uri) + self.__uri = uri class Pipeline(GES.Pipeline, SimplePipeline): diff --git a/pitivi/viewer/viewer.py b/pitivi/viewer/viewer.py index aa727befcb03bcc0e648afb334b40a5d65fbc46e..f8d6f4ae05f829cacafd00407e7259b78b9ce5f9 100644 --- a/pitivi/viewer/viewer.py +++ b/pitivi/viewer/viewer.py @@ -16,8 +16,9 @@ # License along with this program; if not, write to the # Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, # Boston, MA 02110-1301, USA. +import collections +import time from gettext import gettext as _ -from time import time from gi.repository import Gdk from gi.repository import GES @@ -28,7 +29,6 @@ from gi.repository import Gtk from pitivi.settings import GlobalSettings from pitivi.utils.loggable import Loggable -from pitivi.utils.misc import format_ns from pitivi.utils.pipeline import AssetPipeline from pitivi.utils.ui import SPACING from pitivi.utils.widgets import TimeWidget @@ -61,6 +61,10 @@ GlobalSettings.addConfigOption("pointColor", section="viewer", default='49a0e0') +# The trim preview is updated at most once per this interval. +TRIM_PREVIEW_UPDATE_INTERVAL_MS = 200 + + class ViewerContainer(Gtk.Box, Loggable): """Wiget holding a viewer and the controls. @@ -85,20 +89,21 @@ class ViewerContainer(Gtk.Box, Loggable): self.log("New ViewerContainer") self.project = None - self.pipeline = None + self.trim_pipeline = None + self.trim_pipelines_cache = collections.OrderedDict() self.docked = True self.target = None self._compactMode = False - # Only used for restoring the pipeline position after a live clip trim - # preview: - self._oldTimelinePos = None + # When was the last seek performed while previewing a clip trim. + self._last_trim_ns = 0 + # The delayed seek timeout ID, in case the last seek is too recent. + self.__trim_seek_id = 0 self._haveUI = False self._createUi() - self.__owning_pipeline = False if not self.settings.viewerDocked: self.undock() @@ -112,8 +117,7 @@ class ViewerContainer(Gtk.Box, Loggable): def _project_manager_new_project_loaded_cb(self, unused_project_manager, project): project.connect("rendering-settings-changed", self._project_rendering_settings_changed_cb) - self.project = project - self.setPipeline(project.pipeline) + self.set_project(project) def _projectManagerProjectClosedCb(self, unused_project_manager, project): if self.project == project: @@ -129,16 +133,13 @@ class ViewerContainer(Gtk.Box, Loggable): self.target.update_aspect_ratio(project) self.timecode_entry.setFramerate(project.videorate) - def setPipeline(self, pipeline, position=None): - """Sets the displayed pipeline. - - Properly switches the currently set action to that new Pipeline. + def set_project(self, project): + """Sets the displayed project. Args: - pipeline (Pipeline): The Pipeline to switch to. - position (Optional[int]): The position to seek to initially. + project (Project): The Project to switch to. """ - self.debug("Setting pipeline: %r", pipeline) + self.debug("Setting project: %r", project) self._disconnectFromPipeline() if self.target: @@ -146,21 +147,20 @@ class ViewerContainer(Gtk.Box, Loggable): if parent: parent.remove(self.target) - self.pipeline = pipeline - self.pipeline.connect("state-change", self._pipelineStateChangedCb) - self.pipeline.connect("position", self._positionCb) - self.pipeline.connect("duration-changed", self._durationChangedCb) + project.pipeline.connect("state-change", self._pipelineStateChangedCb) + project.pipeline.connect("position", self._positionCb) + project.pipeline.connect("duration-changed", self._durationChangedCb) + self.project = project - self.__owning_pipeline = False self.__createNewViewer() self._setUiActive() - if position: - self.pipeline.simple_seek(position) - self.pipeline.pause() + # This must be done at the end, otherwise the created sink widget + # appears in a separate window. + project.pipeline.pause() def __createNewViewer(self): - _, sink_widget = self.pipeline.create_sink() + _, sink_widget = self.project.pipeline.create_sink() self.overlay_stack = OverlayStack(self.app, sink_widget) self.target = ViewerWidget(self.overlay_stack) @@ -179,18 +179,14 @@ class ViewerContainer(Gtk.Box, Loggable): GLib.timeout_add(1000, self.__viewer_realization_done_cb, None) def _disconnectFromPipeline(self): - if self.pipeline is None: - # silently return, there's nothing to disconnect from + if self.project is None: return - self.debug("Disconnecting from: %r", self.pipeline) - self.pipeline.disconnect_by_func(self._pipelineStateChangedCb) - self.pipeline.disconnect_by_func(self._positionCb) - self.pipeline.disconnect_by_func(self._durationChangedCb) - - if self.__owning_pipeline: - self.pipeline.release() - self.pipeline = None + pipeline = self.project.pipeline + self.debug("Disconnecting from: %r", pipeline) + pipeline.disconnect_by_func(self._pipelineStateChangedCb) + pipeline.disconnect_by_func(self._positionCb) + pipeline.disconnect_by_func(self._durationChangedCb) def _setUiActive(self, active=True): self.debug("active %r", active) @@ -310,6 +306,14 @@ class ViewerContainer(Gtk.Box, Loggable): _("Detach the viewer\nYou can re-attach it by closing the newly created window.")) bbox.pack_start(self.undock_button, False, False, 0) + self.show_all() + + # Create a hidden container for the clip trim preview video widget. + self.hidden_chest = Gtk.Frame() + # It has to be added to the window, otherwise when we add + # a video widget to it, it will create a new window! + self.pack_end(self.hidden_chest, False, False, 0) + self._haveUI = True # Identify widgets for AT-SPI, making our test suite easier to develop @@ -323,7 +327,6 @@ class ViewerContainer(Gtk.Box, Loggable): self.undock_button.get_accessible().set_name("undock_button") self.buttons_container = bbox - self.show_all() self.external_vbox.show_all() def __corner_draw_cb(self, unused_widget, cr, lines, space, margin): @@ -429,10 +432,10 @@ class ViewerContainer(Gtk.Box, Loggable): self.settings.viewerDocked = False self.remove(self.buttons_container) position = None - if self.pipeline: + if self.project: self.overlay_stack.enable_resize_status(False) - position = self.pipeline.getPosition() - self.pipeline.setState(Gst.State.NULL) + position = self.project.pipeline.getPosition() + self.project.pipeline.setState(Gst.State.NULL) self.remove(self.target) self.__createNewViewer() self.buttons_container.set_margin_bottom(SPACING) @@ -453,9 +456,9 @@ class ViewerContainer(Gtk.Box, Loggable): self.external_window.move(self.settings.viewerX, self.settings.viewerY) self.external_window.resize( self.settings.viewerWidth, self.settings.viewerHeight) - if self.pipeline: - self.pipeline.pause() - self.pipeline.simple_seek(position) + if self.project: + self.project.pipeline.pause() + self.project.pipeline.simple_seek(position) def __viewer_realization_done_cb(self, unused_data): self.overlay_stack.enable_resize_status(True) @@ -470,10 +473,10 @@ class ViewerContainer(Gtk.Box, Loggable): self.settings.viewerDocked = True position = None - if self.pipeline: + if self.project: self.overlay_stack.enable_resize_status(False) - position = self.pipeline.getPosition() - self.pipeline.setState(Gst.State.NULL) + position = self.project.pipeline.getPosition() + self.project.pipeline.setState(Gst.State.NULL) self.external_vbox.remove(self.target) self.__createNewViewer() @@ -485,9 +488,9 @@ class ViewerContainer(Gtk.Box, Loggable): self.show() self.external_window.hide() - if self.pipeline: - self.pipeline.pause() - self.pipeline.simple_seek(position) + if self.project.pipeline: + self.project.pipeline.pause() + self.project.pipeline.simple_seek(position) def _toggleFullscreen(self, widget): if widget.get_active(): @@ -518,34 +521,97 @@ class ViewerContainer(Gtk.Box, Loggable): self.log("Not previewing trim for image or title clip: %s", clip) return False - clip_uri = clip.props.uri - cur_time = time() - if self.pipeline == self.app.project_manager.current_project.pipeline: - self.debug("Creating temporary pipeline for clip %s, position %s", - clip_uri, format_ns(position)) - self._oldTimelinePos = self.pipeline.getPosition(False) - self.pipeline.set_state(Gst.State.NULL) - self.setPipeline(AssetPipeline(clip)) - self.__owning_pipeline = True - self._lastClipTrimTime = cur_time - - if (cur_time - self._lastClipTrimTime) > 0.2 and self.pipeline.getState() == Gst.State.PAUSED: - # Do not seek more than once every 200 ms (for performance) - self.pipeline.simple_seek(position) - self._lastClipTrimTime = cur_time + if self.project.pipeline.getState() == Gst.State.PLAYING: + self.project.pipeline.setState(Gst.State.PAUSED) + + uri = clip.props.uri + if self.trim_pipeline and uri != self.trim_pipeline.uri: + # Seems to be the trim preview pipeline for a different clip. + self.trim_pipeline.release() + self.trim_pipeline = None + + if not self.trim_pipeline: + self.trim_pipeline, sink_widget = self.get_trim_preview_pipeline(uri) + # Add the widget to a hidden container and make it appear later + # when it's ready. If we show it before the initial seek completion, + # there is a flicker when the first frame of the asset is shown for + # a brief moment until the initial seek to the frame we actually + # want to show is performed. + # First make sure the container itself is ready. + widget = self.hidden_chest.get_child() + if widget: + self.warning("The previous trim preview video widget should have been removed already") + self.hidden_chest.remove(widget) + self.hidden_chest.add(sink_widget) + sink_widget.show() + self.trim_pipeline.connect("state-change", self._state_change_cb) + self.trim_pipeline.setState(Gst.State.PAUSED) + self._last_trim_ns = 0 + + if self.__trim_seek_id: + # A seek is scheduled. Update the position where it will be done. + self.__trim_seek_position = position + else: + # Avoid seeking too often, for performance. + time_ns = time.monotonic_ns() + delta_ns = time_ns - self._last_trim_ns + if delta_ns > TRIM_PREVIEW_UPDATE_INTERVAL_MS * 1000 * 1000: + # The last seek is not recent, we can seek right away. + self.trim_pipeline.simple_seek(position) + self._last_trim_ns = time_ns + else: + # The previous seek was too recent. Schedule a seek at this position. + self.__trim_seek_position = position + self.__trim_seek_id = GLib.timeout_add(delta_ns / 1000 / 1000, + self.__trim_seek_timeout_cb, None) + + def get_trim_preview_pipeline(self, uri): + try: + trim_pipeline, sink_widget = self.trim_pipelines_cache[uri] + self.debug("Reusing temporary pipeline for clip %s", uri) + except KeyError: + self.debug("Creating temporary pipeline for clip %s", uri) + trim_pipeline = AssetPipeline(uri) + unused_video_sink, sink_widget = trim_pipeline.create_sink() + self.trim_pipelines_cache[uri] = trim_pipeline, sink_widget + if len(self.trim_pipelines_cache) > 4: + # Pop the first inserted item. + expired_uri, (expired_pipeline, unused_expired_widget) = self.trim_pipelines_cache.popitem(last=False) + self.debug("Releasing temporary pipeline for clip %s", expired_uri) + expired_pipeline.release() + return trim_pipeline, sink_widget + + def _state_change_cb(self, trim_pipeline, state, prev_state): + if self.trim_pipeline is not trim_pipeline: + 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: + sink_widget = self.hidden_chest.get_child() + if sink_widget: + self.hidden_chest.remove(sink_widget) + self.target.switch_widget(sink_widget) + trim_pipeline.disconnect_by_func(self._state_change_cb) + + def __trim_seek_timeout_cb(self, unused_data): + self.__trim_seek_id = 0 + self.trim_pipeline.simple_seek(self.__trim_seek_position) + self._last_trim_ns = time.monotonic_ns() + return False def clipTrimPreviewFinished(self): """Switches back to the project pipeline following a clip trimming.""" - if self.pipeline is not self.app.project_manager.current_project.pipeline: - self.debug("Going back to the project's pipeline") - self.pipeline.setState(Gst.State.NULL) - # Using pipeline.getPosition() here does not work because for some - # reason it's a bit off, that's why we need self._oldTimelinePos. - self.setPipeline( - self.app.project_manager.current_project.pipeline, self._oldTimelinePos) - self._oldTimelinePos = None - - def _pipelineStateChangedCb(self, unused_pipeline, state, old_state): + if self.__trim_seek_id: + GLib.source_remove(self.__trim_seek_id) + self.__trim_seek_id = 0 + if not self.trim_pipeline: + return + self.target.switch_widget(self.overlay_stack) + self.trim_pipeline = None + + def _pipelineStateChangedCb(self, pipeline, state, old_state): """Updates the widgets when the playback starts or stops.""" if state == Gst.State.PLAYING: st = Gst.Structure.new_empty("play") @@ -559,8 +625,8 @@ class ViewerContainer(Gtk.Box, Loggable): if old_state != Gst.State.PAUSED: st = Gst.Structure.new_empty("pause") if old_state == Gst.State.PLAYING: - st.set_value("playback_time", - self.pipeline.getPosition() / Gst.SECOND) + position_seconds = pipeline.getPosition() / Gst.SECOND + st.set_value("playback_time", position_seconds) self.app.write_action(st) self.playpause_button.setPlay() @@ -595,6 +661,12 @@ class ViewerWidget(Gtk.AspectFrame, Loggable): # would show through the non-double-buffered widget! self.hide() + def switch_widget(self, widget): + child = self.get_child() + if child: + self.remove(child) + self.add(widget) + def update_aspect_ratio(self, project): """Forces the DAR of the project on the child widget.""" ratio_fraction = project.getDAR()