From 4ac8d0f1cab90416900871f357888c6092fa27f3 Mon Sep 17 00:00:00 2001 From: Gergely Wootsch Date: Mon, 16 Sep 2024 00:56:29 +0200 Subject: [PATCH] Reimplemented the new C++ api calls and fixed related bugs. --- bookmarks/actions.py | 2 +- bookmarks/application_launcher.py | 1 + bookmarks/common/env.py | 94 +++--- bookmarks/common/settings.py | 2 + bookmarks/common/setup.py | 1 + bookmarks/editor/asset_properties.py | 2 +- bookmarks/editor/base_widgets.py | 15 +- bookmarks/external/ffmpeg_widget.py | 452 +++++++++++++++++++++++---- bookmarks/images.py | 19 +- bookmarks/items/views.py | 25 +- bookmarks/publish.py | 11 +- bookmarks/rsc/stylesheet.qss | 6 +- bookmarks/threads/workers.py | 52 +-- bookmarks/ui.py | 5 +- 14 files changed, 513 insertions(+), 174 deletions(-) diff --git a/bookmarks/actions.py b/bookmarks/actions.py index 47c3d72b..792624e5 100644 --- a/bookmarks/actions.py +++ b/bookmarks/actions.py @@ -930,7 +930,7 @@ def refresh(idx=None): p = model.source_path() source = '/'.join(p) if p else '' assets_cache_dir = QtCore.QDir(f'{common.active("root", path=True)}/{common.bookmark_cache_dir}/assets') - if assets_cache_dir.exists(): + if not assets_cache_dir.exists(): assets_cache_dir.mkpath('.') assets_cache_name = common.get_hash(source) cache = f'{assets_cache_dir.path()}/{assets_cache_name}.cache' diff --git a/bookmarks/application_launcher.py b/bookmarks/application_launcher.py index 151b456c..f25ecb5d 100644 --- a/bookmarks/application_launcher.py +++ b/bookmarks/application_launcher.py @@ -530,6 +530,7 @@ def item_generator(self): ) == QtWidgets.QDialog.Rejected: return actions.edit_bookmark() + return for k in sorted(v, key=lambda idx: v[idx]['name']): yield v[k] diff --git a/bookmarks/common/env.py b/bookmarks/common/env.py index fe16d450..caabcb57 100644 --- a/bookmarks/common/env.py +++ b/bookmarks/common/env.py @@ -21,21 +21,22 @@ def get_binary(binary_name): """Binary path getter. The paths are resolved from the following sources and order: - - active bookmark item's application launcher items + - active bookmark item's app launcher items + - distribution folder's bin directory - user settings - environment variables in a ``{PREFIX}_{BINARY_NAME}`` format, - e.g. ``BOOKMARKS_FFMPEG``, or ``BOOKMARKS_RV``. These environment variables - should point to an appropriate executable, e.g. + for example ``BOOKMARKS_FFMPEG``, or ``BOOKMARKS_RV``. These environment variables + should point to an appropriate executable, for example ``BOOKMARKS_FFMPEG=C:/ffmpeg/ffmpeg.exe`` - If the environment variable is absent, we'll look at the PATH environment to + If the environment variable is absent, look at the PATH environment to see if the binary is available there. Args: - binary_name (str): Name of a binary, lower-case, without spaces. E.g. `aftereffects`, `oiiotool`, `ffmpeg`, etc. + binary_name (str): Name of a binary, lower-case, without spaces. For example, `aftereffects`, `oiiotool`. Returns: - str: Path to an executable binary, or `None` if the binary is not found in any of the sources. + str: Path to an executable binary, or `None` if the binary isn't found in any of the sources. """ # Sanitize the binary name @@ -54,31 +55,45 @@ def get_binary(binary_name): # Sanitize names, so they're all lower-case and without spaces names = [re.sub(r'\s+', '', v['name']).lower().strip() for v in applications.values()] if binary_name in names: - # We have a match, return the path v = applications[names.index(binary_name)]['path'] if v and QtCore.QFileInfo(v).exists(): return v # Check the user settings for possible values v = get_user_setting(binary_name) - if v: + if v and QtCore.QFileInfo(v).exists(): return v + + # Check the distribution folder for possible values + root = os.environ.get('Bookmarks_ROOT', None) + + if root and QtCore.QFileInfo(root).exists(): + bin_dir = QtCore.QFileInfo(f'{root}/bin') + if bin_dir.exists(): + for entry in os.scandir(bin_dir.filePath()): + try: + if not entry.is_file(): + continue + except: + continue + + match = re.match( + rf'^{binary_name}$|{binary_name}\..+', + entry.name, + flags=re.IGNORECASE + ) + if match: + return QtCore.QFileInfo(entry.path).filePath() + # Check the environment variables for possible values key = f'{common.product}_{binary_name}'.upper() - if key in os.environ: - v = os.environ[key] - try: - if v and os.path.isfile(v): - return QtCore.QFileInfo(v).filePath() - except: - pass - - v = _parse_dist_env(binary_name) - if v: - return v - v = _parse_path_env(binary_name) + v = os.environ.get(key, None) + if v and QtCore.QFileInfo(v).exists(): + return QtCore.QFileInfo(v).filePath() + # Check the PATH environment for possible values + v = _parse_path_env(binary_name) return v @@ -107,45 +122,6 @@ def get_user_setting(binary_name): return None -def _parse_dist_env(binary_name): - from . import env_key - if env_key not in os.environ: - return - - v = os.environ[env_key] - if not QtCore.QFileInfo(v).exists(): - return - - def _scan_dir(v): - if not os.path.isdir(v): - print(f'{v} is not a directory') - return None - for entry in os.scandir(v): - try: - if not entry.is_file(): - continue - except: - continue - - match = re.match( - rf'^{binary_name}$|{binary_name}\..+', - entry.name, - flags=re.IGNORECASE - ) - if match: - return QtCore.QFileInfo(entry.path).filePath() - - return None - - _v = _scan_dir(v) - if _v: - return _v - _v = _scan_dir(f'{v}/bin') - if _v: - return _v - return None - - def _parse_path_env(binary_name): items = { os.path.normpath(k.lower()).strip(): QtCore.QFileInfo(k).filePath() for k diff --git a/bookmarks/common/settings.py b/bookmarks/common/settings.py index df7d592e..145d8e21 100644 --- a/bookmarks/common/settings.py +++ b/bookmarks/common/settings.py @@ -89,6 +89,8 @@ 'ffmpeg/preset', 'ffmpeg/size', 'ffmpeg/timecode_preset', + 'ffmpeg/sourcecolorspace', + 'ffmpeg/targetcolorspace', 'ffmpeg/pushtorv', ), 'akaconvert': ( diff --git a/bookmarks/common/setup.py b/bookmarks/common/setup.py index 4fbac042..8feb8e04 100644 --- a/bookmarks/common/setup.py +++ b/bookmarks/common/setup.py @@ -8,6 +8,7 @@ import sys import time +import OpenImageIO from PySide2 import QtWidgets, QtGui from .. import common diff --git a/bookmarks/editor/asset_properties.py b/bookmarks/editor/asset_properties.py index 61e7acae..6b221767 100644 --- a/bookmarks/editor/asset_properties.py +++ b/bookmarks/editor/asset_properties.py @@ -458,7 +458,7 @@ def save_changes(self): """Saves changes. """ - # When the asset is not set, we'll create one based on the name set + # When the asset isn't set, create one based on the name set if not self.asset: self.create_asset() diff --git a/bookmarks/editor/base_widgets.py b/bookmarks/editor/base_widgets.py index 33e9cc6d..cf5b781a 100644 --- a/bookmarks/editor/base_widgets.py +++ b/bookmarks/editor/base_widgets.py @@ -22,13 +22,13 @@ @common.error @common.debug def process_image(source): - """Converts, resizes and loads an image file as a QImage. + """Converts, resizes, and loads an image file as a QImage. Args: source (str): Path to an image file. Returns: - QImage: The resized QImage, or `None` if the image was not processed + QImage: The resized QImage, or `None` if the image wasn't processed successfully. """ @@ -45,15 +45,20 @@ def process_image(source): if not f.dir().mkpath('.'): raise RuntimeError('Could not create temp folder') - res = bookmarks_openimageio.convert_image( + error = bookmarks_openimageio.convert_image( source, destination, - max_size=int(common.thumbnail_size) + source_color_space='', + target_color_space='sRGB', + size=int(common.thumbnail_size) ) - if not res: + if error == 1: raise RuntimeError('Failed to convert the thumbnail') + # Flush cache + images.ImageCache.flush(source) images.ImageCache.flush(destination) + image = images.ImageCache.get_image( destination, int(common.thumbnail_size), diff --git a/bookmarks/external/ffmpeg_widget.py b/bookmarks/external/ffmpeg_widget.py index da05fe95..e71bd43b 100644 --- a/bookmarks/external/ffmpeg_widget.py +++ b/bookmarks/external/ffmpeg_widget.py @@ -3,6 +3,7 @@ """ import functools import os +import re import subprocess import bookmarks_openimageio @@ -78,20 +79,12 @@ def init_data(self): QtCore.Qt.UserRole: None, } - template = common.settings.value('ffmpeg/timecode_preset') - for v in data[tokens.FFMpegTCConfig].values(): - if template == v['name']: - icon = ui.get_icon( - 'check', color=common.color(common.color_green), size=common.size(common.size_margin) * 2 - ) - else: - icon = ui.get_icon( - 'branch_closed', size=common.size(common.size_margin) * 2 - ) + self._add_separator('Timecode presets') + for v in data[tokens.FFMpegTCConfig].values(): self._data[len(self._data)] = { QtCore.Qt.DisplayRole: v['name'], - QtCore.Qt.DecorationRole: icon, + QtCore.Qt.DecorationRole: None, QtCore.Qt.SizeHintRole: self.row_size, QtCore.Qt.StatusTipRole: v['description'], QtCore.Qt.AccessibleDescriptionRole: v['description'], @@ -100,6 +93,24 @@ def init_data(self): QtCore.Qt.UserRole: v['value'], } + def data(self, index, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DecorationRole: + flags = super().data(index, common.FlagsRole) + if flags == QtCore.Qt.NoItemFlags: + return None + + template = common.settings.value('ffmpeg/timecode_preset') + name = super().data(index, QtCore.Qt.DisplayRole) + if template == name: + return ui.get_icon( + 'check', color=common.color(common.color_green), size=common.size(common.size_margin) * 2 + ) + return ui.get_icon( + 'branch_closed', color=common.color(common.color_separator), size=common.size(common.size_margin) * 2 + ) + + return super().data(index, role) + class TimecodeComboBox(QtWidgets.QComboBox): """Timecode preset picker. @@ -112,40 +123,253 @@ def __init__(self, parent=None): self.setModel(TimecodeModel()) -class PresetComboBox(QtWidgets.QComboBox): - """FFMpeg preset picker. +class SourceColorSpaceModel(ui.AbstractListModel): + """Template item picker model. """ + def init_data(self, *args, **kwargs): + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: 'Guess from source', + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: 'Guess from source', + QtCore.Qt.AccessibleDescriptionRole: 'Guess from source', + QtCore.Qt.WhatsThisRole: 'Guess from source', + QtCore.Qt.ToolTipRole: 'Guess from source', + QtCore.Qt.UserRole: None, + } + + self._add_separator('Built-in roles') + + default_roles = [ + 'sRGB', + 'linear', + ] + + for role in default_roles: + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: role, + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: role, + QtCore.Qt.AccessibleDescriptionRole: role, + QtCore.Qt.WhatsThisRole: role, + QtCore.Qt.ToolTipRole: role, + QtCore.Qt.UserRole: role, + } + + roles = [] + + oiiotool_bin = common.get_binary('oiiotool') + if oiiotool_bin and QtCore.QFileInfo(oiiotool_bin).exists(): + result = subprocess.run( + [os.path.normpath(oiiotool_bin), '--colorconfiginfo'], + capture_output=True, + text=True + ) + + for line in result.stdout.split('\n'): + if re.match(r'\s+-\s', line): + match = re.match(r'\s+-\s+(.+)', line) + if not match: + continue + + s = (match.group(1) + .split('->')[0] + .replace('(*)', '') + .replace('(linear)', '') + .strip().strip('"').strip()) + roles.append(s) + + self._add_separator('OpenColorIO roles') + + for v in roles: + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: v, + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: v, + QtCore.Qt.AccessibleDescriptionRole: v, + QtCore.Qt.WhatsThisRole: v, + QtCore.Qt.ToolTipRole: v, + QtCore.Qt.UserRole: v, + } + + def data(self, index, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DecorationRole: + flags = super().data(index, common.FlagsRole) + if flags == QtCore.Qt.NoItemFlags: + return None + + template = common.settings.value('ffmpeg/sourcecolorspace') + name = super().data(index, QtCore.Qt.DisplayRole) + if template == name: + return ui.get_icon( + 'check', + color=common.color(common.color_green), + size=common.size(common.size_margin) * 2 + ) + return ui.get_icon( + 'branch_closed', + color=common.color(common.color_separator), + size=common.size(common.size_margin) * 2 + ) + + return super().data(index, role) + + +class SourceColorSpaceComboBox(QtWidgets.QComboBox): + def __init__(self, parent=None): super().__init__(parent=parent) self.setView(QtWidgets.QListView()) - self.init_data() + self.setModel(SourceColorSpaceModel()) - def init_data(self): - """Initializes data. - """ - self.blockSignals(True) +class TargetColorSpaceModel(ui.AbstractListModel): + + def init_data(self, *args, **kwargs): + self._add_separator('Built-in roles') + + default_roles = [ + 'sRGB', + 'linear', + ] + + for role in default_roles: + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: role, + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: role, + QtCore.Qt.AccessibleDescriptionRole: role, + QtCore.Qt.WhatsThisRole: role, + QtCore.Qt.ToolTipRole: role, + QtCore.Qt.UserRole: role, + } + + roles = [] + + oiiotool_bin = common.get_binary('oiiotool') + + if oiiotool_bin and QtCore.QFileInfo(oiiotool_bin).exists(): + result = subprocess.run( + [os.path.normpath(oiiotool_bin), '--colorconfiginfo'], + capture_output=True, + text=True + ) + + for line in result.stdout.split('\n'): + if re.match(r'\s+-\s', line): + match = re.match(r'\s+-\s+(.+)', line) + if not match: + continue + + s = (match.group(1) + .split('->')[0] + .replace('(*)', '') + .replace('(linear)', '') + .strip().strip('"').strip()) + roles.append(s) + + self._add_separator('OpenColorIO roles') + + for v in roles: + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: v, + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: v, + QtCore.Qt.AccessibleDescriptionRole: v, + QtCore.Qt.WhatsThisRole: v, + QtCore.Qt.ToolTipRole: v, + QtCore.Qt.UserRole: v, + } + + def data(self, index, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DecorationRole: + flags = super().data(index, common.FlagsRole) + if flags == QtCore.Qt.NoItemFlags: + return None + + template = common.settings.value('ffmpeg/targetcolorspace') + name = super().data(index, QtCore.Qt.DisplayRole) + if template == name: + return ui.get_icon( + 'check', + color=common.color(common.color_green), + size=common.size(common.size_margin) * 2 + ) + return ui.get_icon( + 'branch_closed', + color=common.color(common.color_separator), + size=common.size(common.size_margin) * 2 + ) + + return super().data(index, role) + + +class TargetColorSpaceComboBox(QtWidgets.QComboBox): + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setView(QtWidgets.QListView()) + self.setModel(TargetColorSpaceModel()) + + +class PresetModel(ui.AbstractListModel): + + def init_data(self, *args, **kwargs): for v in ffmpeg.PRESETS.values(): - self.addItem(v['name'], userData=v['preset']) - self.blockSignals(False) + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: v['name'], + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: v['description'], + QtCore.Qt.AccessibleDescriptionRole: v['description'], + QtCore.Qt.WhatsThisRole: v['description'], + QtCore.Qt.ToolTipRole: v['description'], + QtCore.Qt.UserRole: v['preset'], + } + def data(self, index, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DecorationRole: + flags = super().data(index, common.FlagsRole) + if flags == QtCore.Qt.NoItemFlags: + return None + + template = common.settings.value('ffmpeg/preset') + name = super().data(index, QtCore.Qt.DisplayRole) + if template == name: + return ui.get_icon( + 'check', + color=common.color(common.color_green), + size=common.size(common.size_margin) * 2 + ) + return ui.get_icon( + 'branch_closed', + color=common.color(common.color_separator), + size=common.size(common.size_margin) * 2 + ) -class SizeComboBox(QtWidgets.QComboBox): - """FFMpeg output size picker. + return super().data(index, role) + + +class PresetComboBox(QtWidgets.QComboBox): + """FFMpeg preset picker. """ def __init__(self, parent=None): super().__init__(parent=parent) self.setView(QtWidgets.QListView()) - self.init_data() + self.setModel(PresetModel()) - def init_data(self): - """Initializes data. - """ +class SizeComboBoxModel(ui.AbstractListModel): + + def init_data(self, *args, **kwargs): db = database.get(*common.active('root', args=True)) bookmark_width = db.value(db.source(), 'width', database.BookmarkTable) bookmark_height = db.value(db.source(), 'height', database.BookmarkTable) @@ -156,14 +380,73 @@ def init_data(self): height = asset_height or bookmark_height or None if all((width, height)): - self.addItem(f'Project | {int(height)}p', userData=(width, height)) - self.addItem(f'Project | {int(height * 0.5)}p', userData=(int(width * 0.5), int(height * 0.5))) + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: f'Project | {int(height)}p', + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: f'Project | {int(height)}p', + QtCore.Qt.AccessibleDescriptionRole: f'Project | {int(height)}p', + QtCore.Qt.WhatsThisRole: f'Project | {int(height)}p', + QtCore.Qt.ToolTipRole: f'Project | {int(height)}p', + QtCore.Qt.UserRole: (width, height), + } + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: f'Project | {int(height * 0.5)}p', + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: f'Project | {int(height * 0.5)}p', + QtCore.Qt.AccessibleDescriptionRole: f'Project | {int(height * 0.5)}p', + QtCore.Qt.WhatsThisRole: f'Project | {int(height * 0.5)}p', + QtCore.Qt.ToolTipRole: f'Project | {int(height * 0.5)}p', + QtCore.Qt.UserRole: (int(width * 0.5), int(height * 0.5)), + } + + self._add_separator('Size presets') - self.blockSignals(True) for v in ffmpeg.SIZE_PRESETS.values(): - self.addItem(v['name'], userData=v['value']) + self._data[len(self._data)] = { + QtCore.Qt.DisplayRole: v['name'], + QtCore.Qt.DecorationRole: None, + QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.StatusTipRole: v['name'], + QtCore.Qt.AccessibleDescriptionRole: v['name'], + QtCore.Qt.WhatsThisRole: v['name'], + QtCore.Qt.ToolTipRole: v['name'], + QtCore.Qt.UserRole: v['value'], + } - self.blockSignals(False) + def data(self, index, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DecorationRole: + flags = super().data(index, common.FlagsRole) + if flags == QtCore.Qt.NoItemFlags: + return None + + template = common.settings.value('ffmpeg/size') + name = super().data(index, QtCore.Qt.DisplayRole) + if template == name: + return ui.get_icon( + 'check', + color=common.color(common.color_green), + size=common.size(common.size_margin) * 2 + ) + return ui.get_icon( + 'branch_closed', + color=common.color(common.color_separator), + size=common.size(common.size_margin) * 2 + ) + + return super().data(index, role) + + +class SizeComboBox(QtWidgets.QComboBox): + """FFMpeg output size picker. + + """ + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setView(QtWidgets.QListView()) + self.setModel(SizeComboBoxModel()) class FFMpegWidget(base.BasePropertyEditor): @@ -203,6 +486,22 @@ class FFMpegWidget(base.BasePropertyEditor): 'description': 'Select the timecode preset to use.', }, 4: { + 'name': 'Source color space', + 'key': 'ffmpeg_sourcecolorspace', + 'validator': None, + 'widget': SourceColorSpaceComboBox, + 'placeholder': None, + 'description': 'Select the source color space.', + }, + 5: { + 'name': 'Target color space', + 'key': 'ffmpeg_targetcolorspace', + 'validator': None, + 'widget': TargetColorSpaceComboBox, + 'placeholder': None, + 'description': 'Select the target color space.', + }, + 6: { 'name': 'Push to RV', 'key': 'ffmpeg_pushtorv', 'validator': None, @@ -266,7 +565,7 @@ def save_changes(self): f'{index.data(QtCore.Qt.DisplayRole)} is too short.' ) - # Check output video file + # Check the output video file seq = index.data(common.SequenceRole) preset = self.ffmpeg_preset_editor.currentData() ext = next( @@ -294,19 +593,33 @@ def save_changes(self): raise RuntimeError(f'Could not remove {destination}') common.show_message( - 'Preparing images...', body='Please wait while the frames are being converted. This might take a ' - 'while...', message_type=None, disable_animation=True, buttons=[], ) + 'Preparing images...', + body='Please wait while the frames are being converted. This might take a while...', + message_type=None, + disable_animation=True, + buttons=[], + ) - source_image_paths = self.preprocess_sequence() + source_color_space = self.ffmpeg_sourcecolorspace_editor.currentData() + target_color_space = self.ffmpeg_targetcolorspace_editor.currentData() + source_image_paths = self.preprocess_sequence( + source_color_space=source_color_space, + target_color_space=target_color_space, + preconversion_format='jpg' + ) if not common.message_widget or common.message_widget.isHidden(): return timecode_preset = self.ffmpeg_timecode_preset_editor.currentData() mov = ffmpeg.convert( - source_image_paths[ - 0], self.ffmpeg_preset_editor.currentData(), size=self.ffmpeg_size_editor.currentData(), - timecode=bool(timecode_preset), timecode_preset=timecode_preset, output_path=destination, parent=self + source_image_paths[0], + self.ffmpeg_preset_editor.currentData(), + size=self.ffmpeg_size_editor.currentData(), + timecode=bool(timecode_preset), + timecode_preset=timecode_preset, + output_path=destination, + parent=self ) for f in source_image_paths: @@ -334,19 +647,26 @@ def save_changes(self): log.success(f'Movie saved to {destination}') return True - def preprocess_sequence(self, preconversion_format='jpg'): - """Preprocesses the source image sequence. - - FFMpeg can't handle missing frames, so we'll check and fill in the gaps and convert the source images - to jpeg images using OpenImageIO if they're not already supported by FFMpeg. + def preprocess_sequence(self, source_color_space='', target_color_space='sRGB', preconversion_format='jpg'): + """Preprocesses the source image sequence for ffmpeg. Args: + source_color_space (str): The source color space. Defaults to an empty string. + target_color_space (str): The target color space. Defaults to 'sRGB'. preconversion_format (str): The format to convert the source images to. Returns: tuple: A tuple of jpeg file paths to be used as input for ffmpeg. """ + ffmpeg_bin = common.get_binary('ffmpeg') + + if not ffmpeg_bin: + raise RuntimeError('FFMpeg binary not found.') + + if not QtCore.QFileInfo(ffmpeg_bin).exists(): + raise RuntimeError(f'FFMpeg binary {ffmpeg_bin} does not exist.') + index = self._index seq = index.data(common.SequenceRole) @@ -361,13 +681,13 @@ def preprocess_sequence(self, preconversion_format='jpg'): has_missing_frames = len(all_frames) != len(frames) # Set up the temp directory - _dir = QtCore.QDir(f'{common.temp_path()}/ffmpeg') - if not _dir.exists(): - if not _dir.mkpath('.'): + temp_dir = QtCore.QDir(f'{common.temp_path()}/ffmpeg') + if not temp_dir.exists(): + if not temp_dir.mkpath('.'): raise RuntimeError('Could not create ffmpeg temp dir') # Remove any previously created temp image frames - for entry in os.scandir(_dir.path()): + for entry in os.scandir(temp_dir.path()): if entry.is_dir(): continue if not entry.name.startswith('ffmpeg_'): @@ -378,47 +698,34 @@ def preprocess_sequence(self, preconversion_format='jpg'): ext = QtCore.QFileInfo(index.data(common.PathRole)).suffix().strip('.').lower() - # Get the supported ffmpeg image extensions from the current binary - # Run the command and capture the output - ffmpeg_bin = common.get_binary('ffmpeg') - - if not ffmpeg_bin: - raise RuntimeError('FFMpeg binary not found.') - - if not QtCore.QFileInfo(ffmpeg_bin).exists(): - raise RuntimeError(f'FFMpeg binary {ffmpeg_bin} does not exist.') - + # Get the supported image extensions from ffmpeg result = subprocess.run([os.path.normpath(ffmpeg_bin), '-decoders'], capture_output=True, text=True) extensions = ffmpeg.get_supported_formats(result.stdout) - if not extensions: - raise RuntimeError('FFMpeg doesn\'t seem to support any image formats.') + raise RuntimeError('Could not get supported ffmpeg image extensions.') needs_conversion = ext not in extensions source_images = [] ffmpeg_source_images = [] - # If the source images are already supported by ffmpeg and there are no missing frames, we'll just use the - # source images as input for ffmpeg. + # If the source images are already supported by ffmpeg and there are no missing frames, use the + # source images as input for ffmpeg if not has_missing_frames and not needs_conversion: return [f'{seq.group(1)}{f}{seq.group(3)}.{seq.group(4)}' for f in frames] - # We'll build a full sequence filling in any missing frames with the closest available frame. This allows us - # to correctly create videos of sequences with missing images. + # Otherwise, build a full sequence filling in any missing frames with the closest available frame source_frame = all_frames[0] - for frame in all_frames: + for idx, frame in enumerate(all_frames): if frame in frames: source_frame = next(frames_it) source_path = f'{seq.group(1)}{source_frame}{seq.group(3)}.{seq.group(4)}' source_images.append(source_path) - destination_path = f'{_dir.path()}/ffmpeg_{frame}.{preconversion_format if needs_conversion else ext}' + destination_path = f'{temp_dir.path()}/ffmpeg.{idx}.{preconversion_format if needs_conversion else ext}' ffmpeg_source_images.append(destination_path) - # We'll copy the source files to the temp directory instead of converting them to create a full sequence - # of images. This allows us to correctly create videos of sequences with missing images. if not needs_conversion and has_missing_frames: for idx, items in enumerate(zip(source_images, ffmpeg_source_images)): source_path, destination_path = items @@ -436,10 +743,17 @@ def preprocess_sequence(self, preconversion_format='jpg'): ) QtWidgets.QApplication.instance().processEvents(QtCore.QEventLoop.ExcludeUserInputEvents) - if not bookmarks_openimageio.convert_images(source_images, ffmpeg_source_images, max_size=-1, release_gil=True): + error = bookmarks_openimageio.convert_sequence( + f'{seq.group(1)}%0{len(frames[0])}d{seq.group(3)}.{seq.group(4)}', + f'{temp_dir.path()}/ffmpeg.{preconversion_format}', + source_color_space, + target_color_space, + size=0 + ) + if error == 1: raise RuntimeError('Failed to convert an image using OpenImageIO.') - # Sanity check to make sure all the destination paths exist + # Check to make sure all the destination paths exist for f in ffmpeg_source_images: if not QtCore.QFileInfo(f).exists(): raise RuntimeError(f'{f} does not exist') diff --git a/bookmarks/images.py b/bookmarks/images.py index 6caa1a23..56218318 100644 --- a/bookmarks/images.py +++ b/bookmarks/images.py @@ -280,10 +280,9 @@ def create_thumbnail_from_image(server, job, root, source, image, proxy=False): """Creates a thumbnail from a given image file and saves it as the source file's thumbnail image. - The ``server``, ``job``, ``root``, ``source`` arguments refer to a file we - want to create a new thumbnail for. The ``image`` argument should be a path to - an image file that will be converted using `bookmarks_openimageio.make_thumbnail()` to a - thumbnail image and saved to our image cache and disk to represent ``source``. + The ``server``, ``job``, ``root``, ``source`` arguments refer to a file to create a new thumbnail for. + The ``image`` argument should be a path to an image file to be converted to a + thumbnail image and saved to the image cache and disk to represent ``source``. Args: server (str): `server` path segment. @@ -304,14 +303,17 @@ def create_thumbnail_from_image(server, job, root, source, image, proxy=False): s = 'Failed to remove existing thumbnail file.' raise RuntimeError(s) - res = bookmarks_openimageio.convert_image( + error = bookmarks_openimageio.convert_image( image, thumbnail_path, - max_size=int(common.thumbnail_size) + source_color_space='', + target_color_space='sRGB', + size=int(common.thumbnail_size) ) - if not res: + if error == 1: raise RuntimeError('Failed to make thumbnail.') + ImageCache.flush(image) ImageCache.flush(thumbnail_path) @@ -327,9 +329,10 @@ def get_cached_thumbnail_path(server, job, root, source, proxy=False): job (str): `job` path segment. root (str): `root` path segment. source (str): The full file path. + proxy (bool): Specify if the source is an image sequence. Returns: - str: The resolved thumbnail path. + str: The resolved thumbnail path. """ for arg in (server, job, root, source): diff --git a/bookmarks/items/views.py b/bookmarks/items/views.py index a133297b..c0112526 100644 --- a/bookmarks/items/views.py +++ b/bookmarks/items/views.py @@ -12,6 +12,7 @@ """ import collections import functools +import os import re import weakref @@ -21,6 +22,7 @@ from . import models from .widgets import filter_editor from .. import actions +from .. import log from .. import common from .. import contextmenu from .. import database @@ -1417,7 +1419,7 @@ def set_row_size(self, v): @common.debug @QtCore.Slot(str) def show_item(self, v, role=QtCore.Qt.DisplayRole, update=True, limit=10000): - """Show an item in the viewer. + """Show an item in the view. Args: v (any): A value to match. @@ -1428,8 +1430,25 @@ def show_item(self, v, role=QtCore.Qt.DisplayRole, update=True, limit=10000): """ proxy = self.model() model = proxy.sourceModel() - if update and model.rowCount() < limit: - model.reset_data(force=True, emit_active=False) + + p = model.source_path() + + if p and len(p) == 3: + # Read from the cache if it exists + source = '/'.join(p) + assets_cache_dir = QtCore.QDir(f'{common.active("root", path=True)}/{common.bookmark_cache_dir}/assets') + if not assets_cache_dir.exists(): + assets_cache_dir.mkpath('.') + + assets_cache_name = common.get_hash(source) + cache = f'{assets_cache_dir.path()}/{assets_cache_name}.cache' + + if assets_cache_dir.exists() and os.path.exists(cache): + log.debug('Removing asset cache:', cache) + os.remove(cache) + + if update and model.rowCount() < limit: + model.reset_data(force=True, emit_active=False) # Delay the selection to let the model process events QtCore.QTimer.singleShot( diff --git a/bookmarks/publish.py b/bookmarks/publish.py index e57b3336..e7649935 100644 --- a/bookmarks/publish.py +++ b/bookmarks/publish.py @@ -551,11 +551,16 @@ def save_thumbnail(self, destination, payload=None): self.thumbnail_editor.save_image(destination=temp) if QtCore.QFileInfo(temp).exists(): - res = bookmarks_openimageio.convert_image( - temp, dest, max_size=int(common.thumbnail_size) + error = bookmarks_openimageio.convert_image( + temp, + dest, + source_color_space='', + target_color_space='sRGB', + size=int(common.thumbnail_size) ) - if not res: + if error == 1: print(f'Error: Could not convert {temp}') + QtCore.QFile(temp).remove() payload['thumbnail'] = dest diff --git a/bookmarks/rsc/stylesheet.qss b/bookmarks/rsc/stylesheet.qss index 9e9ec8ad..d0ddd780 100644 --- a/bookmarks/rsc/stylesheet.qss +++ b/bookmarks/rsc/stylesheet.qss @@ -69,12 +69,14 @@ QCheckBox::indicator {{ border-radius: {size_indicator1}px; }} QCheckBox::indicator:unchecked {{ - /* image: url({close}); */ + image: url({close}); background-color: {color_separator}; + border-radius: {size_indicator1}px; }} QCheckBox::indicator:checked {{ - /* image: url({check}); */ + image: url({check}); background-color: {color_green}; + border-radius: {size_indicator1}px; }} QCheckBox:checked {{ color: {color_selected_text}; diff --git a/bookmarks/threads/workers.py b/bookmarks/threads/workers.py index 2a482c96..505c38e4 100644 --- a/bookmarks/threads/workers.py +++ b/bookmarks/threads/workers.py @@ -32,7 +32,7 @@ def _widget(q): def _qlast_modified(n): - return QtCore.QDateTime.fromMSecsSinceEpoch(n * 1000) + return QtCore.QDateTime.fromMSecsSinceEpoch(int(n) * 1000) def _model(q): @@ -842,49 +842,57 @@ def process_data(self, ref): if '_broken__' in source: return False + # Check the file extension + ext = QtCore.QFileInfo(source).suffix().lower() + if ext not in images.get_oiio_extensions(): + return True + # Resolve the thumbnail's path... destination = images.get_cached_thumbnail_path(_p[0], _p[1], _p[2], source, ) # ...and use it to load the resource image = images.ImageCache.get_image( - destination, int(size), force=True # force=True will refresh the cache + destination, int(size), force=True ) - # If the image successfully loads we can wrap things up here - if image and not image.isNull(): - images.make_color(destination) - return True - - # Otherwise, we will try to generate a thumbnail using OpenImageIO - - # If the items is a sequence, we'll use the first image of the - # sequence to make the thumbnail. + # If the items is a sequence, use the first image of to make the thumbnail if not self.is_valid(ref): return False + if ref()[common.DataTypeRole] == common.SequenceItem: if not self.is_valid(ref): return False source = ref()[common.EntryRole][0].path.replace('\\', '/') - if QtCore.QFileInfo(source).size() >= pow(1024, 3) * 2: - return True - - buf = images.ImageCache.get_buf(source) + # If the thumbnail successfully loads, there's a previously generated image. + # Let's check it against the source to make sure it's still valid. + if image and not image.isNull(): + res = bookmarks_openimageio.is_up_to_date(source, destination) + print(res, source, destination) + if res == 1: + images.make_color(destination) + return True + else: + images.ImageCache.flush(destination) - if not buf: + # Skip if the file is too large + if QtCore.QFileInfo(source).size() >= pow(1024, 3) * 2: return True try: - # Skip large files + error = bookmarks_openimageio.convert_image( + source, + destination, + source_color_space='', + target_color_space='sRGB', + size=int(common.thumbnail_size), + ) + images.ImageCache.flush(source) - res = bookmarks_openimageio.convert_image(source, destination, max_size=int(common.thumbnail_size), ) - if res: + if error != 1: images.ImageCache.get_image(destination, int(size), force=True) images.make_color(destination) return True - # We should never get here ideally, but if we do we'll mark the item - # with a bespoke 'failed' thumbnail - fpath = common.rsc(f'{common.GuiResource}/failed.{common.thumbnail_format}') hash = common.get_hash(destination) diff --git a/bookmarks/ui.py b/bookmarks/ui.py index 3168683c..975526f3 100644 --- a/bookmarks/ui.py +++ b/bookmarks/ui.py @@ -1648,11 +1648,14 @@ def flags(self, index): ) def _add_separator(self, label): + size = QtCore.QSize(self.row_size) + size.setHeight(size.height() * 0.5) + self._data[len(self._data)] = { QtCore.Qt.DisplayRole: label, QtCore.Qt.DecorationRole: None, QtCore.Qt.ForegroundRole: common.color(common.color_disabled_text), - QtCore.Qt.SizeHintRole: self.row_size, + QtCore.Qt.SizeHintRole: size, QtCore.Qt.UserRole: None, common.FlagsRole: QtCore.Qt.NoItemFlags }