OpenShot Video Editor  2.0.0
files_treeview.py
Go to the documentation of this file.
1 ##
2 #
3 # @file
4 # @brief This file contains the project file treeview, used by the main window
5 # @author Noah Figg <eggmunkee@hotmail.com>
6 # @author Jonathan Thomas <jonathan@openshot.org>
7 # @author Olivier Girard <eolinwen@gmail.com>
8 #
9 # @section LICENSE
10 #
11 # Copyright (c) 2008-2018 OpenShot Studios, LLC
12 # (http://www.openshotstudios.com). This file is part of
13 # OpenShot Video Editor (http://www.openshot.org), an open-source project
14 # dedicated to delivering high quality video editing and animation solutions
15 # to the world.
16 #
17 # OpenShot Video Editor is free software: you can redistribute it and/or modify
18 # it under the terms of the GNU General Public License as published by
19 # the Free Software Foundation, either version 3 of the License, or
20 # (at your option) any later version.
21 #
22 # OpenShot Video Editor is distributed in the hope that it will be useful,
23 # but WITHOUT ANY WARRANTY; without even the implied warranty of
24 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25 # GNU General Public License for more details.
26 #
27 # You should have received a copy of the GNU General Public License
28 # along with OpenShot Library. If not, see <http://www.gnu.org/licenses/>.
29 #
30 
31 import os
32 import glob
33 import re
34 import sys
35 from urllib.parse import urlparse
36 
37 from PyQt5.QtCore import QSize, Qt, QPoint
38 from PyQt5.QtGui import *
39 from PyQt5.QtWidgets import QTreeView, QMessageBox, QAbstractItemView, QMenu, QSizePolicy, QHeaderView
40 import openshot # Python module for libopenshot (required video editing module installed separately)
41 
42 from classes.query import File
43 from classes.logger import log
44 from classes.app import get_app
45 from windows.models.files_model import FilesModel
46 
47 try:
48  import json
49 except ImportError:
50  import simplejson as json
51 
52 
53 ##
54 # A TreeView QWidget used on the main window
55 class FilesTreeView(QTreeView):
56  drag_item_size = 48
57 
58  def updateSelection(self):
59 
60  # Track selected items
61  self.selected = self.selectionModel().selectedIndexes()
62 
63  # Track selected file ids on main window
64  rows = []
65  self.win.selected_files = []
66  for selection in self.selected:
67  selected_row = self.files_model.model.itemFromIndex(selection).row()
68  if selected_row not in rows:
69  self.win.selected_files.append(self.files_model.model.item(selected_row, 5).text())
70  rows.append(selected_row)
71 
72  def contextMenuEvent(self, event):
73  # Update selection
74  self.updateSelection()
75 
76  # Set context menu mode
77  app = get_app()
78  app.context_menu_object = "files"
79 
80  menu = QMenu(self)
81 
82  menu.addAction(self.win.actionImportFiles)
83  menu.addAction(self.win.actionThumbnailView)
84  if self.selected:
85  # If file selected, show file related options
86  menu.addSeparator()
87 
88  # Add edit title option (if svg file)
89  selected_file_id = self.win.selected_files[0]
90  file = File.get(id=selected_file_id)
91  if file and file.data.get("path").endswith(".svg"):
92  menu.addAction(self.win.actionEditTitle)
93  menu.addAction(self.win.actionDuplicateTitle)
94  menu.addSeparator()
95 
96  menu.addAction(self.win.actionPreview_File)
97  menu.addAction(self.win.actionSplitClip)
98  menu.addAction(self.win.actionAdd_to_Timeline)
99  menu.addAction(self.win.actionFile_Properties)
100  menu.addSeparator()
101  menu.addAction(self.win.actionRemove_from_Project)
102  menu.addSeparator()
103 
104  # Show menu
105  menu.exec_(QCursor.pos())
106 
107  def dragEnterEvent(self, event):
108  # If dragging urls onto widget, accept
109  if event.mimeData().hasUrls():
110  event.setDropAction(Qt.CopyAction)
111  event.accept()
112 
113  ##
114  # Override startDrag method to display custom icon
115  def startDrag(self, event):
116 
117  # Get image of selected item
118  selected_row = self.files_model.model.itemFromIndex(self.selectionModel().selectedIndexes()[0]).row()
119  icon = self.files_model.model.item(selected_row, 0).icon()
120 
121  # Start drag operation
122  drag = QDrag(self)
123  drag.setMimeData(self.files_model.model.mimeData(self.selectionModel().selectedIndexes()))
124  drag.setPixmap(icon.pixmap(QSize(self.drag_item_size, self.drag_item_size)))
125  drag.setHotSpot(QPoint(self.drag_item_size / 2, self.drag_item_size / 2))
126  drag.exec_()
127 
128  # Without defining this method, the 'copy' action doesn't show with cursor
129  def dragMoveEvent(self, event):
130  pass
131 
132  def is_image(self, file):
133  path = file["path"].lower()
134 
135  if path.endswith((".jpg", ".jpeg", ".png", ".bmp", ".svg", ".thm", ".gif", ".bmp", ".pgm", ".tif", ".tiff")):
136  return True
137  else:
138  return False
139 
140  def add_file(self, filepath):
141  path, filename = os.path.split(filepath)
142 
143  # Add file into project
144  app = get_app()
145  _ = get_app()._tr
146 
147  # Check for this path in our existing project data
148  file = File.get(path=filepath)
149 
150  # If this file is already found, exit
151  if file:
152  return
153 
154  # Load filepath in libopenshot clip object (which will try multiple readers to open it)
155  clip = openshot.Clip(filepath)
156 
157  # Get the JSON for the clip's internal reader
158  try:
159  reader = clip.Reader()
160  file_data = json.loads(reader.Json())
161 
162  # Determine media type
163  if file_data["has_video"] and not self.is_image(file_data):
164  file_data["media_type"] = "video"
165  elif file_data["has_video"] and self.is_image(file_data):
166  file_data["media_type"] = "image"
167  elif file_data["has_audio"] and not file_data["has_video"]:
168  file_data["media_type"] = "audio"
169 
170  # Save new file to the project data
171  file = File()
172  file.data = file_data
173 
174  # Is this file an image sequence / animation?
175  image_seq_details = self.get_image_sequence_details(filepath)
176  if image_seq_details:
177  # Update file with correct path
178  folder_path = image_seq_details["folder_path"]
179  file_name = image_seq_details["file_path"]
180  base_name = image_seq_details["base_name"]
181  fixlen = image_seq_details["fixlen"]
182  digits = image_seq_details["digits"]
183  extension = image_seq_details["extension"]
184 
185  if not fixlen:
186  zero_pattern = "%d"
187  else:
188  zero_pattern = "%%0%sd" % digits
189 
190  # Generate the regex pattern for this image sequence
191  pattern = "%s%s.%s" % (base_name, zero_pattern, extension)
192 
193  # Split folder name
194  (parentPath, folderName) = os.path.split(folder_path)
195  if not base_name:
196  # Give alternate name
197  file.data["name"] = "%s (%s)" % (folderName, pattern)
198 
199  # Load image sequence (to determine duration and video_length)
200  image_seq = openshot.Clip(os.path.join(folder_path, pattern))
201 
202  # Update file details
203  file.data["path"] = os.path.join(folder_path, pattern)
204  file.data["media_type"] = "video"
205  file.data["duration"] = image_seq.Reader().info.duration
206  file.data["video_length"] = image_seq.Reader().info.video_length
207 
208  # Save file
209  file.save()
210  return True
211 
212  except:
213  # Handle exception
214  msg = QMessageBox()
215  msg.setText(_("{} is not a valid video, audio, or image file.".format(filename)))
216  msg.exec_()
217  return False
218 
219  ##
220  # Inspect a file path and determine if this is an image sequence
221  def get_image_sequence_details(self, file_path):
222 
223  # Get just the file name
224  (dirName, fileName) = os.path.split(file_path)
225  extensions = ["png", "jpg", "jpeg", "gif", "tif"]
226  match = re.findall(r"(.*[^\d])?(0*)(\d+)\.(%s)" % "|".join(extensions), fileName, re.I)
227 
228  if not match:
229  # File name does not match an image sequence
230  return None
231  else:
232  # Get the parts of image name
233  base_name = match[0][0]
234  fixlen = match[0][1] > ""
235  number = int(match[0][2])
236  digits = len(match[0][1] + match[0][2])
237  extension = match[0][3]
238 
239  full_base_name = os.path.join(dirName, base_name)
240 
241  # Check for images which the file names have the different length
242  fixlen = fixlen or not (glob.glob("%s%s.%s" % (full_base_name, "[0-9]" * (digits + 1), extension))
243  or glob.glob(
244  "%s%s.%s" % (full_base_name, "[0-9]" * ((digits - 1) if digits > 1 else 3), extension)))
245 
246  # Check for previous or next image
247  for x in range(max(0, number - 100), min(number + 101, 50000)):
248  if x != number and os.path.exists("%s%s.%s" % (
249  full_base_name, str(x).rjust(digits, "0") if fixlen else str(x), extension)):
250  is_sequence = True
251  break
252  else:
253  is_sequence = False
254 
255  if is_sequence and dirName not in self.ignore_image_sequence_paths:
256  log.info('Prompt user to import image sequence')
257  # Ignore this path (temporarily)
258  self.ignore_image_sequence_paths.append(dirName)
259 
260  # Translate object
261  _ = get_app()._tr
262 
263  # Handle exception
264  ret = QMessageBox.question(self, _("Import Image Sequence"), _("Would you like to import %s as an image sequence?") % fileName, QMessageBox.No | QMessageBox.Yes)
265  if ret == QMessageBox.Yes:
266  # Yes, import image sequence
267  parameters = {"file_path":file_path, "folder_path":dirName, "base_name":base_name, "fixlen":fixlen, "digits":digits, "extension":extension}
268  return parameters
269  else:
270  return None
271  else:
272  return None
273 
274  # Handle a drag and drop being dropped on widget
275  def dropEvent(self, event):
276  # Reset list of ignored image sequences paths
278 
279  for uri in event.mimeData().urls():
280  log.info('Processing drop event for {}'.format(uri))
281  filepath = uri.toLocalFile()
282  if os.path.exists(filepath) and os.path.isfile(filepath):
283  log.info('Adding file: {}'.format(filepath))
284  if ".osp" in filepath:
285  # Auto load project passed as argument
286  self.win.OpenProjectSignal.emit(filepath)
287  event.accept()
288  else:
289  # Auto import media file
290  if self.add_file(filepath):
291  event.accept()
292 
293  def clear_filter(self):
294  if self:
295  self.win.filesFilter.setText("")
296 
297  def filter_changed(self):
298  if self:
299  if self.win.filesFilter.text() == "":
300  self.win.actionFilesClear.setEnabled(False)
301  else:
302  self.win.actionFilesClear.setEnabled(True)
303  self.refresh_view()
304 
305  def refresh_view(self):
306  self.files_model.update_model()
307 
308  ##
309  # Resize and hide certain columns
310  def refresh_columns(self):
311  if type(self) == FilesTreeView:
312  # Only execute when the treeview is active
313  self.hideColumn(3)
314  self.hideColumn(4)
315  self.hideColumn(5)
316  self.resize_contents()
317 
318  def resize_contents(self):
319  # Get size of widget
320  thumbnail_width = 78
321  tags_width = 75
322 
323  # Resize thumbnail and tags column
324  self.header().resizeSection(0, thumbnail_width)
325  self.header().resizeSection(2, tags_width)
326 
327  # Set stretch mode on certain columns
328  self.header().setStretchLastSection(False)
329  self.header().setSectionResizeMode(1, QHeaderView.Stretch)
330  self.header().setSectionResizeMode(2, QHeaderView.Interactive)
331 
332  def currentChanged(self, selected, deselected):
333  log.info('currentChanged')
334  self.updateSelection()
335 
336  ##
337  # Name or tags updated
338  def value_updated(self, item):
339  # Get translation method
340  _ = get_app()._tr
341 
342  # Determine what was changed
343  file_id = self.files_model.model.item(item.row(), 5).text()
344  name = self.files_model.model.item(item.row(), 1).text()
345  tags = self.files_model.model.item(item.row(), 2).text()
346 
347  # Get file object and update friendly name and tags attribute
348  f = File.get(id=file_id)
349  if name != f.data["path"]:
350  f.data["name"] = name
351  else:
352  f.data["name"] = ""
353  if "tags" in f.data.keys():
354  if tags != f.data["tags"]:
355  f.data["tags"] = tags
356  elif tags:
357  f.data["tags"] = tags
358 
359  # Tell file model to ignore updates (since this treeview will already be updated)
360  self.files_model.ignore_update_signal = True
361 
362  # Save File
363  f.save()
364 
365  # Re-enable updates
366  self.files_model.ignore_update_signal = False
367 
368  ##
369  # Remove signal handlers and prepare for deletion
371  try:
372  self.files_model.model.ModelRefreshed.disconnect()
373  except:
374  pass
375 
376  def __init__(self, *args):
377  # Invoke parent init
378  QTreeView.__init__(self, *args)
379 
380  # Get a reference to the window object
381  self.win = get_app().window
382 
383  # Get Model data
384  self.files_model = FilesModel()
385 
386  # Keep track of mouse press start position to determine when to start drag
387  self.setAcceptDrops(True)
388  self.setDragEnabled(True)
389  self.setDropIndicatorShown(True)
390  self.selected = []
392 
393  # Setup header columns
394  self.setModel(self.files_model.model)
395  self.setIconSize(QSize(75, 62))
396  self.setIndentation(0)
397  self.setSelectionBehavior(QTreeView.SelectRows)
398  self.setSelectionMode(QAbstractItemView.ExtendedSelection)
399  self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
400  self.setWordWrap(False)
401  self.setTextElideMode(Qt.ElideRight)
402  self.setStyleSheet('QTreeView::item { padding-top: 2px; }')
403  self.files_model.model.ModelRefreshed.connect(self.refresh_columns)
404 
405  # Refresh view
406  self.refresh_view()
407  self.refresh_columns()
408 
409  # setup filter events
410  app = get_app()
411  app.window.filesFilter.textChanged.connect(self.filter_changed)
412  app.window.actionFilesClear.triggered.connect(self.clear_filter)
413  self.files_model.model.itemChanged.connect(self.value_updated)
def get_app
Returns the current QApplication instance of OpenShot.
Definition: app.py:55
A TreeView QWidget used on the main window.
def startDrag
Override startDrag method to display custom icon.
def get_image_sequence_details
Inspect a file path and determine if this is an image sequence.
def value_updated
Name or tags updated.
def refresh_columns
Resize and hide certain columns.
def prepare_for_delete
Remove signal handlers and prepare for deletion.