Dynamic engine options in UI for blender / ffmpeg (#66)

* Make sure progress UI updates occur on main thread

* Cleanup unnecessary code in FFMPEG

* Cleanup extension matching

* Make sure supported_extensions is now called as a method everywhere

* Fix add_job crashing

* Update the renderer to reflect the current file type

* Sort engine versions from newest to oldest

* Consolidate Project Group and Server Group

* Split UI options into its own file for easier updating

* Add ffmpeg ui stem
This commit is contained in:
2023-11-21 03:31:56 -06:00
committed by GitHub
parent 32afcf945d
commit c0d0ec64a8
10 changed files with 109 additions and 72 deletions

View File

@@ -423,10 +423,12 @@ def renderer_info():
# Get all installed versions of engine # Get all installed versions of engine
installed_versions = EngineManager.all_versions_for_engine(engine.name()) installed_versions = EngineManager.all_versions_for_engine(engine.name())
if installed_versions: if installed_versions:
install_path = installed_versions[0]['path'] # fixme: using system versions only because downloaded versions may have permissions issues
system_installed_versions = [x for x in installed_versions if x['type'] == 'system']
install_path = system_installed_versions[0]['path'] if system_installed_versions else installed_versions[0]['path']
renderer_data[engine.name()] = {'is_available': RenderQueue.is_available_for_job(engine.name()), renderer_data[engine.name()] = {'is_available': RenderQueue.is_available_for_job(engine.name()),
'versions': installed_versions, 'versions': installed_versions,
'supported_extensions': engine.supported_extensions, 'supported_extensions': engine.supported_extensions(),
'supported_export_formats': engine(install_path).get_output_formats()} 'supported_export_formats': engine(install_path).get_output_formats()}
return renderer_data return renderer_data

View File

@@ -10,7 +10,6 @@ logger = logging.getLogger()
class Blender(BaseRenderEngine): class Blender(BaseRenderEngine):
install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender'] install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender']
supported_extensions = ['.blend']
binary_names = {'linux': 'blender', 'windows': 'blender.exe', 'macos': 'Blender'} binary_names = {'linux': 'blender', 'windows': 'blender.exe', 'macos': 'Blender'}
@staticmethod @staticmethod
@@ -23,6 +22,14 @@ class Blender(BaseRenderEngine):
from src.engines.blender.blender_worker import BlenderRenderWorker from src.engines.blender.blender_worker import BlenderRenderWorker
return BlenderRenderWorker return BlenderRenderWorker
def ui_options(self):
from src.engines.blender.blender_ui import BlenderUI
return BlenderUI.get_options(self)
@staticmethod
def supported_extensions():
return ['blend']
def version(self): def version(self):
version = None version = None
try: try:
@@ -150,13 +157,6 @@ class Blender(BaseRenderEngine):
render_engines = [x.strip() for x in engine_output.split('Blender Engine Listing:')[-1].strip().splitlines()] render_engines = [x.strip() for x in engine_output.split('Blender Engine Listing:')[-1].strip().splitlines()]
return render_engines return render_engines
# UI and setup
def get_options(self):
options = [
{'name': 'engine', 'options': self.supported_render_engines()},
]
return options
def perform_presubmission_tasks(self, project_path): def perform_presubmission_tasks(self, project_path):
packed_path = self.pack_project_file(project_path, timeout=30) packed_path = self.pack_project_file(project_path, timeout=30)
return packed_path return packed_path

View File

@@ -0,0 +1,8 @@
class BlenderUI:
@staticmethod
def get_options(instance):
options = [
{'name': 'engine', 'options': instance.supported_render_engines()},
]
return options

View File

@@ -47,7 +47,10 @@ class BaseRenderEngine(object):
def worker_class(): # override when subclassing to link worker class def worker_class(): # override when subclassing to link worker class
raise NotImplementedError("Worker class not implemented") raise NotImplementedError("Worker class not implemented")
def get_help(self): def ui_options(self): # override to return options for ui
return {}
def get_help(self): # override if renderer uses different help flag
path = self.renderer_path() path = self.renderer_path()
if not path: if not path:
raise FileNotFoundError("renderer path not found") raise FileNotFoundError("renderer path not found")
@@ -56,7 +59,7 @@ class BaseRenderEngine(object):
return help_doc return help_doc
def get_project_info(self, project_path, timeout=10): def get_project_info(self, project_path, timeout=10):
raise NotImplementedError(f"get_project_info not implemented for {cls.__name__}") raise NotImplementedError(f"get_project_info not implemented for {self.__name__}")
@classmethod @classmethod
def get_output_formats(cls): def get_output_formats(cls):

View File

@@ -47,7 +47,7 @@ class BaseRenderWorker(Base):
name=None): name=None):
if not ignore_extensions: if not ignore_extensions:
if not any(ext in input_path for ext in self.engine.supported_extensions): if not any(ext in input_path for ext in self.engine.supported_extensions()):
err_meg = f'Cannot find valid project with supported file extension for {self.engine.name()} renderer' err_meg = f'Cannot find valid project with supported file extension for {self.engine.name()} renderer'
logger.error(err_meg) logger.error(err_meg)
raise ValueError(err_meg) raise ValueError(err_meg)

View File

@@ -76,7 +76,9 @@ class EngineManager:
@classmethod @classmethod
def all_versions_for_engine(cls, engine): def all_versions_for_engine(cls, engine):
return [x for x in cls.all_engines() if x['engine'] == engine] versions = [x for x in cls.all_engines() if x['engine'] == engine]
sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True)
return sorted_versions
@classmethod @classmethod
def newest_engine_version(cls, engine, system_os=None, cpu=None): def newest_engine_version(cls, engine, system_os=None, cpu=None):
@@ -84,9 +86,8 @@ class EngineManager:
cpu = cpu or current_system_cpu() cpu = cpu or current_system_cpu()
try: try:
filtered = [x for x in cls.all_engines() if x['engine'] == engine and x['system_os'] == system_os and x['cpu'] == cpu] filtered = [x for x in cls.all_versions_for_engine(engine) if x['system_os'] == system_os and x['cpu'] == cpu]
versions = sorted(filtered, key=lambda x: x['version'], reverse=True) return filtered[0]
return versions[0]
except IndexError: except IndexError:
logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}") logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}")
return None return None
@@ -234,10 +235,11 @@ class EngineManager:
@classmethod @classmethod
def engine_for_project_path(cls, path): def engine_for_project_path(cls, path):
name, extension = os.path.splitext(path) name, extension = os.path.splitext(path)
extension = extension.lower().strip('.')
for engine in cls.supported_engines(): for engine in cls.supported_engines():
if extension in engine.supported_extensions: if extension in engine.supported_extensions():
return engine return engine
undefined_renderer_support = [x for x in cls.supported_engines() if not x.supported_extensions] undefined_renderer_support = [x for x in cls.supported_engines() if not x.supported_extensions()]
return undefined_renderer_support[0] return undefined_renderer_support[0]

View File

@@ -18,6 +18,20 @@ class FFMPEG(BaseRenderEngine):
from src.engines.ffmpeg.ffmpeg_worker import FFMPEGRenderWorker from src.engines.ffmpeg.ffmpeg_worker import FFMPEGRenderWorker
return FFMPEGRenderWorker return FFMPEGRenderWorker
def ui_options(self):
from src.engines.ffmpeg.ffmpeg_ui import FFMPEGUI
return FFMPEGUI.get_options(self)
@classmethod
def supported_extensions(cls):
help_text = (subprocess.check_output([cls().renderer_path(), '-h', 'full'], stderr=subprocess.STDOUT)
.decode('utf-8'))
found = re.findall('extensions that .* is allowed to access \(default "(.*)"', help_text)
found_extensions = set()
for match in found:
found_extensions.update(match.split(','))
return list(found_extensions)
def version(self): def version(self):
version = None version = None
try: try:
@@ -31,15 +45,11 @@ class FFMPEG(BaseRenderEngine):
return version return version
def get_project_info(self, project_path, timeout=10): def get_project_info(self, project_path, timeout=10):
return self.get_video_info_ffprobe(project_path)
@staticmethod
def get_video_info_ffprobe(video_path):
try: try:
# Run ffprobe and parse the output as JSON # Run ffprobe and parse the output as JSON
cmd = [ cmd = [
'ffprobe', '-v', 'quiet', '-print_format', 'json', 'ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_streams', '-select_streams', 'v', video_path '-show_streams', '-select_streams', 'v', project_path
] ]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
video_info = json.loads(result.stdout) video_info = json.loads(result.stdout)
@@ -85,7 +95,7 @@ class FFMPEG(BaseRenderEngine):
try: try:
formats_raw = subprocess.check_output([self.renderer_path(), '-formats'], stderr=subprocess.DEVNULL, formats_raw = subprocess.check_output([self.renderer_path(), '-formats'], stderr=subprocess.DEVNULL,
timeout=SUBPROCESS_TIMEOUT).decode('utf-8') timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
pattern = '(?P<type>[DE]{1,2})\s+(?P<id>\S{2,})\s+(?P<name>.*)\r' pattern = '(?P<type>[DE]{1,2})\s+(?P<id>\S{2,})\s+(?P<name>.*)'
all_formats = [m.groupdict() for m in re.finditer(pattern, formats_raw)] all_formats = [m.groupdict() for m in re.finditer(pattern, formats_raw)]
return all_formats return all_formats
except Exception as e: except Exception as e:
@@ -105,7 +115,7 @@ class FFMPEG(BaseRenderEngine):
return found_extensions return found_extensions
def get_output_formats(self): def get_output_formats(self):
return [x for x in self.get_all_formats() if 'E' in x['type'].upper()] return [x['id'] for x in self.get_all_formats() if 'E' in x['type'].upper()]
def get_frame_count(self, path_to_file): def get_frame_count(self, path_to_file):
raw_stdout = subprocess.check_output([self.renderer_path(), '-i', path_to_file, '-map', '0:v:0', '-c', 'copy', raw_stdout = subprocess.check_output([self.renderer_path(), '-i', path_to_file, '-map', '0:v:0', '-c', 'copy',
@@ -117,7 +127,8 @@ class FFMPEG(BaseRenderEngine):
return frame_number return frame_number
def get_arguments(self): def get_arguments(self):
help_text = subprocess.check_output([self.renderer_path(), '-h', 'long'], stderr=subprocess.STDOUT).decode('utf-8') help_text = (subprocess.check_output([self.renderer_path(), '-h', 'long'], stderr=subprocess.STDOUT)
.decode('utf-8'))
lines = help_text.splitlines() lines = help_text.splitlines()
options = {} options = {}

View File

@@ -0,0 +1,5 @@
class FFMPEGUI:
@staticmethod
def get_options(instance):
options = []
return options

View File

@@ -10,15 +10,9 @@ class FFMPEGRenderWorker(BaseRenderWorker):
engine = FFMPEG engine = FFMPEG
def __init__(self, input_path, output_path, args=None, parent=None, name=None): def __init__(self, input_path, output_path, engine_path, args=None, parent=None, name=None):
super(FFMPEGRenderWorker, self).__init__(input_path=input_path, output_path=output_path, args=args, super(FFMPEGRenderWorker, self).__init__(input_path=input_path, output_path=output_path,
parent=parent, name=name) engine_path=engine_path, args=args, parent=parent, name=name)
stream_info = subprocess.check_output([self.renderer_path, "-i", # https://stackoverflow.com/a/61604105
input_path, "-map", "0:v:0", "-c", "copy", "-f", "null", "-y",
"/dev/null"], stderr=subprocess.STDOUT).decode('utf-8')
found_frames = re.findall('frame=\s*(\d+)', stream_info)
self.project_length = found_frames[-1] if found_frames else '-1'
self.current_frame = -1 self.current_frame = -1
def generate_worker_subprocess(self): def generate_worker_subprocess(self):

View File

@@ -5,7 +5,7 @@ import socket
import threading import threading
import psutil import psutil
from PyQt6.QtCore import QThread, pyqtSignal, Qt from PyQt6.QtCore import QThread, pyqtSignal, Qt, pyqtSlot
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox, QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox,
QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem
@@ -21,10 +21,11 @@ from src.utilities.zeroconf_server import ZeroconfServer
class NewRenderJobForm(QWidget): class NewRenderJobForm(QWidget):
def __init__(self, project_path=None): def __init__(self, project_path=None):
super().__init__() super().__init__()
self.project_path = project_path self.project_path = project_path
# UI # UI
self.project_group = None
self.load_file_group = None
self.current_engine_options = None self.current_engine_options = None
self.file_format_combo = None self.file_format_combo = None
self.renderer_options_layout = None self.renderer_options_layout = None
@@ -73,41 +74,41 @@ class NewRenderJobForm(QWidget):
# Main Layout # Main Layout
main_layout = QVBoxLayout(self) main_layout = QVBoxLayout(self)
# Scene File Group # Loading File Group
scene_file_group = QGroupBox("Project") self.load_file_group = QGroupBox("Loading")
scene_file_layout = QVBoxLayout(scene_file_group) load_file_layout = QVBoxLayout(self.load_file_group)
scene_file_picker_layout = QHBoxLayout()
self.scene_file_input = QLineEdit()
self.scene_file_input.setText(self.project_path)
self.scene_file_browse_button = QPushButton("Browse...")
self.scene_file_browse_button.clicked.connect(self.browse_scene_file)
scene_file_picker_layout.addWidget(self.scene_file_input)
scene_file_picker_layout.addWidget(self.scene_file_browse_button)
scene_file_layout.addLayout(scene_file_picker_layout)
# progress bar # progress bar
progress_layout = QHBoxLayout() progress_layout = QHBoxLayout()
self.process_progress_bar = QProgressBar() self.process_progress_bar = QProgressBar()
self.process_progress_bar.setMinimum(0) self.process_progress_bar.setMinimum(0)
self.process_progress_bar.setMaximum(0) self.process_progress_bar.setMaximum(0)
self.process_progress_bar.setHidden(True)
self.process_label = QLabel("Processing") self.process_label = QLabel("Processing")
self.process_label.setHidden(True)
progress_layout.addWidget(self.process_label) progress_layout.addWidget(self.process_label)
progress_layout.addWidget(self.process_progress_bar) progress_layout.addWidget(self.process_progress_bar)
scene_file_layout.addLayout(progress_layout) load_file_layout.addLayout(progress_layout)
main_layout.addWidget(scene_file_group) main_layout.addWidget(self.load_file_group)
# Server Group # Project Group
self.project_group = QGroupBox("Project")
server_layout = QVBoxLayout(self.project_group)
# File Path
scene_file_picker_layout = QHBoxLayout()
self.scene_file_input = QLineEdit()
self.scene_file_input.setText(self.project_path)
self.scene_file_browse_button = QPushButton("Browse...")
self.scene_file_browse_button.clicked.connect(self.browse_scene_file)
scene_file_picker_layout.addWidget(QLabel("File:"))
scene_file_picker_layout.addWidget(self.scene_file_input)
scene_file_picker_layout.addWidget(self.scene_file_browse_button)
server_layout.addLayout(scene_file_picker_layout)
# Server List # Server List
self.server_group = QGroupBox("Server")
server_layout = QVBoxLayout(self.server_group)
server_list_layout = QHBoxLayout() server_list_layout = QHBoxLayout()
server_list_layout.setSpacing(0) server_list_layout.setSpacing(0)
self.server_input = QComboBox() self.server_input = QComboBox()
server_list_layout.addWidget(QLabel("Hostname:"), 1) server_list_layout.addWidget(QLabel("Hostname:"), 1)
server_list_layout.addWidget(self.server_input, 3) server_list_layout.addWidget(self.server_input, 3)
server_layout.addLayout(server_list_layout) server_layout.addLayout(server_list_layout)
main_layout.addWidget(self.server_group) main_layout.addWidget(self.project_group)
self.update_server_list() self.update_server_list()
# Priority # Priority
priority_layout = QHBoxLayout() priority_layout = QHBoxLayout()
@@ -234,8 +235,13 @@ class NewRenderJobForm(QWidget):
self.toggle_renderer_enablement(False) self.toggle_renderer_enablement(False)
def update_renderer_info(self): def update_renderer_info(self):
# get the renderer info and add them all to the ui
self.renderer_info = self.server_proxy.get_renderer_info() self.renderer_info = self.server_proxy.get_renderer_info()
self.renderer_type.addItems(self.renderer_info.keys()) self.renderer_type.addItems(self.renderer_info.keys())
# select the best renderer for the file type
engine = EngineManager.engine_for_project_path(self.project_path)
self.renderer_type.setCurrentText(engine.name().lower())
# refresh ui
self.renderer_changed() self.renderer_changed()
def renderer_changed(self): def renderer_changed(self):
@@ -294,15 +300,18 @@ class NewRenderJobForm(QWidget):
# Set the best renderer we can find # Set the best renderer we can find
input_path = self.scene_file_input.text() input_path = self.scene_file_input.text()
engine = EngineManager.engine_for_project_path(input_path) engine = EngineManager.engine_for_project_path(input_path)
index = self.renderer_type.findText(engine.name().lower())
if index >= 0: engine_index = self.renderer_type.findText(engine.name().lower())
self.renderer_type.setCurrentIndex(index) if engine_index >= 0:
self.renderer_type.setCurrentIndex(engine_index)
else:
self.renderer_type.setCurrentIndex(0) #todo: find out why we don't have renderer info yet
# not ideal but if we don't have the renderer info we have to pick something
self.output_path_input.setText(os.path.basename(input_path)) self.output_path_input.setText(os.path.basename(input_path))
# cleanup progress UI # cleanup progress UI
self.process_progress_bar.setHidden(True) self.load_file_group.setHidden(True)
self.process_label.setHidden(True)
self.toggle_renderer_enablement(True) self.toggle_renderer_enablement(True)
# Load scene data # Load scene data
@@ -331,12 +340,9 @@ class NewRenderJobForm(QWidget):
self.cameras_group.setHidden(True) self.cameras_group.setHidden(True)
# Dynamic Engine Options # Dynamic Engine Options
engine_name = self.renderer_type.currentText() clear_layout(self.renderer_options_layout) # clear old options
engine = EngineManager.engine_with_name(engine_name)
# clear old options
clear_layout(self.renderer_options_layout)
# dynamically populate option list # dynamically populate option list
self.current_engine_options = engine().get_options() self.current_engine_options = engine().ui_options()
for option in self.current_engine_options: for option in self.current_engine_options:
h_layout = QHBoxLayout() h_layout = QHBoxLayout()
label = QLabel(option['name'].capitalize() + ':') label = QLabel(option['name'].capitalize() + ':')
@@ -355,7 +361,7 @@ class NewRenderJobForm(QWidget):
def toggle_renderer_enablement(self, enabled=False): def toggle_renderer_enablement(self, enabled=False):
"""Toggle on/off all the render settings""" """Toggle on/off all the render settings"""
self.server_group.setHidden(not enabled) self.project_group.setHidden(not enabled)
self.output_settings_group.setHidden(not enabled) self.output_settings_group.setHidden(not enabled)
self.renderer_group.setHidden(not enabled) self.renderer_group.setHidden(not enabled)
self.notes_group.setHidden(not enabled) self.notes_group.setHidden(not enabled)
@@ -402,14 +408,23 @@ class NewRenderJobForm(QWidget):
# submit job in background thread # submit job in background thread
self.worker_thread = SubmitWorker(window=self) self.worker_thread = SubmitWorker(window=self)
self.worker_thread.update_ui_signal.connect(self.update_submit_progress)
self.worker_thread.message_signal.connect(self.after_job_submission) self.worker_thread.message_signal.connect(self.after_job_submission)
self.worker_thread.start() self.worker_thread.start()
@pyqtSlot(str, str)
def update_submit_progress(self, hostname, percent):
# Update the UI here. This slot will be executed in the main thread
self.submit_progress_label.setText(f"Transferring to {hostname} - {percent}%")
self.submit_progress.setMaximum(100)
self.submit_progress.setValue(int(percent))
class SubmitWorker(QThread): class SubmitWorker(QThread):
"""Worker class called to submit all the jobs to the server and update the UI accordingly""" """Worker class called to submit all the jobs to the server and update the UI accordingly"""
message_signal = pyqtSignal(Response) message_signal = pyqtSignal(Response)
update_ui_signal = pyqtSignal(str, str)
def __init__(self, window): def __init__(self, window):
super().__init__() super().__init__()
@@ -421,10 +436,7 @@ class SubmitWorker(QThread):
def callback(monitor): def callback(monitor):
percent = f"{monitor.bytes_read / encoder_len * 100:.0f}" percent = f"{monitor.bytes_read / encoder_len * 100:.0f}"
self.window.submit_progress_label.setText(f"Transferring to {hostname} - {percent}%") self.update_ui_signal.emit(hostname, percent)
self.window.submit_progress.setMaximum(100)
self.window.submit_progress.setValue(int(percent))
return callback return callback
hostname = self.window.server_input.currentText() hostname = self.window.server_input.currentText()