4 Commits

Author SHA1 Message Date
Brett Williams
82e50d80bc Use 127.0.0.1 when connecting to localhost 2023-11-22 06:50:13 -08:00
c0d0ec64a8 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
2023-11-21 01:31:56 -08:00
32afcf945d Use loopback address for local host (fixes issue with locked down networks) (#65) 2023-11-21 01:16:26 -08:00
e9f9521924 Report Engine Download Status in UI (#64)
* Report downloads in status bar

* Update engine_browser.py UI with any active downloads
2023-11-20 19:58:31 -08:00
11 changed files with 120 additions and 66 deletions

View File

@@ -20,6 +20,7 @@ categories = [RenderStatus.RUNNING, RenderStatus.WAITING_FOR_SUBJOBS, RenderStat
logger = logging.getLogger()
OFFLINE_MAX = 2
LOOPBACK = '127.0.0.1'
class RenderServerProxy:
@@ -34,6 +35,7 @@ class RenderServerProxy:
self.__background_thread = None
self.__offline_flags = 0
self.update_cadence = 5
self.is_localhost = is_localhost(hostname)
# Cache some basic server info
self.system_cpu = None
@@ -75,7 +77,7 @@ class RenderServerProxy:
return None
def request(self, payload, timeout=5):
return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout)
return requests.get(f'http://{self.optimized_hostname()}:{self.port}/api/{payload}', timeout=timeout)
def start_background_update(self):
if self.__update_in_background:
@@ -140,15 +142,15 @@ class RenderServerProxy:
return self.request_data('all_engines')
def notify_parent_of_status_change(self, parent_id, subjob):
return requests.post(f'http://{self.hostname}:{self.port}/api/job/{parent_id}/notify_parent_of_status_change',
return requests.post(f'http://{self.optimized_hostname()}:{self.port}/api/job/{parent_id}/notify_parent_of_status_change',
json=subjob.json())
def post_job_to_server(self, file_path, job_list, callback=None):
# bypass uploading file if posting to localhost
if is_localhost(self.hostname):
if self.is_localhost:
jobs_with_path = [{**item, "local_path": file_path} for item in job_list]
return requests.post(f'http://{self.hostname}:{self.port}/api/add_job', data=json.dumps(jobs_with_path),
return requests.post(f'http://{LOOPBACK}:{self.port}/api/add_job', data=json.dumps(jobs_with_path),
headers={'Content-Type': 'application/json'})
# Prepare the form data
@@ -168,7 +170,7 @@ class RenderServerProxy:
return requests.post(f'http://{self.hostname}:{self.port}/api/add_job', data=monitor, headers=headers)
def get_job_files(self, job_id, save_path):
url = f"http://{self.hostname}:{self.port}/api/job/{job_id}/download_all"
url = f"http://{self.optimized_hostname()}:{self.port}/api/job/{job_id}/download_all"
return self.download_file(url, filename=save_path)
@staticmethod
@@ -188,4 +190,7 @@ class RenderServerProxy:
def delete_engine(self, engine, version, system_cpu=None):
form_data = {'engine': engine, 'version': version, 'system_cpu': system_cpu}
return requests.post(f'http://{self.hostname}:{self.port}/api/delete_engine', json=form_data)
return requests.post(f'http://{self.optimized_hostname()}:{self.port}/api/delete_engine', json=form_data)
def optimized_hostname(self):
return LOOPBACK if self.is_localhost else self.hostname

View File

@@ -22,6 +22,10 @@ class Blender(BaseRenderEngine):
from src.engines.blender.blender_worker import 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']
@@ -153,13 +157,6 @@ class Blender(BaseRenderEngine):
render_engines = [x.strip() for x in engine_output.split('Blender Engine Listing:')[-1].strip().splitlines()]
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):
packed_path = self.pack_project_file(project_path, timeout=30)
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,6 +47,9 @@ class BaseRenderEngine(object):
def worker_class(): # override when subclassing to link worker class
raise NotImplementedError("Worker class not implemented")
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()
if not path:
@@ -66,9 +69,6 @@ class BaseRenderEngine(object):
def get_arguments(cls):
pass
def get_options(self): # override to return options for ui
return {}
def perform_presubmission_tasks(self, project_path):
return project_path

View File

@@ -76,7 +76,9 @@ class EngineManager:
@classmethod
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
def newest_engine_version(cls, engine, system_os=None, cpu=None):
@@ -84,9 +86,8 @@ class EngineManager:
cpu = cpu or current_system_cpu()
try:
filtered = [x for x in cls.all_engines() if x['engine'] == engine and x['system_os'] == system_os and x['cpu'] == cpu]
versions = sorted(filtered, key=lambda x: x['version'], reverse=True)
return versions[0]
filtered = [x for x in cls.all_versions_for_engine(engine) if x['system_os'] == system_os and x['cpu'] == cpu]
return filtered[0]
except IndexError:
logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}")
return None
@@ -129,15 +130,6 @@ class EngineManager:
@classmethod
def download_engine(cls, engine, version, system_os=None, cpu=None, background=False):
def download_engine_task(engine, version, system_os=None, cpu=None):
existing_download = cls.is_version_downloaded(engine, version, system_os, cpu)
if existing_download:
logger.info(f"Requested download of {engine} {version}, but local copy already exists")
return existing_download
# Get the appropriate downloader class based on the engine type
cls.engine_with_name(engine).downloader().download_engine(version, download_location=cls.engines_path,
system_os=system_os, cpu=cpu, timeout=300)
engine_to_download = cls.engine_with_name(engine)
existing_task = cls.is_already_downloading(engine, version, system_os, cpu)
@@ -152,8 +144,7 @@ class EngineManager:
elif not cls.engines_path:
raise FileNotFoundError("Engines path must be set before requesting downloads")
thread = threading.Thread(target=download_engine_task, args=(engine, version, system_os, cpu),
name=f'{engine}-{version}-{system_os}-{cpu}')
thread = EngineDownloadWorker(engine, version, system_os, cpu)
cls.download_tasks.append(thread)
thread.start()
@@ -252,6 +243,29 @@ class EngineManager:
return undefined_renderer_support[0]
class EngineDownloadWorker(threading.Thread):
def __init__(self, engine, version, system_os=None, cpu=None):
super().__init__()
self.engine = engine
self.version = version
self.system_os = system_os
self.cpu = cpu
def run(self):
existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu)
if existing_download:
logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists")
return existing_download
# Get the appropriate downloader class based on the engine type
EngineManager.engine_with_name(self.engine).downloader().download_engine(
self.version, download_location=EngineManager.engines_path, system_os=self.system_os, cpu=self.cpu,
timeout=300)
# remove itself from the downloader list
EngineManager.download_tasks.remove(self)
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')

View File

@@ -18,6 +18,10 @@ class FFMPEG(BaseRenderEngine):
from src.engines.ffmpeg.ffmpeg_worker import 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)

View File

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

View File

@@ -21,10 +21,11 @@ from src.utilities.zeroconf_server import ZeroconfServer
class NewRenderJobForm(QWidget):
def __init__(self, project_path=None):
super().__init__()
self.project_path = project_path
# UI
self.project_group = None
self.load_file_group = None
self.current_engine_options = None
self.file_format_combo = None
self.renderer_options_layout = None
@@ -73,41 +74,41 @@ class NewRenderJobForm(QWidget):
# Main Layout
main_layout = QVBoxLayout(self)
# Scene File Group
scene_file_group = QGroupBox("Project")
scene_file_layout = QVBoxLayout(scene_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)
# Loading File Group
self.load_file_group = QGroupBox("Loading")
load_file_layout = QVBoxLayout(self.load_file_group)
# progress bar
progress_layout = QHBoxLayout()
self.process_progress_bar = QProgressBar()
self.process_progress_bar.setMinimum(0)
self.process_progress_bar.setMaximum(0)
self.process_progress_bar.setHidden(True)
self.process_label = QLabel("Processing")
self.process_label.setHidden(True)
progress_layout.addWidget(self.process_label)
progress_layout.addWidget(self.process_progress_bar)
scene_file_layout.addLayout(progress_layout)
main_layout.addWidget(scene_file_group)
load_file_layout.addLayout(progress_layout)
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
self.server_group = QGroupBox("Server")
server_layout = QVBoxLayout(self.server_group)
server_list_layout = QHBoxLayout()
server_list_layout.setSpacing(0)
self.server_input = QComboBox()
server_list_layout.addWidget(QLabel("Hostname:"), 1)
server_list_layout.addWidget(self.server_input, 3)
server_layout.addLayout(server_list_layout)
main_layout.addWidget(self.server_group)
main_layout.addWidget(self.project_group)
self.update_server_list()
# Priority
priority_layout = QHBoxLayout()
@@ -310,8 +311,7 @@ class NewRenderJobForm(QWidget):
self.output_path_input.setText(os.path.basename(input_path))
# cleanup progress UI
self.process_progress_bar.setHidden(True)
self.process_label.setHidden(True)
self.load_file_group.setHidden(True)
self.toggle_renderer_enablement(True)
# Load scene data
@@ -342,7 +342,7 @@ class NewRenderJobForm(QWidget):
# Dynamic Engine Options
clear_layout(self.renderer_options_layout) # clear old options
# 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:
h_layout = QHBoxLayout()
label = QLabel(option['name'].capitalize() + ':')
@@ -361,7 +361,7 @@ class NewRenderJobForm(QWidget):
def toggle_renderer_enablement(self, enabled=False):
"""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.renderer_group.setHidden(not enabled)
self.notes_group.setHidden(not enabled)

View File

@@ -4,6 +4,7 @@ import subprocess
import sys
import threading
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QPushButton, QTableWidget, QTableWidgetItem, QHBoxLayout, QAbstractItemView,
QHeaderView, QProgressBar, QLabel, QMessageBox
@@ -28,6 +29,7 @@ class EngineBrowserWindow(QMainWindow):
self.setGeometry(100, 100, 500, 300)
self.engine_data = []
self.initUI()
self.init_timer()
def initUI(self):
# Central widget
@@ -82,6 +84,12 @@ class EngineBrowserWindow(QMainWindow):
self.update_download_status()
def init_timer(self):
# Set up the timer
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_download_status)
self.timer.start(1000)
def update_table(self):
def update_table_worker():
@@ -124,9 +132,15 @@ class EngineBrowserWindow(QMainWindow):
hide_progress = not bool(running_tasks)
self.progress_bar.setHidden(hide_progress)
self.progress_label.setHidden(hide_progress)
# todo: update progress bar with status
self.progress_label.setText(f"Downloading {len(running_tasks)} engines")
# Update the status labels
if len(EngineManager.download_tasks) == 0:
new_status = ""
elif len(EngineManager.download_tasks) == 1:
task = EngineManager.download_tasks[0]
new_status = f"Downloading {task.engine.capitalize()} {task.version}..."
else:
new_status = f"Downloading {len(EngineManager.download_tasks)} engines..."
self.progress_label.setText(new_status)
def launch_button_click(self):
engine_info = self.engine_data[self.table_widget.currentRow()]

View File

@@ -477,7 +477,7 @@ class MainWindow(QMainWindow):
"""
selected_job_ids = self.selected_job_ids()
if selected_job_ids:
url = f'http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}/api/job/{selected_job_ids[0]}/logs'
url = f'http://{self.current_server_proxy.optimized_hostname()}:{self.current_server_proxy.port}/api/job/{selected_job_ids[0]}/logs'
self.log_viewer_window = LogViewer(url)
self.log_viewer_window.show()

View File

@@ -9,6 +9,7 @@ from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QStatusBar, QLabel
from src.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager
from src.utilities.misc_helper import resources_dir
@@ -28,17 +29,23 @@ class StatusBar(QStatusBar):
proxy = RenderServerProxy(socket.gethostname())
proxy.start_background_update()
image_names = {'Ready': 'GreenCircle.png', 'Offline': "RedSquare.png"}
last_update = None
# Check for status change every 1s on background thread
while True:
new_status = proxy.status()
if new_status is not last_update:
new_image_name = image_names.get(new_status, 'Synchronize.png')
image_path = os.path.join(resources_dir(), 'icons', new_image_name)
self.label.setPixmap((QPixmap(image_path).scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio)))
self.messageLabel.setText(new_status)
last_update = new_status
new_image_name = image_names.get(new_status, 'Synchronize.png')
image_path = os.path.join(resources_dir(), 'icons', new_image_name)
self.label.setPixmap((QPixmap(image_path).scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio)))
# add download status
if EngineManager.download_tasks:
if len(EngineManager.download_tasks) == 1:
task = EngineManager.download_tasks[0]
new_status = f"{new_status} | Downloading {task.engine.capitalize()} {task.version}..."
else:
new_status = f"{new_status} | Downloading {len(EngineManager.download_tasks)} engines"
self.messageLabel.setText(new_status)
time.sleep(1)
background_thread = threading.Thread(target=background_update,)