[pitivi] greeter: Add project thumbnails



commit a6511c1ee14e4dd01034e82d7d8930462ab2046b
Author: HarishFulara07 <harish14143 iiitd ac in>
Date:   Mon Jul 16 22:58:32 2018 +0530

    greeter: Add project thumbnails

 data/ui/project_info.ui      |  37 +++++++++++++-
 pitivi/application.py        |   2 +-
 pitivi/greeterperspective.py |   2 +
 pitivi/medialibrary.py       |  24 ++++++----
 pitivi/project.py            | 112 ++++++++++++++++++++++++++++++++++++++++++-
 pitivi/undo/undo.py          |   7 +++
 pitivi/utils/misc.py         |  17 +++++++
 pitivi/utils/ui.py           |   4 ++
 tests/test_misc.py           |  32 +++++++++++++
 tests/test_undo_project.py   |   3 ++
 10 files changed, 228 insertions(+), 12 deletions(-)
---
diff --git a/data/ui/project_info.ui b/data/ui/project_info.ui
index 3aa810b9..b880c605 100644
--- a/data/ui/project_info.ui
+++ b/data/ui/project_info.ui
@@ -21,7 +21,38 @@
       </packing>
     </child>
     <child>
-      <object class="GtkBox" id="project_info_vbox">
+      <object class="GtkBox">
+        <property name="name">project_thumbnail_box</property>
+        <property name="width_request">96</property>
+        <property name="height_request">54</property>
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="valign">center</property>
+        <property name="margin_right">12</property>
+        <property name="vexpand">True</property>
+        <property name="orientation">vertical</property>
+        <child>
+          <object class="GtkImage" id="project_thumbnail">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="valign">center</property>
+            <property name="vexpand">True</property>
+          </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="GtkBox">
         <property name="visible">True</property>
         <property name="can_focus">False</property>
         <property name="orientation">vertical</property>
@@ -70,7 +101,9 @@
             <property name="visible">True</property>
             <property name="can_focus">False</property>
             <property name="halign">start</property>
+            <property name="valign">end</property>
             <property name="margin_right">200</property>
+            <property name="vexpand">True</property>
           </object>
           <packing>
             <property name="expand">False</property>
@@ -82,7 +115,7 @@
       <packing>
         <property name="expand">False</property>
         <property name="fill">True</property>
-        <property name="position">1</property>
+        <property name="position">2</property>
       </packing>
     </child>
   </object>
diff --git a/pitivi/application.py b/pitivi/application.py
index ee47959a..1713c9f4 100644
--- a/pitivi/application.py
+++ b/pitivi/application.py
@@ -151,7 +151,7 @@ class Pitivi(Gtk.Application, Loggable):
             "new-project-loading", self._newProjectLoadingCb)
         self.project_manager.connect(
             "new-project-loaded", self._newProjectLoaded)
-        self.project_manager.connect("project-closed", self._projectClosed)
+        self.project_manager.connect_after("project-closed", self._projectClosed)
         self.project_manager.connect("project-saved", self.__project_saved_cb)
 
         self._createActions()
diff --git a/pitivi/greeterperspective.py b/pitivi/greeterperspective.py
index dbae89b8..77d260fb 100644
--- a/pitivi/greeterperspective.py
+++ b/pitivi/greeterperspective.py
@@ -29,6 +29,7 @@ from gi.repository import Gtk
 from pitivi.configure import get_ui_dir
 from pitivi.dialogs.browseprojects import BrowseProjectsDialog
 from pitivi.perspective import Perspective
+from pitivi.project import Project
 from pitivi.utils.ui import beautify_last_updated_timestamp
 from pitivi.utils.ui import beautify_project_path
 from pitivi.utils.ui import fix_infobar
@@ -57,6 +58,7 @@ class ProjectInfoRow(Gtk.ListBoxRow):
         # show it during projects removal screen.
         self.select_button.hide()
 
+        builder.get_object("project_thumbnail").set_from_pixbuf(Project.get_thumb(self.uri))
         builder.get_object("project_name_label").set_text(self.name)
         builder.get_object("project_uri_label").set_text(
             beautify_project_path(recent_project_item.get_uri_display()))
diff --git a/pitivi/medialibrary.py b/pitivi/medialibrary.py
index e5843e2a..e3ebc00f 100644
--- a/pitivi/medialibrary.py
+++ b/pitivi/medialibrary.py
@@ -252,7 +252,20 @@ class AssetThumbnail(Loggable):
         return small_thumb, large_thumb
 
     @staticmethod
-    def get_thumbnails_from_xdg_cache(real_uri):
+    def get_asset_thumbnails_path(real_uri):
+        """Gets normal & large thumbnail path for the asset in the XDG cache.
+
+        Returns:
+            List[str]: The path of normal thumbnail and large thumbnail.
+        """
+        quoted_uri = quote_uri(real_uri)
+        thumbnail_hash = md5(quoted_uri.encode()).hexdigest()
+        thumb_dir = os.path.join(GLib.get_user_cache_dir(), "thumbnails")
+        return os.path.join(thumb_dir, "normal", thumbnail_hash + ".png"),\
+            os.path.join(thumb_dir, "large", thumbnail_hash + ".png")
+
+    @classmethod
+    def get_thumbnails_from_xdg_cache(cls, real_uri):
         """Gets pixbufs for the specified thumbnail from the user's cache dir.
 
         Looks for thumbnails according to the [Thumbnail Managing 
Standard](https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html#DIRECTORY).
@@ -264,10 +277,7 @@ class AssetThumbnail(Loggable):
             List[GdkPixbuf.Pixbuf]: The small thumbnail and the large thumbnail,
             if available in the user's cache directory, otherwise (None, None).
         """
-        quoted_uri = quote_uri(real_uri)
-        thumbnail_hash = md5(quoted_uri.encode()).hexdigest()
-        thumb_dir = os.path.join(GLib.get_user_cache_dir(), "thumbnails")
-        path_128 = os.path.join(thumb_dir, "normal", thumbnail_hash + ".png")
+        path_128, path_256 = cls.get_asset_thumbnails_path(real_uri)
         interpolation = GdkPixbuf.InterpType.BILINEAR
 
         # The cache dirs might have resolutions of 256 and/or 128,
@@ -280,7 +290,6 @@ class AssetThumbnail(Loggable):
             return small_thumb, large_thumb
         except GLib.GError:
             # path_128 doesn't exist, try the 256 version.
-            path_256 = os.path.join(thumb_dir, "large", thumbnail_hash + ".png")
             try:
                 thumb_256 = GdkPixbuf.Pixbuf.new_from_file(path_256)
                 w, h = thumb_256.get_width(), thumb_256.get_height()
@@ -649,8 +658,7 @@ class MediaLibraryWidget(Gtk.Box, Loggable):
         rows = [Gtk.TreeRowReference.new(model, path)
                 for path in paths]
 
-        with self.app.action_log.started("remove asset from media library",
-                                         toplevel=True):
+        with self.app.action_log.started("assets-removal", toplevel=True):
             for row in rows:
                 asset = model[row.get_path()][COL_ASSET]
                 target = asset.get_proxy_target()
diff --git a/pitivi/project.py b/pitivi/project.py
index 7383147a..3fa29024 100644
--- a/pitivi/project.py
+++ b/pitivi/project.py
@@ -21,11 +21,14 @@
 import datetime
 import os
 import pwd
+import shutil
 import tarfile
 import time
 import uuid
 from gettext import gettext as _
+from hashlib import md5
 
+from gi.repository import GdkPixbuf
 from gi.repository import GES
 from gi.repository import GLib
 from gi.repository import GObject
@@ -35,9 +38,12 @@ from gi.repository import GstVideo
 from gi.repository import Gtk
 
 from pitivi.configure import get_ui_dir
+from pitivi.medialibrary import AssetThumbnail
 from pitivi.preset import AudioPresetManager
 from pitivi.preset import VideoPresetManager
 from pitivi.render import Encoders
+from pitivi.settings import get_dir
+from pitivi.settings import xdg_cache_home
 from pitivi.undo.project import AssetAddedIntention
 from pitivi.undo.project import AssetProxiedIntention
 from pitivi.utils.loggable import Loggable
@@ -45,6 +51,7 @@ from pitivi.utils.misc import fixate_caps_with_default_values
 from pitivi.utils.misc import isWritable
 from pitivi.utils.misc import path_from_uri
 from pitivi.utils.misc import quote_uri
+from pitivi.utils.misc import scale_pixbuf
 from pitivi.utils.misc import unicode_error_dialog
 from pitivi.utils.pipeline import Pipeline
 from pitivi.utils.ripple_update_group import RippleUpdateGroup
@@ -80,6 +87,11 @@ for i in range(2, GLib.MAXINT):
 # a project.
 IGNORED_PROPS = ["name", "parent"]
 
+SCALED_THUMB_WIDTH = 96
+SCALED_THUMB_HEIGHT = 54
+SCALED_THUMB_DIR = "96x54"
+ORIGINAL_THUMB_DIR = "original"
+
 
 class ProjectManager(GObject.Object, Loggable):
     """The project manager.
@@ -477,6 +489,7 @@ class ProjectManager(GObject.Object, Loggable):
 
         project = self.current_project
         self.current_project = None
+        project.create_thumb()
         self.emit("project-closed", project)
         # We should never choke on silly stuff like disconnecting signals
         # that were already disconnected. It blocks the UI for nothing.
@@ -783,6 +796,103 @@ class Project(Loggable, GES.Project):
             return
         self.set_meta("author", author)
 
+    @staticmethod
+    def get_thumb_path(uri, resolution):
+        """Returns path of thumbnail of specified resolution in the cache."""
+        thumb_hash = md5(quote_uri(uri).encode()).hexdigest()
+        thumbs_cache_dir = get_dir(os.path.join(xdg_cache_home(),
+                                   "project_thumbs", resolution))
+        return os.path.join(thumbs_cache_dir, thumb_hash) + ".png"
+
+    @classmethod
+    def get_thumb(cls, uri):
+        """Gets the project thumb, if exists, else the default thumb or None."""
+        try:
+            thumb = GdkPixbuf.Pixbuf.new_from_file(cls.get_thumb_path(uri, SCALED_THUMB_DIR))
+        except GLib.Error:
+            # Try to get the default thumb.
+            try:
+                thumb = Gtk.IconTheme.get_default().load_icon("video-x-generic", 128, 0)
+            except GLib.Error:
+                return None
+            thumb = scale_pixbuf(thumb, SCALED_THUMB_WIDTH, SCALED_THUMB_HEIGHT)
+
+        return thumb
+
+    def __create_scaled_thumb(self):
+        """Creates scaled thumbnail from the original thumbnail."""
+        try:
+            thumb = GdkPixbuf.Pixbuf.new_from_file(self.get_thumb_path(self.uri, ORIGINAL_THUMB_DIR))
+            thumb = scale_pixbuf(thumb, SCALED_THUMB_WIDTH, SCALED_THUMB_HEIGHT)
+            thumb.savev(self.get_thumb_path(self.uri, SCALED_THUMB_DIR), "png", [], [])
+        except GLib.Error as e:
+            self.warning("Failed to create scaled project thumbnail: %s", e)
+
+    def __remove_thumbs(self):
+        """Removes existing project thumbnails."""
+        for thumb_dir in (ORIGINAL_THUMB_DIR, SCALED_THUMB_DIR):
+            try:
+                os.remove(self.get_thumb_path(self.uri, thumb_dir))
+            except FileNotFoundError:
+                pass
+
+    def create_thumb(self):
+        """Creates project thumbnails."""
+        thumb_path = self.get_thumb_path(self.uri, ORIGINAL_THUMB_DIR)
+
+        if os.path.exists(thumb_path) and not self.app.action_log.has_assets_operations():
+            # The project thumbnail already exists and the assets are the same.
+            return
+
+        # Project Thumbnail Generation Approach: Out of thumbnails of all
+        # the assets in the current project, the one with maximum file size
+        # will be our project thumbnail - http://bit.ly/thumbnail-generation
+
+        assets_uri = [asset.props.id for asset in self.listSources()]
+        normal_thumb_path = None
+        large_thumb_path = None
+        normal_thumb_size = 0
+        large_thumb_size = 0
+        n_normal_thumbs = 0
+        n_large_thumbs = 0
+
+        for uri in assets_uri:
+            path_128, path_256 = AssetThumbnail.get_asset_thumbnails_path(uri)
+
+            try:
+                thumb_size = os.stat(path_128).st_size
+                if thumb_size > normal_thumb_size:
+                    normal_thumb_path = path_128
+                    normal_thumb_size = thumb_size
+                n_normal_thumbs += 1
+            except FileNotFoundError:
+                # The asset is missing the normal thumbnail.
+                pass
+
+            try:
+                thumb_size = os.stat(path_256).st_size
+                if thumb_size > large_thumb_size:
+                    large_thumb_path = path_256
+                    large_thumb_size = thumb_size
+                n_large_thumbs += 1
+            except FileNotFoundError:
+                # The asset is missing the large thumbnail.
+                pass
+
+        if normal_thumb_path or large_thumb_path:
+            # Use the category for which we found the max number of
+            # thumbnails to find the most complex thumbnail, because
+            # we can't compare the small with the large.
+            if n_normal_thumbs > n_large_thumbs:
+                shutil.copyfile(normal_thumb_path, thumb_path)
+            else:
+                shutil.copyfile(large_thumb_path, thumb_path)
+            self.__create_scaled_thumb()
+        else:
+            # No asset thumbs available, so remove the existing
+            # project thumbnails, if any.
+            self.__remove_thumbs()
+
     def set_rendering(self, rendering):
         """Sets the a/v restrictions for rendering or for editing."""
         self._ensureAudioRestrictions()
@@ -1470,7 +1580,7 @@ class Project(Loggable, GES.Project):
         Args:
             uris (List[str]): The URIs of the assets.
         """
-        with self.app.action_log.started("Adding assets"):
+        with self.app.action_log.started("assets-addition"):
             for uri in uris:
                 if self.create_asset(quote_uri(uri), GES.UriClip):
                     # The asset was not already part of the project.
diff --git a/pitivi/undo/undo.py b/pitivi/undo/undo.py
index 9c826a9e..692b09d3 100644
--- a/pitivi/undo/undo.py
+++ b/pitivi/undo/undo.py
@@ -358,6 +358,13 @@ class UndoableActionLog(GObject.Object, Loggable):
         """Gets whether currently recording an operation."""
         return bool(self.stacks)
 
+    def has_assets_operations(self):
+        """Checks whether user added/removed assets while working on the project."""
+        for stack in self.undo_stacks:
+            if stack.action_group_name in ["assets-addition", "assets-removal"]:
+                return True
+        return False
+
 
 class MetaChangedAction(UndoableAction):
 
diff --git a/pitivi/utils/misc.py b/pitivi/utils/misc.py
index 88068ce4..dc59bdc7 100644
--- a/pitivi/utils/misc.py
+++ b/pitivi/utils/misc.py
@@ -28,6 +28,7 @@ from urllib.parse import unquote
 from urllib.parse import urlparse
 from urllib.parse import urlsplit
 
+from gi.repository import GdkPixbuf
 from gi.repository import GES
 from gi.repository import GLib
 from gi.repository import Gst
@@ -40,6 +41,22 @@ from pitivi.configure import APPNAME
 from pitivi.utils.threads import Thread
 
 
+def scale_pixbuf(pixbuf, width, height):
+    """Scales the given pixbuf preserving the original aspect ratio."""
+    pixbuf_width = pixbuf.props.width
+    pixbuf_height = pixbuf.props.height
+
+    if pixbuf_width > width:
+        pixbuf_height = width * pixbuf_height / pixbuf_width
+        pixbuf_width = width
+
+    if pixbuf_height > height:
+        pixbuf_width = height * pixbuf_width / pixbuf_height
+        pixbuf_height = height
+
+    return pixbuf.scale_simple(pixbuf_width, pixbuf_height, GdkPixbuf.InterpType.BILINEAR)
+
+
 # Work around https://bugzilla.gnome.org/show_bug.cgi?id=759249
 def disconnectAllByFunc(obj, func):
     i = 0
diff --git a/pitivi/utils/ui.py b/pitivi/utils/ui.py
index 424b6de6..cbfa56da 100644
--- a/pitivi/utils/ui.py
+++ b/pitivi/utils/ui.py
@@ -149,6 +149,10 @@ GREETER_PERSPECTIVE_CSS = """
     #recent_projects_label {
         font-weight: bold;
     }
+
+    #project_thumbnail_box {
+        background-color: #181818;
+    }
 """
 
 TIMELINE_CSS = """
diff --git a/tests/test_misc.py b/tests/test_misc.py
index bd4dc04f..b80ad6dc 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -19,13 +19,45 @@
 """Tests for the utils.misc module."""
 # pylint: disable=protected-access,no-self-use
 import os
+from unittest import mock
 
+from gi.repository import GdkPixbuf
 from gi.repository import Gst
 
 from pitivi.utils.misc import PathWalker
+from pitivi.utils.misc import scale_pixbuf
 from tests import common
 
 
+class MiscMethodsTest(common.TestCase):
+    """Tests methods in utils.misc module."""
+
+    # pylint: disable=too-many-arguments
+    def check_pixbuf_scaling(self, pixbuf_width, pixbuf_height,
+                             width, height,
+                             expected_width, expected_height):
+        """Checks pixbuf scaling."""
+        pixbuf = mock.Mock()
+        pixbuf.props.width = pixbuf_width
+        pixbuf.props.height = pixbuf_height
+        _ = scale_pixbuf(pixbuf, width, height)
+        pixbuf.scale_simple.assert_called_once_with(expected_width, expected_height, 
GdkPixbuf.InterpType.BILINEAR)
+
+    def test_scale_pixbuf(self):
+        """Tests pixbuf scaling."""
+        # Larger, same aspect ratio.
+        self.check_pixbuf_scaling(200, 100, 20, 10, 20, 10)
+        # Larger, wider aspect ratio.
+        self.check_pixbuf_scaling(200, 50, 20, 10, 20, 5)
+        # Larger, taller aspect ratio.
+        self.check_pixbuf_scaling(100, 200, 20, 10, 5, 10)
+
+        # Smaller.
+        self.check_pixbuf_scaling(1, 1, 20, 10, 1, 1)
+        self.check_pixbuf_scaling(20, 1, 20, 10, 20, 1)
+        self.check_pixbuf_scaling(1, 10, 20, 10, 1, 10)
+
+
 class PathWalkerTest(common.TestCase):
     """Tests for the `PathWalker` class."""
 
diff --git a/tests/test_undo_project.py b/tests/test_undo_project.py
index 3a2688a4..3f8630f4 100644
--- a/tests/test_undo_project.py
+++ b/tests/test_undo_project.py
@@ -63,10 +63,13 @@ class TestProjectUndo(common.TestCase):
 
         mainloop.run()
 
+        self.assertTrue(self.action_log.has_assets_operations())
         self.assertEqual(len(self.project.list_assets(GES.Extractable)), 1)
         self.action_log.undo()
+        self.assertFalse(self.action_log.has_assets_operations())
         self.assertEqual(len(self.project.list_assets(GES.Extractable)), 0)
         self.action_log.redo()
+        self.assertTrue(self.action_log.has_assets_operations())
         self.assertEqual(len(self.project.list_assets(GES.Extractable)), 1)
 
     def test_use_proxy(self):


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