diff --git a/src/windows/main_window.py b/src/windows/main_window.py index 302907526f..a8434aae92 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -755,22 +755,27 @@ def actionSaveAs_trigger(self, event): def actionImportFiles_trigger(self, event): app = get_app() _ = app._tr + recommended_path = app.project.get("import_path") if not recommended_path or not os.path.exists(recommended_path): recommended_path = os.path.join(info.HOME_PATH) - files = QFileDialog.getOpenFileNames(self, _("Import File..."), recommended_path)[0] - # Set cursor to waiting - get_app().setOverrideCursor(QCursor(Qt.WaitCursor)) + qurl_list = QFileDialog.getOpenFileUrls( + self, _("Import Files..."), + QUrl.fromLocalFile(recommended_path))[0] - # Import list of files - self.files_model.add_files(files) + # Set cursor to waiting + app.setOverrideCursor(QCursor(Qt.WaitCursor)) - # Restore cursor - get_app().restoreOverrideCursor() + try: + # Import list of files + self.files_model.process_urls(qurl_list) - # Refresh files views - self.refreshFilesSignal.emit() + # Refresh files views + self.refreshFilesSignal.emit() + finally: + # Restore cursor + app.restoreOverrideCursor() def invalidImage(self, filename=None): """ Show a popup when an image file can't be loaded """ @@ -1387,9 +1392,9 @@ def getTimelineObjectPositions(obj): # Add all Effect keyframes if "effects" in obj.data: for effect_data in obj.data["effects"]: - for property in effect_data: + for prop in effect_data: try: - for point in effect_data[property]["Points"]: + for point in effect_data[prop]["Points"]: keyframe_time = (point["co"]["X"]-1)/fps_float + clip_orig_time if clip_start_time < keyframe_time < clip_stop_time: positions.append(keyframe_time) @@ -1773,12 +1778,12 @@ def actionRemove_from_Project_trigger(self, event): if not f: continue - id = f.data["id"] + f_id = f.data["id"] # Remove file f.delete() # Find matching clips (if any) - clips = Clip.filter(file_id=id) + clips = Clip.filter(file_id=f_id) for c in clips: # Remove clip c.delete() @@ -2084,14 +2089,14 @@ def showDocks(self, docks): dock.show() def freezeDocks(self): - """ Freeze all dockable widgets on the main screen. + """ Freeze all dockable widgets on the main screen (prevent them being closed, floated, or moved) """ for dock in self.getDocks(): if self.dockWidgetArea(dock) != Qt.NoDockWidgetArea: dock.setFeatures(QDockWidget.NoDockWidgetFeatures) def unFreezeDocks(self): - """ Un-freeze all dockable widgets on the main screen. + """ Un-freeze all dockable widgets on the main screen (allow them to be closed, floated, or moved, as appropriate) """ for dock in self.getDocks(): if self.dockWidgetArea(dock) != Qt.NoDockWidgetArea: @@ -2393,7 +2398,7 @@ def load_settings(self): if s.get('window_geometry_v2'): self.restoreGeometry(qt_types.str_to_bytes(s.get('window_geometry_v2'))) if s.get('docks_frozen'): - """ Freeze all dockable widgets on the main screen """ + # Freeze all dockable widgets on the main screen self.freezeDocks() self.actionFreeze_View.setVisible(False) self.actionUn_Freeze_View.setVisible(True) diff --git a/src/windows/models/files_model.py b/src/windows/models/files_model.py index 3c4ae364e8..d3d0371a6d 100644 --- a/src/windows/models/files_model.py +++ b/src/windows/models/files_model.py @@ -30,6 +30,7 @@ import json import re import glob +import functools from PyQt5.QtCore import ( QMimeData, Qt, pyqtSignal, QEventLoop, QObject, @@ -126,7 +127,7 @@ def changed(self, action): self.update_model(clear=True) def update_model(self, clear=True, delete_file_id=None): - log.info("updating files model.") + log.debug("updating files model.") app = get_app() self.ignore_updates = True @@ -246,7 +247,7 @@ def update_model(self, clear=True, delete_file_id=None): # Emit signal when model is updated self.ModelRefreshed.emit() - def add_files(self, files, image_seq_details=None): + def add_files(self, files, image_seq_details=None, quiet=False): # Access translations app = get_app() _ = app._tr @@ -259,11 +260,12 @@ def add_files(self, files, image_seq_details=None): (dir_path, filename) = os.path.split(filepath) # Check for this path in our existing project data - file = File.get(path=filepath) + new_file = File.get(path=filepath) # If this file is already found, exit - if file: - return + if new_file: + del new_file + continue try: # Load filepath in libopenshot clip object (which will try multiple readers to open it) @@ -285,21 +287,19 @@ def add_files(self, files, image_seq_details=None): file_data["media_type"] = "video" # Save new file to the project data - file = File() - file.data = file_data - - if not image_seq_details: - # Try to discover image sequence, if not supplied - image_seq_details = self.get_image_sequence_details(filepath) + new_file = File() + new_file.data = file_data # Is this an image sequence / animation? - if image_seq_details: + seq_info = image_seq_details or self.get_image_sequence_details(filepath) + + if seq_info: # Update file with correct path - folder_path = image_seq_details["folder_path"] - base_name = image_seq_details["base_name"] - fixlen = image_seq_details["fixlen"] - digits = image_seq_details["digits"] - extension = image_seq_details["extension"] + folder_path = seq_info["folder_path"] + base_name = seq_info["base_name"] + fixlen = seq_info["fixlen"] + digits = seq_info["digits"] + extension = seq_info["extension"] if not fixlen: zero_pattern = "%d" @@ -313,32 +313,34 @@ def add_files(self, files, image_seq_details=None): folderName = os.path.basename(folder_path) if not base_name: # Give alternate name - file.data["name"] = "%s (%s)" % (folderName, pattern) + new_file.data["name"] = "%s (%s)" % (folderName, pattern) # Load image sequence (to determine duration and video_length) image_seq = openshot.Clip(os.path.join(folder_path, pattern)) # Update file details - file.data["path"] = os.path.join(folder_path, pattern) - file.data["media_type"] = "video" - file.data["duration"] = image_seq.Reader().info.duration - file.data["video_length"] = image_seq.Reader().info.video_length + new_file.data["path"] = os.path.join(folder_path, pattern) + new_file.data["media_type"] = "video" + new_file.data["duration"] = image_seq.Reader().info.duration + new_file.data["video_length"] = image_seq.Reader().info.video_length log.info('Imported {} as image sequence {}'.format( filepath, pattern)) # Remove any other image sequence files from the list we're processing match_glob = "{}{}.{}".format(base_name, '[0-9]*', extension) + log.debug("Removing files from import list with glob: {}".format(match_glob)) for seq_file in glob.iglob(os.path.join(folder_path, match_glob)): - if seq_file in files: + # Don't remove the current file, or we mess up the for loop + if seq_file in files and seq_file != filepath: files.remove(seq_file) - if not image_seq_details: + if not seq_info: # Log our not-an-image-sequence import log.info("Imported media file {}".format(filepath)) # Save file - file.save() + new_file.save() prev_path = app.project.get("import_path") if dir_path != prev_path: @@ -348,8 +350,9 @@ def add_files(self, files, image_seq_details=None): # Log exception log.warning("Failed to import {}: {}".format(filepath, ex)) - # Show message box to user - app.window.invalidImage(filename) + if not quiet: + # Show message box to user + app.window.invalidImage(filename) # Reset list of ignored paths self.ignore_image_sequence_paths = [] @@ -359,6 +362,11 @@ def get_image_sequence_details(self, file_path): # Get just the file name (dirName, fileName) = os.path.split(file_path) + + # Image sequence imports are one per directory per run + if dirName in self.ignore_image_sequence_paths: + return None + extensions = ["png", "jpg", "jpeg", "gif", "tif", "svg"] match = re.findall(r"(.*[^\d])?(0*)(\d+)\.(%s)" % "|".join(extensions), fileName, re.I) @@ -385,34 +393,67 @@ def get_image_sequence_details(self, file_path): for x in range(max(0, number - 100), min(number + 101, 50000)): if x != number and os.path.exists( "%s%s.%s" % (full_base_name, str(x).rjust(digits, "0") if fixlen else str(x), extension)): - is_sequence = True - break + break # found one! else: - is_sequence = False - - if is_sequence and dirName not in self.ignore_image_sequence_paths: - log.info('Prompt user to import image sequence from {}'.format(dirName)) - # Ignore this path (temporarily) - self.ignore_image_sequence_paths.append(dirName) - - if not get_app().window.promptImageSequence(fileName): - # User said no, don't import as a sequence - return None - - # Yes, import image sequence - parameters = { - "folder_path": dirName, - "base_name": base_name, - "fixlen": fixlen, - "digits": digits, - "extension": extension - } - return parameters - - # We didn't discover an image sequence - return None - - def get_thumb_path(self, file_id, thumbnail_frame, clear_cache=False): + # We didn't discover an image sequence + return None + + # Found a sequence, ignore this path (no matter what the user answers) + # To avoid issues with overlapping/conflicting sets of files, + # we only attempt one image sequence match per directory + log.debug("Ignoring path for image sequence imports: {}".format(dirName)) + self.ignore_image_sequence_paths.append(dirName) + + log.info('Prompt user to import sequence starting from {}'.format(fileName)) + if not get_app().window.promptImageSequence(fileName): + # User said no, don't import as a sequence + return None + + # Yes, import image sequence + parameters = { + "folder_path": dirName, + "base_name": base_name, + "fixlen": fixlen, + "digits": digits, + "extension": extension + } + return parameters + + def process_urls(self, qurl_list): + """Recursively process QUrls from a QDropEvent""" + import_quietly = False + media_paths = [] + + for uri in qurl_list: + filepath = uri.toLocalFile() + if not os.path.exists(filepath): + continue + if filepath.endswith(".osp") and os.path.isfile(filepath): + # Auto load project passed as argument + self.win.OpenProjectSignal.emit(filepath) + return True + if os.path.isdir(filepath): + import_quietly = True + log.info("Recursively importing {}".format(filepath)) + try: + for r, _, f in os.walk(filepath): + media_paths.extend( + [os.path.join(r, p) for p in f]) + except OSError: + log.warning("Directory recursion failed", exc_info=1) + elif os.path.isfile(filepath): + media_paths.append(filepath) + + # Import all new media files + if media_paths: + media_paths.sort() + log.debug("Importing file list: {}".format(media_paths)) + return self.add_files(media_paths, quiet=import_quietly) + else: + return False + + def get_thumb_path( + self, file_id, thumbnail_frame, clear_cache=False): """Get thumbnail path by invoking HTTP thumbnail request""" # Clear thumb cache (if requested) @@ -423,7 +464,11 @@ def get_thumb_path(self, file_id, thumbnail_frame, clear_cache=False): # Connect to thumbnail server and get image thumb_server_details = get_app().window.http_server_thread.server_address thumb_address = "http://%s:%s/thumbnails/%s/%s/path/%s" % ( - thumb_server_details[0], thumb_server_details[1], file_id, thumbnail_frame, thumb_cache) + thumb_server_details[0], + thumb_server_details[1], + file_id, + thumbnail_frame, + thumb_cache) r = get(thumb_address) if r.ok: # Update thumbnail path to real one @@ -519,6 +564,8 @@ def __init__(self, *args): # Connect signal app.window.FileUpdated.connect(self.update_file_thumbnail) + app.window.refreshFilesSignal.connect( + functools.partial(self.update_model, clear=False)) # Call init for superclass QObject super(QObject, FilesModel).__init__(self, *args) diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py index b37a7b1245..ed7bda9f29 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -26,8 +26,6 @@ along with OpenShot Library. If not, see . """ -import os - from PyQt5.QtCore import QSize, Qt, QPoint, QRegExp from PyQt5.QtGui import QDrag, QCursor from PyQt5.QtWidgets import QListView, QAbstractItemView, QMenu @@ -121,28 +119,22 @@ def dragMoveEvent(self, event): # Handle a drag and drop being dropped on widget def dropEvent(self, event): - # Set cursor to waiting - get_app().setOverrideCursor(QCursor(Qt.WaitCursor)) - - media_paths = [] - for uri in event.mimeData().urls(): - log.info('Processing drop event for {}'.format(uri)) - filepath = uri.toLocalFile() - if os.path.exists(filepath) and os.path.isfile(filepath): - log.info('Adding file: {}'.format(filepath)) - if ".osp" in filepath: - # Auto load project passed as argument - self.win.OpenProjectSignal.emit(filepath) - event.accept() - else: - media_paths.append(filepath) - - # Import all new media files - if media_paths and self.files_model.add_files(media_paths): - event.accept() - - # Restore cursor - get_app().restoreOverrideCursor() + # Use try/finally so we always reset the cursor + try: + # Set cursor to waiting + get_app().setOverrideCursor(QCursor(Qt.WaitCursor)) + + if not event.mimeData().hasUrls(): + return + + qurl_list = event.mimeData().urls() + log.info("Processing drop event for {} urls".format(len(qurl_list))) + result = self.files_model.process_urls(qurl_list) + if result: + event.accept() + finally: + # Restore cursor + get_app().restoreOverrideCursor() # Pass file add requests to the model def add_file(self, filepath): @@ -202,4 +194,3 @@ def __init__(self, model, *args): # setup filter events app = get_app() app.window.filesFilter.textChanged.connect(self.filter_changed) - app.window.refreshFilesSignal.connect(self.refresh_view) diff --git a/src/windows/views/files_treeview.py b/src/windows/views/files_treeview.py index d915489f7c..609bea4229 100644 --- a/src/windows/views/files_treeview.py +++ b/src/windows/views/files_treeview.py @@ -122,31 +122,22 @@ def dragMoveEvent(self, event): # Handle a drag and drop being dropped on widget def dropEvent(self, event): - # Reset list of ignored image sequences paths - self.ignore_image_sequence_paths = [] - - # Set cursor to waiting - get_app().setOverrideCursor(QCursor(Qt.WaitCursor)) - - media_paths = [] - for uri in event.mimeData().urls(): - log.info('Processing drop event for {}'.format(uri)) - filepath = uri.toLocalFile() - if os.path.exists(filepath) and os.path.isfile(filepath): - log.info('Adding file: {}'.format(filepath)) - if ".osp" in filepath: - # Auto load project passed as argument - self.win.OpenProjectSignal.emit(filepath) - event.accept() - else: - media_paths.append(filepath) - - # Import all new media files - if media_paths and self.files_model.add_files(media_paths): - event.accept() - - # Restore cursor - get_app().restoreOverrideCursor() + # Use try/finally so we always reset the cursor + try: + # Set cursor to waiting + get_app().setOverrideCursor(QCursor(Qt.WaitCursor)) + + if not event.mimeData().hasUrls(): + return + + qurl_list = event.mimeData().urls() + log.info("Processing drop event for {} urls".format(len(qurl_list))) + result = self.files_model.process_urls(qurl_list) + if result: + event.accept() + finally: + # Restore cursor + get_app().restoreOverrideCursor() # Forward file-add requests to the model, for legacy code (previous API) def add_file(self, filepath): @@ -191,10 +182,10 @@ def value_updated(self, item): # Get file object and update friendly name and tags attribute f = File.get(id=file_id) - if name and name != os.path.split(f.data["path"])[-1]: + if name and name != os.path.basename(f.data["path"]): f.data["name"] = name else: - f.data["name"] = os.path.split(f.data["path"])[-1] + f.data["name"] = os.path.basename(f.data["path"]) if "tags" in f.data.keys(): if tags != f.data["tags"]: