diff --git a/src/classes/project_data.py b/src/classes/project_data.py index 660053043..d1f8cd114 100644 --- a/src/classes/project_data.py +++ b/src/classes/project_data.py @@ -1107,6 +1107,30 @@ def changed(self, action): action.set_old_values(old_vals) # Save previous values to reverse this action self.has_unsaved_changes = True + if len(action.key) == 1 and action.key[0] in ["fps"]: + # FPS changed (apply profile) + profile_key = self._data.get("profile") + profile = self.get_profile(profile_key) + if profile: + # Get current FPS (prior to changing) + new_fps = self._data.get("fps") + new_fps_float = float(new_fps["num"]) / float(new_fps["den"]) + old_fps_float = float(old_vals["num"]) / float(old_vals["den"]) + fps_factor = float(new_fps_float / old_fps_float) + + if fps_factor != 1.0: + log.info(f"Convert {old_fps_float} FPS to {new_fps_float} FPS (profile: {profile.ShortName()})") + # Snap to new FPS grid (start, end, duration) + change_profile(self._data["clips"] + self._data["effects"], profile) + + # Rescale keyframes to match new FPS + self.rescale_keyframes(fps_factor) + + # Broadcast this change out + get_app().updates.load(self._data, reset_history=False) + else: + log.warning(f"No profile found for {profile_key}") + elif action.type == "delete": # Delete existing item old_vals = self._set(action.key, remove=True) diff --git a/src/classes/query.py b/src/classes/query.py index 11fa8016d..a02335ba6 100644 --- a/src/classes/query.py +++ b/src/classes/query.py @@ -25,8 +25,10 @@ along with OpenShot Library. If not, see . """ -import os import json +import os + +import openshot from classes import info from classes.app import get_app @@ -257,6 +259,60 @@ def relative_path(self): # Convert path to relative (based on current working directory of Python) return os.path.relpath(file_path, info.CWD) + def profile(self): + """ Get the profile of the file """ + # Create Profile object for current file + d = self.data + + # Calculate accurate DAR + pixel_ratio = openshot.Fraction(d.get("pixel_ratio", {})) + display_ratio = openshot.Fraction(d.get("display_ratio", {})) + if display_ratio.num == 1 and display_ratio.den == 1: + # Some audio / image files have inaccurate DAR - calculate from size + display_ratio = openshot.Fraction(round(d.get("width", 1) * pixel_ratio.ToFloat()), + round(d.get("height", 1) * pixel_ratio.ToFloat())) + display_ratio.Reduce() + + profile_dict = { + "display_ratio": + { + "den": display_ratio.den, + "num": display_ratio.num, + }, + "fps": + { + "den": d.get("fps", {}).get("den", 1), + "num": d.get("fps", {}).get("num", 1), + }, + "height": d.get("height", 1), + "interlaced_frame": d.get("interlaced_frame", False), + "pixel_format": d.get("pixel_format", None), + "pixel_ratio": + { + "den": d.get("pixel_ratio", {}).get("den", 1), + "num": d.get("pixel_ratio", {}).get("num", 1), + }, + "width": d.get("width", 1) + } + file_profile = openshot.Profile() + file_profile.SetJson(json.dumps(profile_dict)) + + # Load all possible profiles + for profile_folder in [info.USER_PROFILES_PATH, info.PROFILES_PATH]: + for file in reversed(sorted(os.listdir(profile_folder))): + profile_path = os.path.join(profile_folder, file) + if os.path.isdir(profile_path): + continue + try: + # Load Profile + profile = openshot.Profile(profile_path) + print(profile.Key(), file_profile.Key()) + if profile.Key() == file_profile.Key(): + return profile + except RuntimeError as e: + pass + return file_profile + class Marker(QueryObject): """ This class allows Markers to be queried, updated, and deleted from the project data. """ diff --git a/src/windows/main_window.py b/src/windows/main_window.py index e9946f629..e0edb7a6c 100644 --- a/src/windows/main_window.py +++ b/src/windows/main_window.py @@ -1753,21 +1753,27 @@ def initShortcuts(self): # Log shortcut initialization completion log.debug("Shortcuts initialized or updated.") - def actionProfile_trigger(self): - # Show dialog - from windows.profile import Profile - log.debug("Showing profile dialog") - - # Get current project profile description - current_project_profile_desc = get_app().project.get(['profile']) - + def actionProfile_trigger(self, profile=None): # Disable video caching openshot.Settings.Instance().ENABLE_PLAYBACK_CACHING = False - win = Profile(current_project_profile_desc) - # Run the dialog event loop - blocking interaction on this window during this time - result = win.exec_() - profile = win.selected_profile + # Show Profile dialog (if profile not passed) + if not profile: + # Show dialog + from windows.profile import Profile + log.debug("Showing profile dialog") + + # Get current project profile description + current_project_profile_desc = get_app().project.get(['profile']) + + win = Profile(current_project_profile_desc) + result = win.exec_() + profile = win.selected_profile + else: + # Profile passed in alraedy + result = QDialog.Accepted + + # Update profile (if changed) if result == QDialog.Accepted and profile and profile.info.description != get_app().project.get(['profile']): proj = get_app().project @@ -1792,8 +1798,12 @@ def actionProfile_trigger(self): # Check for audio-only files if file.data.get("has_audio") and not file.data.get("has_video"): # Audio-only file should match the current project size and FPS - file.data["width"] = proj.get("width") - file.data["height"] = proj.get("height") + file.data["width"] = profile.info.width + file.data["height"] = profile.info.height + display_ratio = openshot.Fraction(file.data["width"], file.data["height"]) + display_ratio.Reduce() + file.data["display_ratio"]["num"] = display_ratio.num + file.data["display_ratio"]["den"] = display_ratio.den file.save() # Change all related clips @@ -1801,23 +1811,17 @@ def actionProfile_trigger(self): clip.data["reader"] = file.data clip.save() + # Apply new profile (and any FPS precision updates) + get_app().updates.update(["profile"], profile.info.description) + get_app().updates.update(["width"], profile.info.width) + get_app().updates.update(["height"], profile.info.height) + get_app().updates.update(["display_ratio"], {"num": profile.info.display_ratio.num, "den": profile.info.display_ratio.den}) + get_app().updates.update(["pixel_ratio"], {"num": profile.info.pixel_ratio.num, "den": profile.info.pixel_ratio.den}) + get_app().updates.update(["fps"], {"num": profile.info.fps.num, "den": profile.info.fps.den}) + # Clear transaction id get_app().updates.transaction_id = None - # Rescale all keyframes and reload project - if fps_factor != 1.0: - # Rescale keyframes (if FPS changed) - proj.rescale_keyframes(fps_factor) - - # Apply new profile (and any FPS precision updates) - proj.apply_profile(profile) - - # Distribute all project data through update manager - get_app().updates.load(proj._data, reset_history=False) - - # Force ApplyMapperToClips to apply these changes - self.timeline_sync.timeline.ApplyMapperToClips() - # Seek to the same location, adjusted for new frame rate self.SeekSignal.emit(adjusted_frame) diff --git a/src/windows/models/files_model.py b/src/windows/models/files_model.py index 0ce814a22..2cd6870c2 100644 --- a/src/windows/models/files_model.py +++ b/src/windows/models/files_model.py @@ -391,8 +391,8 @@ def add_files(self, files, image_seq_details=None, quiet=False, # Log exception log.warning("Failed to import {}: {}".format(filepath, ex)) - if not quiet: - # Show message box to user + if not quiet and start_count == 1: + # Show message box to user (if importing a single file) app.window.invalidImage(filename) # Reset list of ignored paths diff --git a/src/windows/views/files_listview.py b/src/windows/views/files_listview.py index 568b6736c..8cf661048 100644 --- a/src/windows/views/files_listview.py +++ b/src/windows/views/files_listview.py @@ -27,7 +27,7 @@ """ from PyQt5.QtCore import QSize, Qt, QPoint, QRegExp -from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter +from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon from PyQt5.QtWidgets import QListView, QAbstractItemView from classes import info @@ -47,6 +47,7 @@ def contextMenuEvent(self, event): # Set context menu mode app = get_app() + _ = app._tr app.context_menu_object = "files" index = self.indexAt(event.pos()) @@ -81,6 +82,23 @@ def contextMenuEvent(self, event): menu.addAction(self.win.actionExportFiles) menu.addSeparator() menu.addAction(self.win.actionAdd_to_Timeline) + + # Add Profile menu + profile_menu = StyledContextMenu(title=_("Choose Profile"), parent=self) + profile_icon = get_app().window.actionProfile.icon() + profile_missing_icon = QIcon(":/icons/Humanity/actions/16/list-add.svg") + profile_menu.setIcon(profile_icon) + + # Get file's profile + file_profile = file.profile() + if file_profile.info.description: + action = profile_menu.addAction(profile_icon, _(f"{file_profile.info.description}")) + action.triggered.connect(lambda: get_app().window.actionProfile_trigger(file_profile)) + else: + action = profile_menu.addAction(profile_missing_icon, _(f"Create Profile: {file_profile.ShortName()}")) + #action.triggered.connect(lambda: get_app().window.actionProfile_trigger(file_profile)) + menu.addMenu(profile_menu) + menu.addAction(self.win.actionFile_Properties) menu.addSeparator() menu.addAction(self.win.actionRemove_from_Project) diff --git a/src/windows/views/files_treeview.py b/src/windows/views/files_treeview.py index 96fb19097..800d173cb 100644 --- a/src/windows/views/files_treeview.py +++ b/src/windows/views/files_treeview.py @@ -30,7 +30,7 @@ import os from PyQt5.QtCore import QSize, Qt, QPoint -from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter +from PyQt5.QtGui import QDrag, QCursor, QPixmap, QPainter, QIcon from PyQt5.QtWidgets import QTreeView, QAbstractItemView, QSizePolicy, QHeaderView from classes import info @@ -49,6 +49,7 @@ def contextMenuEvent(self, event): # Set context menu mode app = get_app() + _ = app._tr app.context_menu_object = "files" event.accept() @@ -84,6 +85,23 @@ def contextMenuEvent(self, event): menu.addAction(self.win.actionExportFiles) menu.addSeparator() menu.addAction(self.win.actionAdd_to_Timeline) + + # Add Profile menu + profile_menu = StyledContextMenu(title=_("Choose Profile"), parent=self) + profile_icon = get_app().window.actionProfile.icon() + profile_missing_icon = QIcon(":/icons/Humanity/actions/16/list-add.svg") + profile_menu.setIcon(profile_icon) + + # Get file's profile + file_profile = file.profile() + if file_profile.info.description: + action = profile_menu.addAction(profile_icon, _(f"{file_profile.info.description}")) + action.triggered.connect(lambda: get_app().window.actionProfile_trigger(file_profile)) + else: + action = profile_menu.addAction(profile_missing_icon, _(f"Create Profile: {file_profile.ShortName()}")) + #action.triggered.connect(lambda: get_app().window.actionProfile_trigger(file_profile)) + menu.addMenu(profile_menu) + menu.addAction(self.win.actionFile_Properties) menu.addSeparator() menu.addAction(self.win.actionRemove_from_Project)