diff --git a/bookmarks/common/settings.py b/bookmarks/common/settings.py index ae60dca5..7b193a0a 100644 --- a/bookmarks/common/settings.py +++ b/bookmarks/common/settings.py @@ -121,10 +121,8 @@ 'publish': ( 'publish/archive_existing', 'publish/template', - 'publish/task', 'publish/copy_path', 'publish/reveal', - 'publish/teams_notification', ), } diff --git a/bookmarks/external/akaconvert.py b/bookmarks/external/akaconvert.py index 46a29e6b..6f3bae6b 100644 --- a/bookmarks/external/akaconvert.py +++ b/bookmarks/external/akaconvert.py @@ -9,7 +9,6 @@ from .. import common from .. import database -from .. import log from ..editor import base @@ -288,8 +287,8 @@ class AkaConvertWidget(base.BasePropertyEditor): #: UI layout definition sections = { 0: { - 'name': 'Convert Image Sequence', - 'icon': 'convert', + 'name': 'AkaConvert', + 'icon': 'studioaka', 'color': common.color(common.color_dark_background), 'groups': { 0: { @@ -429,7 +428,7 @@ def save_changes(self): self.process = QtCore.QProcess(parent=self) self.process.readyReadStandardOutput.connect(self.read_output) - self.process.finished.connect(self.on_finished) + self.process.finished.connect(self.convert_process_finished) self.process.setProgram(get_convert_script_path()) self.process.setArguments(args) @@ -443,8 +442,24 @@ def read_output(self): data = self.process.readAllStandardOutput().data().decode('utf-8') for line in data.splitlines(): + if 'Movie saved to' in line: + + # Get the path to the output video + video_path = line.split('Movie saved to')[-1].strip().strip('"') + + if not QtCore.QFileInfo(video_path).exists(): + print(f'Could not find the output video: {video_path}') + else: + # Show the video in the files tab + common.signals.fileAdded.emit(video_path) + + # Push to RV + if self.akaconvert_pushtorv_editor.isChecked(): + from ..external import rv + rv.execute_rvpush_command(video_path, rv.PushAndClear) + if 'Finished' in line: - self.on_finished(0, QtCore.QProcess.NormalExit) + self.convert_process_finished(0, QtCore.QProcess.NormalExit) return if line.startswith('[AkaConvert Error]'): @@ -460,7 +475,7 @@ def read_output(self): 'Converting...', body=current_progress_line.replace('[AkaConvert Info]', ''), message_type=None, buttons=[], disable_animation=True, ) - def on_finished(self, exit_code, exit_status): + def convert_process_finished(self, exit_code, exit_status): # I don't know why, but the process doesn't terminate itself and will # keep running in the background. So we need to terminate it manually I guess! if exit_code != 0: @@ -472,6 +487,7 @@ def on_finished(self, exit_code, exit_status): common.show_message( 'Finished.', f'Conversion has finished successfully.' ) + raise RuntimeError('Finished.') def sizeHint(self): diff --git a/bookmarks/external/rv.py b/bookmarks/external/rv.py index dcb88aad..c9129ef1 100644 --- a/bookmarks/external/rv.py +++ b/bookmarks/external/rv.py @@ -69,7 +69,7 @@ def execute_rvpush_command(source, command): process1.waitForFinished(7000) # Wait 3 seconds for the process - QtCore.QThread.msleep(3000) + QtCore.QThread.msleep(2000) # Format the command cmd = command.format( @@ -82,3 +82,7 @@ def execute_rvpush_command(source, command): process2.setProcessEnvironment(env) process2.start(cmd) process2.waitForFinished(7000) + + errors = process2.readAllStandardError().data().decode('utf-8') + if errors: + raise RuntimeError(errors) diff --git a/bookmarks/items/views.py b/bookmarks/items/views.py index b3e2f34d..e3f5c3fe 100644 --- a/bookmarks/items/views.py +++ b/bookmarks/items/views.py @@ -1873,7 +1873,9 @@ def clickable_rectangle_event(self, event): if shift_modifier: # If the filter is empty we'll add a positive filter if not filter_texts: - self.model().set_filter_text(f'"/{_text}"') + if '#' not in _text: + _text = f'"/{_text}"' + self.model().set_filter_text(_text) self.repaint(self.rect()) return @@ -1885,7 +1887,10 @@ def clickable_rectangle_event(self, event): self.repaint(self.rect()) return # If the filter has items we'll append - self.model().set_filter_text(f'{filter_text} "/{_text}"') + if '#' not in _text: + _text = f'"/{_text}"' + self.model().set_filter_text(f'{filter_text} {_text}') + self.repaint(self.rect()) return @@ -1893,7 +1898,9 @@ def clickable_rectangle_event(self, event): if alt_modifier or control_modifier: # If the filter is empty we'll add a negative filter if not filter_texts: - self.model().set_filter_text(f'--"/{_text}"') + if '#' not in _text: + _text = f'"/{_text}"' + self.model().set_filter_text(f'--{_text}') self.repaint(self.rect()) return @@ -1902,7 +1909,9 @@ def clickable_rectangle_event(self, event): if _text in filter_text_element: del filter_texts[idx] - filter_texts.append(f'--"/{_text}"') + if '#' not in _text: + _text = f'"/{_text}"' + filter_texts.append(f'--{_text}') # add negative filter self.model().set_filter_text(' '.join(filter_texts)) diff --git a/bookmarks/maya/actions.py b/bookmarks/maya/actions.py index 872a7814..8085b06c 100644 --- a/bookmarks/maya/actions.py +++ b/bookmarks/maya/actions.py @@ -259,9 +259,11 @@ def save_warning(*args): return if workspace_info.path().lower() not in scene_file.filePath().lower(): + p = workspace_info.path() common.show_message( - f'Looks like you are saving "{scene_file.fileName()}" outside the current project\n\n', - body=f'The current project is:\n "{workspace_info.path()}"', + f'Scene is not in the current project.', + body=f'Look like "{scene_file.fileName()}" is being saved to another project:\n"{p}"\n\n' + f'You can safely ignore this message, it\'s just a friendly reminder.', message_type=None, disable_animation=True ) diff --git a/bookmarks/publish.py b/bookmarks/publish.py index a342607e..54c05656 100644 --- a/bookmarks/publish.py +++ b/bookmarks/publish.py @@ -8,7 +8,6 @@ import functools import json import os -import re import time import pyimageutil @@ -122,7 +121,7 @@ def get_payload(kwargs, destination): class TemplateModel(ui.AbstractListModel): - """Model used to list all available publish templates. + """Model used to list all available publish templates from the active bookmark item's token configuration. """ @@ -141,6 +140,16 @@ def init_data(self): template = common.settings.value('publish/template') for v in data[tokens.PublishConfig].values(): + if 'name' not in v: + print(f'Warning: Invalid publish template: {v}') + continue + if 'value' not in v: + print(f'Warning: Invalid publish template: {v}') + continue + if 'description' not in v: + print(f'Warning: Invalid publish template: {v}') + continue + if template == v['name']: pixmap = images.rsc_pixmap( 'check', common.color(common.color_green), common.size(common.size_margin) * 2 @@ -166,6 +175,8 @@ def init_data(self): class TemplateComboBox(QtWidgets.QComboBox): """Publish template picker. + The editor will list all relevant templates stored in the bookmark item database's token configuration. + """ def __init__(self, parent=None): @@ -174,82 +185,6 @@ def __init__(self, parent=None): self.setModel(TemplateModel()) -class TaskModel(ui.AbstractListModel): - def __init__(self, parent=None): - super(TaskModel, self).__init__(parent=parent) - - @common.error - @common.debug - def init_data(self): - """Initializes data. - - """ - self._data = {} - - k = common.active('asset', path=True) - if not k or not QtCore.QFileInfo(k).exists(): - return - - # Load the available task folders from the active bookmark item's `tokens`. - self._add_sub_folders(tokens.SceneFolder) - self._add_separator('Custom (click \'Add\' to add new)') - - def _add_separator(self, label): - 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.UserRole: None, - common.FlagsRole: QtCore.Qt.NoItemFlags - } - - def _add_sub_folders(self, token): - _icon = ui.get_icon('icon_bw', size=common.size(common.size_margin) * 2) - description = tokens.get_description(token) - for sub_folder in tokens.get_subfolders(token): - self._data[len(self._data)] = { - QtCore.Qt.DisplayRole: self.display_name(sub_folder), - QtCore.Qt.DecorationRole: _icon, - QtCore.Qt.ForegroundRole: common.color(common.color_text), - QtCore.Qt.SizeHintRole: self.row_size, - QtCore.Qt.StatusTipRole: description, - QtCore.Qt.AccessibleDescriptionRole: description, - QtCore.Qt.WhatsThisRole: description, - QtCore.Qt.ToolTipRole: description, - QtCore.Qt.UserRole: sub_folder, - } - - def add_item(self, task): - """Adds a new task item. - - """ - icon = ui.get_icon('icon_bw', size=common.size(common.size_margin) * 2) - - self.modelAboutToBeReset.emit() - self.beginResetModel() - - self._data[len(self._data)] = { - QtCore.Qt.DisplayRole: self.display_name(task), - QtCore.Qt.DecorationRole: icon, - QtCore.Qt.ForegroundRole: common.color(common.color_text), - QtCore.Qt.SizeHintRole: self.row_size, - QtCore.Qt.UserRole: task, - } - - self.endResetModel() - - -class TaskComboBox(QtWidgets.QComboBox): - """Task picker. - - """ - - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setView(QtWidgets.QListView()) - self.setModel(TaskModel()) - class PublishWidget(base.BasePropertyEditor): """Publishes a footage. @@ -258,16 +193,20 @@ class PublishWidget(base.BasePropertyEditor): #: UI layout definition sections = { 0: { - 'name': None, - 'icon': None, - 'color': common.color(common.color_dark_background), + 'name': 'Publish File', + 'icon': 'icon', + 'color': common.color(common.color_green), 'groups': { 0: { 0: { 'name': 'Source', 'key': 'source', 'validator': None, - 'widget': QtWidgets.QLabel, + 'widget': functools.partial( + ui.PaintedLabel, + '', + color=common.color(common.color_selected_text) + ), 'placeholder': '', 'description': 'Source file path', }, @@ -275,9 +214,13 @@ class PublishWidget(base.BasePropertyEditor): 'name': 'Destination', 'key': 'destination', 'validator': None, - 'widget': QtWidgets.QLabel, + 'widget': functools.partial( + ui.PaintedLabel, + '', + color=common.color(common.color_green) + ), 'placeholder': '', - 'description': 'Final output path', + 'description': 'The publish\'s destination path', }, }, }, @@ -285,7 +228,7 @@ class PublishWidget(base.BasePropertyEditor): 1: { 'name': 'Template', 'icon': None, - 'color': common.color(common.color_dark_background), + 'color': None, 'groups': { 0: { 0: { @@ -294,17 +237,13 @@ class PublishWidget(base.BasePropertyEditor): 'validator': None, 'widget': TemplateComboBox, 'placeholder': None, - 'description': 'Select the publish template', - }, - 1: { - 'name': 'Task', - 'key': 'publish/task', - 'validator': None, - 'widget': TaskComboBox, - 'placeholder': None, - 'description': 'Select the publish template', + 'description': 'Select a publish template', + 'help': 'Select a publish template from the list. The templates can be customized in the ' + 'bookmark item\'s property editor.', }, - 2: { + }, + 1: { + 0: { 'name': 'Description', 'key': 'description', 'validator': None, @@ -314,7 +253,7 @@ class PublishWidget(base.BasePropertyEditor): 'publish.\nIndicate significant changes and ' 'notes here.', }, - 3: { + 1: { 'name': 'Specify Element', 'key': 'element', 'validator': base.text_validator, @@ -327,7 +266,7 @@ class PublishWidget(base.BasePropertyEditor): }, }, 2: { - 'name': 'Settings', + 'name': 'Options', 'icon': None, 'color': common.color(common.color_dark_background), 'groups': { @@ -343,15 +282,15 @@ class PublishWidget(base.BasePropertyEditor): }, 1: { 0: { - 'name': 'Copy Path to Clipboard', + 'name': 'Copy publish path', 'key': 'publish/copy_path', 'validator': None, 'widget': functools.partial(QtWidgets.QCheckBox, 'Enable'), 'placeholder': None, - 'description': 'Copy the path to the clipboard after finish.', + 'description': 'Copy the publish path to the clipboard after finish.', }, 1: { - 'name': 'Reveal Publish', + 'name': 'Reveal files', 'key': 'publish/reveal', 'validator': None, 'widget': functools.partial(QtWidgets.QCheckBox, 'Enable'), @@ -380,6 +319,44 @@ def __init__(self, index, parent=None): self._connect_settings_save_signals(common.SECTIONS['publish']) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + self.source_editor.clicked.connect(self.source_editor_clicked) + self.destination_editor.clicked.connect(self.destination_editor_clicked) + + @QtCore.Slot() + def source_editor_clicked(self): + v = self.source_editor.text() + if not v: + return + v = common.get_sequence_start_path(v) + file_info = QtCore.QFileInfo(v) + if not file_info.exists(): + common.show_message( + 'Warning', + f'Publish source file does not exist:\n\n{v}' + ) + return + + actions.reveal(v) + + @QtCore.Slot() + def destination_editor_clicked(self): + text = self.destination_editor.text() + if not text: + return + + file_info = QtCore.QFileInfo(text) + _dir = file_info.dir() + if not _dir.exists(): + common.show_message( + 'Publish directory does not exist', + f'Publish has not yet been created:\n\n{_dir.path()}' + ) + return + + actions.reveal(_dir.path()) + def init_progress_bar(self): """Initializes the progress bar. @@ -412,59 +389,12 @@ def init_data(self): raise ValueError('Invalid index value.') self.load_saved_user_settings(common.SECTIONS['publish']) - self.guess_task_and_element() self.init_db_data() self.init_thumbnail() self.set_source_text() - self.update_timer.start() self.init_progress_bar() - def guess_task_and_element(self): - """Guess the task and element name from the input source file name. - - Publishing an element requires both the task and the element - to be specified. The user can set these manually but if we can suggest them - based on the input name we'll set them for the user. - - """ - kwargs = self.get_publish_kwargs() - - p = self._index.data(common.PathRole) - p = common.get_sequence_start_path(p) - - i = QtCore.QFileInfo(p) - - s = f'{i.dir().path()}/{i.baseName()}' - s = s.replace(kwargs['prefix'], '') # remove prefix - s = _strip(s) - s = re.sub(r'v[0-9]{1,9}', '', s) # remove version string - s = _strip(s) - s = s.replace(kwargs['asset'], '') # remove asset name - s = _strip(s) - s = s.replace(common.get_username(), '') # remove username - s = _strip(s) - - u = common.settings.value('file_saver/user') - u = u if u else '' - s = s.replace(u, '') - s = _strip(s) - - sub_dirs = tokens.get_subfolders(tokens.SceneFolder) - if sub_dirs: - task_candidates = [f for f in sub_dirs if f in s] - if len(task_candidates) == 1: - self.publish_task_editor.setCurrentText(task_candidates[0]) - s = s.replace(task_candidates[0], '') - s = _strip(s) - - if s and len(s) >= 3 and 'main' not in s and '_' not in s: - if common.show_message( - 'Publish', body=f'Found a possible element name. Is it correct?\n\n{s}', buttons=[common.YesButton, - common.NoButton], modal=True, ) == QtWidgets.QDialog.Rejected: - return - self.element_editor.setText(s) - def init_thumbnail(self): """Load the item's current thumbnail. @@ -483,19 +413,15 @@ def set_source_text(self): """Set source item label. """ - c = common.rgb(common.color_green) - n = self._index.data(common.PathRole) - - self.source_editor.setText( - f'{n}' - ) + self.source_editor.setText(self._index.data(common.PathRole)) @QtCore.Slot() def update_expanded_template(self): - """Slot connected to the update timer used to preview the output file name. + """Slot used update the source and destination editors. """ kwargs = self.get_publish_kwargs() + if not kwargs['publish_template']: return tokens.invalid_token @@ -550,7 +476,6 @@ def get_publish_kwargs(self, **_kwargs): kwargs['sq'] = kwargs['sequence'] kwargs['seq'] = kwargs['sequence'] - kwargs['task'] = self.publish_task_editor.currentData() kwargs['element'] = self.element_editor.text() kwargs['element'] = kwargs['element'] if kwargs['element'] else 'main' @@ -851,3 +776,70 @@ def post_publish(self, destination, kwargs): actions.reveal(QtCore.QFileInfo(destination).dir().path()) if kwargs['publish_copy_path']: actions.copy_path(destination) + + # Get the source's description from the database + index = self._index + if not index.isValid(): + return + + path = index.data(common.PathRole) + if kwargs['is_collapsed']: + path = common.proxy_path(path) + + db = database.get(*common.active('root', args=True)) + description = db.value(path, 'description', database.AssetTable) + description = description if description else '' + + if '#published' not in description: + description += f' #published' + db.set_value(path, 'description', description, database.AssetTable) + + # Add a note to the database + note = ( + f'\nTime: {time.strftime("%d/%m/%Y %H:%M:%S")}' + f'\nUser: {common.get_username()}' + f'\nDestination:\n{QtCore.QFileInfo(destination).dir().path()}' + ) + notes = db.value(path, 'notes', database.AssetTable) + notes = notes if notes else {} + notes[len(notes)] = { + 'title': 'Publish Log (server)', + 'body': note, + 'extra_data': { + 'created_by': common.get_username(), + 'created_at': time.strftime('%d/%m/%Y %H:%M:%S'), + 'fold': False, + } + } + db.set_value(path, 'notes', notes, database.AssetTable) + + def showEvent(self, event): + """Called when the widget is shown. + + Args: + event (QtCore.QEvent): The event that triggered this slot. + + """ + super().showEvent(event) + self.update_timer.start() + + def hideEvent(self, event): + """Called when the widget is hidden. + + Args: + event (QtCore.QEvent): The event that triggered this slot. + + """ + super().hideEvent(event) + self.update_timer.stop() + + def closeEvent(self, event): + """Called when the widget is closed. + + Args: + event (QtCore.QEvent): The event that triggered this slot. + + """ + super().closeEvent(event) + self.update_timer.stop() + close() \ No newline at end of file diff --git a/bookmarks/shotgun/sg_publish_clip.py b/bookmarks/shotgun/sg_publish_clip.py index 4235d9ca..80204afc 100644 --- a/bookmarks/shotgun/sg_publish_clip.py +++ b/bookmarks/shotgun/sg_publish_clip.py @@ -1,11 +1,12 @@ """The publishing widget used by Bookmarks to create new PublishedFiles and Version entities on ShotGrid. -Our publishing logic creates `Version` and `PublishFile` entities linked against +The publish logic creates `Version` and `PublishFile` entities linked against the current active project and asset and uploads any custom thumbnails set. """ import re +import time from PySide2 import QtCore, QtGui, QtWidgets @@ -313,7 +314,6 @@ def save_changes(self): if not sg_properties.verify(asset=True): raise ValueError('Asset not configured.') - errors = [] with sg_properties.connection() as sg: @@ -347,11 +347,19 @@ def save_changes(self): ) # Publish steps for creating the Cut, CutInfo and Version entities + # Get the entity type + if 'task_entity' in data and data['task_entity']: + entity = data['task_entity'] + elif 'asset_entity' in data and data['asset_entity']: + entity = data['asset_entity'] + else: + raise RuntimeError('No asset or task entity found to associate with the publish.') + version_data = { 'project': data['project_entity'], 'code': data['name'], 'description': data['description'], - 'entity': data['asset_entity'], + 'entity': entity, 'user': user_entity } version = sg.create('Version', version_data) @@ -372,7 +380,7 @@ def save_changes(self): cut_data = { 'project': data['project_entity'], - 'entity': data['asset_entity'], + 'entity': entity, 'description': data['description'], 'version': version } @@ -474,6 +482,8 @@ def save_changes(self): common.close_message() + self.annotate_local_file(data) + errs = '\n\n'.join([str(e) for e in errors]) body = f'Publish successful.\n{len(errors)} errors occurred:\n\n{errs}' if errors else 'Publish successful.' common.show_message( @@ -482,7 +492,38 @@ def save_changes(self): message_type='success', parent=common.main_widget ) - return True + + def annotate_local_file(self, data): + # Stamp the file + # Get the source's description from the database + db = database.get(*common.active('root', args=True)) + description = db.value(data['file_path'], 'description', database.AssetTable) + description = description if description else '' + + if '#sg_published' not in description: + description += f' #sg_published' + db.set_value(data['file_path'], 'description', description, database.AssetTable) + + # Add a note to the database + note = ( + f'Publish Log (ShotGrid)' + f'\nName: {data["name"]}' + f'\nDescription: {data["description"]}' + f'\nTime: {time.strftime("%d/%m/%Y %H:%M:%S")}' + f'\nUser: {common.get_username()}' + ) + notes = db.value(data['file_path'], 'notes', database.AssetTable) + notes = notes if notes else {} + notes[len(notes)] = { + 'title': 'Publish Log (ShotGrid)', + 'body': note, + 'extra_data': { + 'created_by': common.get_username(), + 'created_at': time.strftime('%d/%m/%Y %H:%M:%S'), + 'fold': False, + } + } + db.set_value(data['file_path'], 'notes', notes, database.AssetTable) def get_publish_args(self): """Get all necessary arguments for publishing. diff --git a/bookmarks/ui.py b/bookmarks/ui.py index aa058bb9..fafd1201 100644 --- a/bookmarks/ui.py +++ b/bookmarks/ui.py @@ -361,7 +361,10 @@ def paintEvent(self, event): class PaintedLabel(QtWidgets.QLabel): - """QLabel used for static aliased label.""" + """QLabel used for static aliased label. + + """ + clicked = QtCore.Signal() def __init__( self, text, color=common.color(common.color_text), @@ -369,9 +372,21 @@ def __init__( parent=None ): super().__init__(text, parent=parent) + self._size = size self._color = color self._text = text + + self.update_size() + + def setText(self, v): + self._text = v + self.update_size() + + super().setText(v) + + def set_color(self, v): + self._color = v self.update_size() def update_size(self): @@ -393,12 +408,12 @@ def paintEvent(self, event): painter.begin(self) hover = option.state & QtWidgets.QStyle.State_MouseOver - pressed = option.state & QtWidgets.QStyle.State_Sunken - focus = option.state & QtWidgets.QStyle.State_HasFocus + # pressed = option.state & QtWidgets.QStyle.State_Sunken + # focus = option.state & QtWidgets.QStyle.State_HasFocus disabled = not self.isEnabled() o = 1.0 if hover else 0.8 - o = 0.3 if disabled else 1.0 + o = 0.3 if disabled else o painter.setOpacity(o) rect = self.rect() @@ -425,6 +440,18 @@ def enterEvent(self, event): """ self.update() + def mouseReleaseEvent(self, event): + """Event handler. + + """ + if not isinstance(event, QtGui.QMouseEvent): + return + # Check if the click was inside the label + if not self.rect().contains(event.pos()): + return + if event.button() == QtCore.Qt.LeftButton: + self.clicked.emit() + class ClickableIconButton(QtWidgets.QLabel): """A utility class for creating a square icon button.