Add Job Window Redesign (#128)

* Initial refactor of add_job_window

* Improved project naming and fixed Blender engine issue

* Improve time representation in main window

* Cleanup Blender job creation

* Send resolution / fps data in job submission

* More window improvements

* EngineManager renaming and refactoring

* FFMPEG path fixes for ffprobe

* More backend refactoring / improvements

* Performance improvements / API refactoring

* Show current job count in add window UI before submission

* Move some UI update code out of background thread

* Move some main window UI update code out of background thread
This commit is contained in:
2026-01-12 09:06:53 -06:00
committed by GitHub
parent d8af7c878e
commit 8b3fdd14b5
18 changed files with 657 additions and 372 deletions

View File

@@ -1,32 +1,33 @@
import copy
import os.path
import pathlib
import socket
import threading
import psutil
from PyQt6.QtCore import QThread, pyqtSignal, Qt, pyqtSlot
from PyQt6.QtWidgets import (
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,
QTabWidget
)
from requests import Response
from src.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager
from src.ui.engine_help_window import EngineHelpViewer
from src.utilities.zeroconf_server import ZeroconfServer
from src.utilities.misc_helper import COMMON_RESOLUTIONS
from utilities.misc_helper import COMMON_FRAME_RATES
class NewRenderJobForm(QWidget):
def __init__(self, project_path=None):
super().__init__()
self.notes_group = None
self.frame_rate_input = None
self.resolution_options_list = None
self.resolution_x_input = None
self.engine_group = None
self.output_settings_group = None
self.resolution_y_input = None
self.fps_options_list = None
self.fps_input = None
self.engine_group = None
self.notes_group = None
self.output_settings_group = None
self.project_path = project_path
# UI
@@ -55,152 +56,202 @@ class NewRenderJobForm(QWidget):
self.priority_input = None
self.end_frame_input = None
self.start_frame_input = None
self.render_name_input = None
self.job_name_input = None
self.scene_file_input = None
self.scene_file_browse_button = None
self.job_name_input = None
self.tabs = None
# Job / Server Data
self.server_proxy = RenderServerProxy(socket.gethostname())
self.engine_info = None
self.project_info = None
self.installed_engines = {}
self.preferred_engine = None
# Setup
self.setWindowTitle("New Job")
self.setup_ui()
self.update_engine_info()
self.setup_project()
# get renderer info in bg thread
# t = threading.Thread(target=self.update_renderer_info)
# t.start()
self.show()
def setup_ui(self):
# Main Layout
# Main widget layout
main_layout = QVBoxLayout(self)
# Loading File Group
# Tabs
self.tabs = QTabWidget()
# ==================== Loading Section (outside tabs) ====================
self.load_file_group = QGroupBox("Loading")
load_file_layout = QVBoxLayout(self.load_file_group)
# progress bar
progress_layout = QHBoxLayout()
self.process_label = QLabel("Processing")
self.process_progress_bar = QProgressBar()
self.process_progress_bar.setMinimum(0)
self.process_progress_bar.setMaximum(0)
self.process_label = QLabel("Processing")
self.process_progress_bar.setMaximum(0) # Indeterminate
progress_layout.addWidget(self.process_label)
progress_layout.addWidget(self.process_progress_bar)
load_file_layout.addLayout(progress_layout)
main_layout.addWidget(self.load_file_group)
# Project Group
self.project_group = QGroupBox("Project")
server_layout = QVBoxLayout(self.project_group)
# File Path
# Scene File
job_overview_group = QGroupBox("Project File")
file_group_layout = QVBoxLayout(job_overview_group)
# Job Name
job_name_layout = QHBoxLayout()
job_name_layout.addWidget(QLabel("Job name:"))
self.job_name_input = QLineEdit()
job_name_layout.addWidget(self.job_name_input)
self.engine_type = QComboBox()
job_name_layout.addWidget(self.engine_type)
file_group_layout.addLayout(job_name_layout)
# Job File
scene_file_picker_layout = QHBoxLayout()
scene_file_picker_layout.addWidget(QLabel("File:"))
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
file_group_layout.addLayout(scene_file_picker_layout)
main_layout.addWidget(job_overview_group)
main_layout.addWidget(self.load_file_group)
main_layout.addWidget(self.tabs)
# ==================== Tab 1: Job Settings ====================
self.project_group = QWidget()
project_layout = QVBoxLayout(self.project_group) # Fixed: proper parent
# Server / Hostname
server_list_layout = QHBoxLayout()
server_list_layout.setSpacing(0)
server_list_layout.addWidget(QLabel("Render Target:"))
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.project_group)
self.update_server_list()
server_list_layout.addWidget(self.server_input)
project_layout.addLayout(server_list_layout)
# Priority
priority_layout = QHBoxLayout()
priority_layout.addWidget(QLabel("Priority:"), 1)
priority_layout.addWidget(QLabel("Priority:"))
self.priority_input = QComboBox()
self.priority_input.addItems(["High", "Medium", "Low"])
self.priority_input.setCurrentIndex(1)
priority_layout.addWidget(self.priority_input, 3)
server_layout.addLayout(priority_layout)
# Splitjobs
self.enable_splitjobs = QCheckBox("Automatically split render across multiple servers")
self.enable_splitjobs.setEnabled(True)
server_layout.addWidget(self.enable_splitjobs)
self.splitjobs_same_os = QCheckBox("Only render on same OS")
self.splitjobs_same_os.setEnabled(True)
server_layout.addWidget(self.splitjobs_same_os)
priority_layout.addWidget(self.priority_input)
project_layout.addLayout(priority_layout)
# Output Settings Group
self.output_settings_group = QGroupBox("Output Settings")
# Split Jobs Options
self.enable_splitjobs = QCheckBox("Automatically split render across multiple servers")
project_layout.addWidget(self.enable_splitjobs)
self.splitjobs_same_os = QCheckBox("Only render on same OS")
project_layout.addWidget(self.splitjobs_same_os)
project_layout.addStretch() # Push everything up
# ==================== Tab 2: Output Settings ====================
self.output_settings_group = QWidget()
output_settings_layout = QVBoxLayout(self.output_settings_group)
# output path
render_name_layout = QHBoxLayout()
render_name_layout.addWidget(QLabel("Render name:"))
self.render_name_input = QLineEdit()
render_name_layout.addWidget(self.render_name_input)
output_settings_layout.addLayout(render_name_layout)
# file format
# File Format
format_group = QGroupBox("Format / Range")
output_settings_layout.addWidget(format_group)
format_group_layout = QVBoxLayout()
format_group.setLayout(format_group_layout)
file_format_layout = QHBoxLayout()
file_format_layout.addWidget(QLabel("Format:"))
self.file_format_combo = QComboBox()
self.file_format_combo.setFixedWidth(200)
file_format_layout.addWidget(self.file_format_combo)
output_settings_layout.addLayout(file_format_layout)
# frame range
frame_range_layout = QHBoxLayout(self.output_settings_group)
file_format_layout.addStretch()
format_group_layout.addLayout(file_format_layout)
# Frame Range
frame_range_layout = QHBoxLayout()
frame_range_layout.addWidget(QLabel("Frames:"))
self.start_frame_input = QSpinBox()
self.start_frame_input.setRange(1, 99999)
self.start_frame_input.setFixedWidth(80)
self.end_frame_input = QSpinBox()
self.end_frame_input.setRange(1, 99999)
frame_range_layout.addWidget(QLabel("Frames:"))
self.end_frame_input.setFixedWidth(80)
frame_range_layout.addWidget(self.start_frame_input)
frame_range_layout.addWidget(QLabel("to"))
frame_range_layout.addWidget(self.end_frame_input)
output_settings_layout.addLayout(frame_range_layout)
# resolution
resolution_layout = QHBoxLayout(self.output_settings_group)
frame_range_layout.addStretch()
format_group_layout.addLayout(frame_range_layout)
# --- Resolution & FPS Group ---
resolution_group = QGroupBox("Resolution / Frame Rate")
output_settings_layout.addWidget(resolution_group)
resolution_group_layout = QVBoxLayout()
resolution_group.setLayout(resolution_group_layout)
# Resolution
resolution_layout = QHBoxLayout()
self.resolution_options_list = QComboBox()
self.resolution_options_list.setFixedWidth(200)
self.resolution_options_list.addItem("Original Size")
for res in COMMON_RESOLUTIONS:
self.resolution_options_list.addItem(res)
self.resolution_options_list.currentIndexChanged.connect(self._resolution_preset_changed)
resolution_layout.addWidget(self.resolution_options_list)
resolution_group_layout.addLayout(resolution_layout)
self.resolution_x_input = QSpinBox()
self.resolution_x_input.setRange(1, 9999) # Assuming max resolution width 9999
self.resolution_x_input.setRange(1, 9999)
self.resolution_x_input.setValue(1920)
self.resolution_y_input = QSpinBox()
self.resolution_y_input.setRange(1, 9999) # Assuming max resolution height 9999
self.resolution_y_input.setValue(1080)
self.frame_rate_input = QDoubleSpinBox()
self.frame_rate_input.setRange(1, 9999) # Assuming max resolution width 9999
self.frame_rate_input.setDecimals(3)
self.frame_rate_input.setValue(23.976)
resolution_layout.addWidget(QLabel("Resolution:"))
self.resolution_x_input.setFixedWidth(80)
resolution_layout.addWidget(self.resolution_x_input)
self.resolution_y_input = QSpinBox()
self.resolution_y_input.setRange(1, 9999)
self.resolution_y_input.setValue(1080)
self.resolution_y_input.setFixedWidth(80)
resolution_layout.addWidget(QLabel("x"))
resolution_layout.addWidget(self.resolution_y_input)
resolution_layout.addWidget(QLabel("@"))
resolution_layout.addWidget(self.frame_rate_input)
resolution_layout.addWidget(QLabel("fps"))
output_settings_layout.addLayout(resolution_layout)
# add group to layout
main_layout.addWidget(self.output_settings_group)
resolution_layout.addStretch()
# Engine Group
self.engine_group = QGroupBox("Engine Settings")
fps_layout = QHBoxLayout()
self.fps_options_list = QComboBox()
self.fps_options_list.setFixedWidth(200)
self.fps_options_list.addItem("Original FPS")
for fps_option in COMMON_FRAME_RATES:
self.fps_options_list.addItem(fps_option)
self.fps_options_list.currentIndexChanged.connect(self._fps_preset_changed)
fps_layout.addWidget(self.fps_options_list)
self.fps_input = QDoubleSpinBox()
self.fps_input.setDecimals(3)
self.fps_input.setRange(1.0, 999.0)
self.fps_input.setValue(23.976)
self.fps_input.setFixedWidth(80)
fps_layout.addWidget(self.fps_input)
fps_layout.addWidget(QLabel("fps"))
fps_layout.addStretch()
resolution_group_layout.addLayout(fps_layout)
output_settings_layout.addStretch()
# ==================== Tab 3: Engine Settings ====================
self.engine_group = QWidget()
engine_group_layout = QVBoxLayout(self.engine_group)
engine_layout = QHBoxLayout()
engine_layout.addWidget(QLabel("Engine:"))
self.engine_type = QComboBox()
self.engine_type.currentIndexChanged.connect(self.engine_changed)
engine_layout.addWidget(self.engine_type)
# Version
engine_layout.addWidget(QLabel("Version:"))
engine_layout.addWidget(QLabel("Engine Version:"))
self.engine_version_combo = QComboBox()
self.engine_version_combo.addItem('latest')
engine_layout.addWidget(self.engine_version_combo)
engine_group_layout.addLayout(engine_layout)
# dynamic options
# Dynamic engine options
self.engine_options_layout = QVBoxLayout()
engine_group_layout.addLayout(self.engine_options_layout)
# Raw Args
raw_args_layout = QHBoxLayout(self.engine_group)
raw_args_layout = QHBoxLayout()
raw_args_layout.addWidget(QLabel("Raw Args:"))
self.raw_args = QLineEdit()
raw_args_layout.addWidget(self.raw_args)
@@ -208,24 +259,34 @@ class NewRenderJobForm(QWidget):
args_help_button.clicked.connect(self.args_help_button_clicked)
raw_args_layout.addWidget(args_help_button)
engine_group_layout.addLayout(raw_args_layout)
main_layout.addWidget(self.engine_group)
engine_group_layout.addStretch()
# Cameras Group
self.cameras_group = QGroupBox("Cameras")
# ==================== Tab 4: Cameras ====================
self.cameras_group = QWidget()
cameras_layout = QVBoxLayout(self.cameras_group)
self.cameras_list = QListWidget()
self.cameras_group.setHidden(True)
self.cameras_list.itemChanged.connect(self.update_job_count)
cameras_layout.addWidget(self.cameras_list)
main_layout.addWidget(self.cameras_group)
# Notes Group
self.notes_group = QGroupBox("Additional Notes")
# ==================== Tab 5: Misc / Notes ====================
self.notes_group = QWidget()
notes_layout = QVBoxLayout(self.notes_group)
self.notes_input = QPlainTextEdit()
notes_layout.addWidget(self.notes_input)
main_layout.addWidget(self.notes_group)
# Submit Button
# == Create Tabs
self.tabs.addTab(self.project_group, "Job Settings")
self.tabs.addTab(self.output_settings_group, "Output Settings")
self.tabs.addTab(self.engine_group, "Engine Settings")
self.tabs.addTab(self.cameras_group, "Cameras")
self.tabs.addTab(self.notes_group, "Notes")
self.update_server_list()
index = self.tabs.indexOf(self.cameras_group)
if index != -1:
self.tabs.setTabEnabled(index, False)
# ==================== Submit Section (outside tabs) ====================
self.submit_button = QPushButton("Submit Job")
self.submit_button.clicked.connect(self.submit_job)
main_layout.addWidget(self.submit_button)
@@ -240,17 +301,38 @@ class NewRenderJobForm(QWidget):
self.submit_progress_label.setHidden(True)
main_layout.addWidget(self.submit_progress_label)
# Initial engine state
self.toggle_engine_enablement(False)
self.tabs.setCurrentIndex(0)
def update_engine_info(self):
# get the engine info and add them all to the ui
self.engine_info = self.server_proxy.get_engine_info(response_type='full')
self.engine_type.addItems(self.engine_info.keys())
# select the best engine for the file type
engine = EngineManager.engine_for_project_path(self.project_path)
self.engine_type.setCurrentText(engine.name().lower())
# refresh ui
self.engine_changed()
def update_job_count(self, changed_item=None):
checked = 0
total = self.cameras_list.count()
for i in range(total):
item = self.cameras_list.item(i)
if item.checkState() == Qt.CheckState.Checked:
checked += 1
message = f"Submit {checked} Jobs" if checked > 1 else "Submit Job"
self.submit_button.setText(message)
self.submit_button.setEnabled(bool(checked))
def _resolution_preset_changed(self, index):
selected_res = COMMON_RESOLUTIONS.get(self.resolution_options_list.currentText())
if selected_res:
self.resolution_x_input.setValue(selected_res[0])
self.resolution_y_input.setValue(selected_res[1])
elif index == 0:
self.resolution_x_input.setValue(self.project_info.get('resolution_x'))
self.resolution_y_input.setValue(self.project_info.get('resolution_y'))
def _fps_preset_changed(self, index):
selected_fps = COMMON_FRAME_RATES.get(self.fps_options_list.currentText())
if selected_fps:
self.fps_input.setValue(selected_fps)
elif index == 0:
self.fps_input.setValue(self.project_info.get('fps'))
def engine_changed(self):
# load the version numbers
@@ -259,9 +341,13 @@ class NewRenderJobForm(QWidget):
self.engine_version_combo.addItem('latest')
self.file_format_combo.clear()
if current_engine:
engine_vers = [version_info['version'] for version_info in self.engine_info[current_engine]['versions']]
engine_info = self.server_proxy.get_engine_info(current_engine, 'full', timeout=10)
self.current_engine_options = engine_info.get('options', [])
if not engine_info:
raise FileNotFoundError(f"Cannot get information about engine '{current_engine}'")
engine_vers = [v['version'] for v in engine_info['versions']]
self.engine_version_combo.addItems(engine_vers)
self.file_format_combo.addItems(self.engine_info[current_engine]['supported_export_formats'])
self.file_format_combo.addItems(engine_info.get('supported_export_formats'))
def update_server_list(self):
clients = ZeroconfServer.found_hostnames()
@@ -282,18 +368,19 @@ class NewRenderJobForm(QWidget):
output_name, _ = os.path.splitext(os.path.basename(self.scene_file_input.text()))
output_name = output_name.replace(' ', '_')
self.render_name_input.setText(output_name)
self.job_name_input.setText(output_name)
file_name = self.scene_file_input.text()
# setup bg worker
self.worker_thread = GetProjectInfoWorker(window=self, project_path=file_name)
self.worker_thread.message_signal.connect(self.post_get_project_info_update)
self.worker_thread.error_signal.connect(self.show_error_message)
self.worker_thread.start()
def browse_output_path(self):
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
if directory:
self.render_name_input.setText(directory)
self.job_name_input.setText(directory)
def args_help_button_clicked(self):
url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/engine/'
@@ -301,14 +388,26 @@ class NewRenderJobForm(QWidget):
self.engine_help_viewer = EngineHelpViewer(url)
self.engine_help_viewer.show()
def show_error_message(self, message):
msg = QMessageBox(self)
msg.setIcon(QMessageBox.Icon.Critical)
msg.setWindowTitle("Error")
msg.setText(message)
msg.exec()
# -------- Update --------
def post_get_project_info_update(self):
"""Called by the GetProjectInfoWorker - Do not call directly."""
try:
self.engine_type.addItems(self.installed_engines.keys())
self.engine_type.setCurrentText(self.preferred_engine)
self.engine_changed()
# Set the best engine we can find
input_path = self.scene_file_input.text()
engine = EngineManager.engine_for_project_path(input_path)
engine = EngineManager.engine_class_for_project_path(input_path)
engine_index = self.engine_type.findText(engine.name().lower())
if engine_index >= 0:
@@ -326,12 +425,13 @@ class NewRenderJobForm(QWidget):
self.end_frame_input.setValue(self.project_info.get('frame_end'))
self.resolution_x_input.setValue(self.project_info.get('resolution_x'))
self.resolution_y_input.setValue(self.project_info.get('resolution_y'))
self.frame_rate_input.setValue(self.project_info.get('fps'))
self.fps_input.setValue(self.project_info.get('fps'))
# Cameras
self.cameras_list.clear()
index = self.tabs.indexOf(self.cameras_group)
if self.project_info.get('cameras'):
self.cameras_group.setHidden(False)
self.tabs.setTabEnabled(index, True)
found_active = False
for camera in self.project_info['cameras']:
# create the list items and make them checkable
@@ -344,13 +444,12 @@ class NewRenderJobForm(QWidget):
if not found_active:
self.cameras_list.item(0).setCheckState(Qt.CheckState.Checked)
else:
self.cameras_group.setHidden(True)
self.tabs.setTabEnabled(index, False)
self.update_job_count()
# Dynamic Engine Options
clear_layout(self.engine_options_layout) # clear old options
# dynamically populate option list
system_info = self.engine_info.get(engine.name(), {}).get('system_info', {})
self.current_engine_options = engine.ui_options(system_info=system_info)
for option in self.current_engine_options:
h_layout = QHBoxLayout()
label = QLabel(option['name'].replace('_', ' ').capitalize() + ':')
@@ -369,12 +468,13 @@ class NewRenderJobForm(QWidget):
def toggle_engine_enablement(self, enabled=False):
"""Toggle on/off all the render settings"""
self.project_group.setHidden(not enabled)
self.output_settings_group.setHidden(not enabled)
self.engine_group.setHidden(not enabled)
self.notes_group.setHidden(not enabled)
if not enabled:
self.cameras_group.setHidden(True)
indexes = [self.tabs.indexOf(self.project_group),
self.tabs.indexOf(self.output_settings_group),
self.tabs.indexOf(self.engine_group),
self.tabs.indexOf(self.cameras_group),
self.tabs.indexOf(self.notes_group)]
for idx in indexes:
self.tabs.setTabEnabled(idx, enabled)
self.submit_button.setEnabled(enabled)
def after_job_submission(self, error_string):
@@ -449,19 +549,22 @@ class SubmitWorker(QThread):
try:
hostname = self.window.server_input.currentText()
resolution = (self.window.resolution_x_input.text(), self.window.resolution_y_input.text())
job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(),
'engine_name': self.window.engine_type.currentText().lower(),
'engine_version': self.window.engine_version_combo.currentText(),
'args': {'raw': self.window.raw_args.text(),
'export_format': self.window.file_format_combo.currentText()},
'output_path': self.window.render_name_input.text(),
'export_format': self.window.file_format_combo.currentText(),
'resolution': resolution,
'fps': self.window.fps_input.text(),},
'output_path': self.window.job_name_input.text(),
'start_frame': self.window.start_frame_input.value(),
'end_frame': self.window.end_frame_input.value(),
'priority': self.window.priority_input.currentIndex() + 1,
'notes': self.window.notes_input.toPlainText(),
'enable_split_jobs': self.window.enable_splitjobs.isChecked(),
'split_jobs_same_os': self.window.splitjobs_same_os.isChecked(),
'name': self.window.render_name_input.text()}
'name': self.window.job_name_input.text()}
# get the dynamic args
for i in range(self.window.engine_options_layout.count()):
@@ -496,9 +599,10 @@ class SubmitWorker(QThread):
children_jobs.append(child_job_data)
job_json['child_jobs'] = children_jobs
# presubmission tasks
engine = EngineManager.engine_with_name(self.window.engine_type.currentText().lower())
input_path = engine().perform_presubmission_tasks(input_path)
# presubmission tasks - use local installs
engine_class = EngineManager.engine_class_with_name(self.window.engine_type.currentText().lower())
latest_engine = EngineManager.get_latest_engine_instance(engine_class)
input_path = latest_engine.perform_presubmission_tasks(input_path)
# submit
err_msg = ""
result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_data=job_json,
@@ -516,6 +620,7 @@ class GetProjectInfoWorker(QThread):
"""Worker class called to retrieve information about a project file on a background thread and update the UI"""
message_signal = pyqtSignal()
error_signal = pyqtSignal(str)
def __init__(self, window, project_path):
super().__init__()
@@ -523,9 +628,19 @@ class GetProjectInfoWorker(QThread):
self.project_path = project_path
def run(self):
engine = EngineManager.engine_for_project_path(self.project_path)
self.window.project_info = engine().get_project_info(self.project_path)
self.message_signal.emit()
try:
# get the engine info and add them all to the ui
self.window.installed_engines = self.window.server_proxy.get_installed_engines()
# select the best engine for the file type
self.window.preferred_engine = self.window.server_proxy.get_engine_for_filename(self.project_path)
# this should be the only time we use a local engine instead of using the proxy besides submitting
engine_class = EngineManager.engine_class_for_project_path(self.project_path)
engine = EngineManager.get_latest_engine_instance(engine_class)
self.window.project_info = engine.get_project_info(self.project_path)
self.message_signal.emit()
except Exception as e:
self.error_signal.emit(str(e))
def clear_layout(layout):

View File

@@ -93,7 +93,7 @@ class EngineBrowserWindow(QMainWindow):
def update_table(self):
def update_table_worker():
raw_server_data = RenderServerProxy(self.hostname).get_engine_info()
raw_server_data = RenderServerProxy(self.hostname).get_all_engine_info()
if not raw_server_data:
return

View File

@@ -2,7 +2,6 @@
import ast
import datetime
import io
import json
import logging
import os
import sys
@@ -10,18 +9,17 @@ import threading
import time
import PIL
import humanize
from PIL import Image
from PyQt6.QtCore import Qt, QByteArray, QBuffer, QIODevice, QThread
from PyQt6.QtCore import Qt, QByteArray, QBuffer, QIODevice, QThread, pyqtSignal
from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon
from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QListWidget, QTableWidget, QAbstractItemView, \
QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem, \
QFileDialog
from src.api.api_server import API_VERSION
from src.api.serverproxy_manager import ServerProxyManager
from src.render_queue import RenderQueue
from src.utilities.misc_helper import get_time_elapsed, resources_dir, is_localhost
from src.utilities.status_utils import RenderStatus
from src.utilities.zeroconf_server import ZeroconfServer
from src.ui.add_job_window import NewRenderJobForm
from src.ui.console_window import ConsoleWindow
from src.ui.engine_browser import EngineBrowserWindow
@@ -30,8 +28,10 @@ from src.ui.widgets.menubar import MenuBar
from src.ui.widgets.proportional_image_label import ProportionalImageLabel
from src.ui.widgets.statusbar import StatusBar
from src.ui.widgets.toolbar import ToolBar
from src.api.serverproxy_manager import ServerProxyManager
from src.utilities.misc_helper import launch_url, iso_datestring_to_formatted_datestring
from src.utilities.misc_helper import get_time_elapsed, resources_dir, is_localhost
from src.utilities.misc_helper import launch_url
from src.utilities.status_utils import RenderStatus
from src.utilities.zeroconf_server import ZeroconfServer
from src.version import APP_NAME
logger = logging.getLogger()
@@ -99,8 +99,10 @@ class MainWindow(QMainWindow):
self.create_toolbars()
# start background update
self.bg_update_thread = QThread()
self.bg_update_thread.run = self.__background_update
self.found_servers = []
self.job_data = {}
self.bg_update_thread = BackgroundUpdater(window=self)
self.bg_update_thread.updated_signal.connect(self.update_ui_data)
self.bg_update_thread.start()
# Setup other windows
@@ -161,44 +163,35 @@ class MainWindow(QMainWindow):
self.job_list_view.verticalHeader().setVisible(False)
self.job_list_view.itemSelectionChanged.connect(self.job_picked)
self.job_list_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.refresh_job_headers()
# Image Layout
image_group = QGroupBox("Job Preview")
image_layout = QVBoxLayout(image_group)
image_layout.setContentsMargins(0, 0, 0, 0)
image_center_layout = QHBoxLayout()
image_center_layout.addWidget(self.image_label)
image_layout.addWidget(self.image_label)
# image_layout.addLayout(image_center_layout)
# Setup Job Headers
self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Engine", "Priority", "Status",
"Time Elapsed", "Frames", "Date Created"])
self.job_list_view.setColumnHidden(0, True)
# Job Layout
job_list_group = QGroupBox("Render Jobs")
self.job_list_view.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.job_list_view.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents)
# Job List Layout
job_list_group = QGroupBox("Job Preview")
job_list_layout = QVBoxLayout(job_list_group)
job_list_layout.setContentsMargins(0, 0, 0, 0)
image_layout.addWidget(self.job_list_view, stretch=True)
image_layout.addLayout(job_list_layout)
job_list_layout.addWidget(self.image_label)
job_list_layout.addWidget(self.job_list_view, stretch=True)
# Add them all to the window
main_layout.addLayout(info_layout)
right_layout = QVBoxLayout()
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.addWidget(image_group)
# right_layout.addWidget(job_list_group)
right_layout.addWidget(job_list_group)
main_layout.addLayout(right_layout)
def __background_update(self):
while True:
try:
self.update_servers()
self.fetch_jobs()
except RuntimeError:
pass
except Exception as e:
logger.error(f"Uncaught exception in background update: {e}")
time.sleep(0.5)
def closeEvent(self, event):
running_jobs = len(RenderQueue.running_jobs())
if running_jobs:
@@ -214,6 +207,10 @@ class MainWindow(QMainWindow):
# -- Server Code -- #
def refresh_job_list(self):
self.job_list_view.clearContents()
self.bg_update_thread.needs_update = True
@property
def current_server_proxy(self):
return ServerProxyManager.get_proxy_for_hostname(self.current_hostname)
@@ -229,7 +226,7 @@ class MainWindow(QMainWindow):
# Update the current hostname and clear the job list
self.current_hostname = new_hostname
self.job_list_view.setRowCount(0)
self.fetch_jobs(clear_table=True)
self.refresh_job_list()
# Select the first row if there are jobs listed
if self.job_list_view.rowCount():
@@ -281,21 +278,19 @@ class MainWindow(QMainWindow):
self.server_info_ram.setText(memory_info)
self.server_info_gpu.setText(gpu_info)
def fetch_jobs(self, clear_table=False):
def update_ui_data(self):
self.update_servers()
if not self.current_server_proxy:
return
if clear_table:
self.job_list_view.clear()
self.refresh_job_headers()
job_fetch = self.current_server_proxy.get_all_jobs(ignore_token=False)
if job_fetch:
num_jobs = len(job_fetch)
server_job_data = self.job_data.get(self.current_server_proxy.hostname)
if server_job_data:
num_jobs = len(server_job_data)
self.job_list_view.setRowCount(num_jobs)
for row, job in enumerate(job_fetch):
for row, job in enumerate(server_job_data):
display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \
('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status
@@ -307,14 +302,15 @@ class MainWindow(QMainWindow):
get_time_elapsed(start_time, end_time)
name = job.get('name') or os.path.basename(job.get('input_path', ''))
engine_name = f"{job.get('renderer', '')}-{job.get('renderer_version')}"
engine_name = f"{job.get('engine', '')}-{job.get('engine_version')}"
priority = str(job.get('priority', ''))
total_frames = str(job.get('total_frames', ''))
date_created_string = iso_datestring_to_formatted_datestring(job['date_created'])
converted_time = datetime.datetime.fromisoformat(job['date_created'])
humanized_time = humanize.naturaltime(converted_time)
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(engine_name),
QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed),
QTableWidgetItem(total_frames), QTableWidgetItem(date_created_string)]
QTableWidgetItem(total_frames), QTableWidgetItem(humanized_time)]
for col, item in enumerate(items):
self.job_list_view.setItem(row, col, item)
@@ -395,18 +391,6 @@ class MainWindow(QMainWindow):
except AttributeError:
return []
def refresh_job_headers(self):
self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Engine", "Priority", "Status",
"Time Elapsed", "Frames", "Date Created"])
self.job_list_view.setColumnHidden(0, True)
self.job_list_view.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.job_list_view.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
self.job_list_view.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents)
# -- Image Code -- #
@@ -444,28 +428,25 @@ class MainWindow(QMainWindow):
logger.error(f"Error loading image data: {e}")
def update_servers(self):
found_servers = list(set(ZeroconfServer.found_hostnames() + self.added_hostnames))
found_servers = [x for x in found_servers if ZeroconfServer.get_hostname_properties(x)['api_version'] == API_VERSION]
# Always make sure local hostname is first
if found_servers and not is_localhost(found_servers[0]):
for hostname in found_servers:
if self.found_servers and not is_localhost(self.found_servers[0]):
for hostname in self.found_servers:
if is_localhost(hostname):
found_servers.remove(hostname)
found_servers.insert(0, hostname)
self.found_servers.remove(hostname)
self.found_servers.insert(0, hostname)
break
old_count = self.server_list_view.count()
# Update proxys
for hostname in found_servers:
for hostname in self.found_servers:
ServerProxyManager.get_proxy_for_hostname(hostname) # setup background updates
# Add in all the missing servers
current_server_list = []
for i in range(self.server_list_view.count()):
current_server_list.append(self.server_list_view.item(i).text())
for hostname in found_servers:
for hostname in self.found_servers:
if hostname not in current_server_list:
properties = ZeroconfServer.get_hostname_properties(hostname)
image_path = os.path.join(resources_dir(), f"{properties.get('system_os', 'Monitor')}.png")
@@ -476,7 +457,7 @@ class MainWindow(QMainWindow):
servers_to_remove = []
for i in range(self.server_list_view.count()):
name = self.server_list_view.item(i).text()
if name not in found_servers:
if name not in self.found_servers:
servers_to_remove.append(name)
# remove any servers that shouldn't be shown any longer
@@ -572,7 +553,7 @@ class MainWindow(QMainWindow):
if msg_box.exec() == QMessageBox.StandardButton.Yes:
for job_id in job_ids:
self.current_server_proxy.cancel_job(job_id, confirm=True)
self.fetch_jobs(clear_table=True)
self.refresh_job_list()
def delete_job(self, event):
"""
@@ -599,7 +580,7 @@ class MainWindow(QMainWindow):
if msg_box.exec() == QMessageBox.StandardButton.Yes:
for job_id in job_ids:
self.current_server_proxy.delete_job(job_id, confirm=True)
self.fetch_jobs(clear_table=True)
self.refresh_job_list()
def download_files(self, event):
@@ -630,6 +611,36 @@ class MainWindow(QMainWindow):
self.new_job_window.show()
class BackgroundUpdater(QThread):
"""Worker class to fetch job and server information and update the UI"""
updated_signal = pyqtSignal()
error_signal = pyqtSignal(str)
def __init__(self, window):
super().__init__()
self.window = window
self.needs_update = True
def run(self):
try:
last_run = 0
while True:
now = time.monotonic()
if now - last_run >= 1.0 or self.needs_update:
self.window.found_servers = list(set(ZeroconfServer.found_hostnames() + self.window.added_hostnames))
self.window.found_servers = [x for x in self.window.found_servers if
ZeroconfServer.get_hostname_properties(x)['api_version'] == API_VERSION]
if self.window.current_server_proxy:
self.window.job_data[self.window.current_server_proxy.hostname] =\
self.window.current_server_proxy.get_all_jobs(ignore_token=False)
self.needs_update = False
self.updated_signal.emit()
time.sleep(0.05)
except Exception as e:
print(f"ERROR: {e}")
self.error_signal.emit(str(e))
if __name__ == "__main__":
# lazy load GUI frameworks
from PyQt6.QtWidgets import QApplication

View File

@@ -37,7 +37,7 @@ class GetEngineInfoWorker(QThread):
self.parent = parent
def run(self):
data = RenderServerProxy(socket.gethostname()).get_engine_info()
data = RenderServerProxy(socket.gethostname()).get_all_engine_info()
self.done.emit(data)
class SettingsWindow(QMainWindow):
@@ -413,7 +413,7 @@ class SettingsWindow(QMainWindow):
msg_result = msg_box.exec()
messagebox_shown = True
if msg_result == QMessageBox.StandardButton.Yes:
EngineManager.download_engine(engine=engine.name(), version=result['version'], background=True,
EngineManager.download_engine(engine_name=engine.name(), version=result['version'], background=True,
ignore_system=ignore_system)
self.engine_download_progress_bar.setHidden(False)
self.engine_download_progress_bar.setValue(0)