Files
Zordon/src/ui/main_window.py
Brett 74dce5cc3d Windows path fixes (#129)
* Change uses of os.path to use Pathlib

* Add return types and type hints

* Add more docstrings

* Add missing import to api_server
2026-01-18 00:18:43 -06:00

692 lines
28 KiB
Python

''' app/ui/main_window.py '''
import ast
import datetime
import io
import logging
import os
import sys
import threading
import time
from typing import List, Dict, Any, Optional
import PIL
import humanize
from PIL import Image
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.ui.add_job_window import NewRenderJobForm
from src.ui.console_window import ConsoleWindow
from src.ui.engine_browser import EngineBrowserWindow
from src.ui.log_window import LogViewer
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.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()
class MainWindow(QMainWindow):
"""
MainWindow
Args:
QMainWindow (QMainWindow): Inheritance
"""
def __init__(self) -> None:
"""
Initialize the Main-Window.
"""
super().__init__()
# Load the queue
self.job_list_view: Optional[QTableWidget] = None
self.server_info_ram: Optional[str] = None
self.server_info_cpu: Optional[str] = None
self.server_info_os: Optional[str] = None
self.server_info_gpu: Optional[List[Dict[str, Any]]] = None
self.server_info_hostname: Optional[str] = None
self.engine_browser_window = None
self.server_info_group = None
self.current_hostname = None
self.subprocess_runner = None
# To pass to console
self.buffer_handler = None
# Window-Settings
self.setWindowTitle(APP_NAME)
self.setGeometry(100, 100, 900, 800)
central_widget = QWidget(self)
self.setCentralWidget(central_widget)
main_layout = QHBoxLayout(central_widget)
# Create a QLabel widget to display the image
self.image_label = ProportionalImageLabel()
self.image_label.setMaximumSize(700, 500)
self.image_label.setFixedHeight(300)
self.image_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
self.load_image_path(os.path.join(resources_dir(), 'Rectangle.png'))
# Server list
self.server_list_view = QListWidget()
self.server_list_view.itemClicked.connect(self.server_picked)
list_font = QFont()
list_font.setPointSize(16)
self.server_list_view.setFont(list_font)
self.added_hostnames = []
self.setup_ui(main_layout)
# Add Widgets to Window
# self.custom_menu_bar =
self.setMenuBar(MenuBar(self))
self.setStatusBar(StatusBar(self))
self.create_toolbars()
# start 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
self.new_job_window = None
self.console_window = None
self.log_viewer_window = None
# Pick default job
self.job_picked()
def setup_ui(self, main_layout: QVBoxLayout) -> None:
"""Setup the main user interface layout.
Args:
main_layout: The main layout container for the UI widgets.
"""
# Servers
server_list_group = QGroupBox("Available Servers")
list_layout = QVBoxLayout()
list_layout.addWidget(self.server_list_view)
list_layout.setContentsMargins(0, 0, 0, 0)
server_list_group.setLayout(list_layout)
server_info_group = QGroupBox("Server Info")
# Server Info Group
self.server_info_hostname = QLabel()
self.server_info_os = QLabel()
self.server_info_cpu = QLabel()
self.server_info_ram = QLabel()
self.server_info_gpu = QLabel()
server_info_engines_button = QPushButton("Render Engines")
server_info_engines_button.clicked.connect(self.engine_browser)
server_info_layout = QVBoxLayout()
server_info_layout.addWidget(self.server_info_hostname)
server_info_layout.addWidget(self.server_info_os)
server_info_layout.addWidget(self.server_info_cpu)
server_info_layout.addWidget(self.server_info_ram)
server_info_layout.addWidget(self.server_info_gpu)
server_info_layout.addWidget(server_info_engines_button)
server_info_group.setLayout(server_info_layout)
# Server Button Layout
server_button_layout = QHBoxLayout()
add_server_button = QPushButton(text="+")
remove_server_button = QPushButton(text="-")
server_button_layout.addWidget(add_server_button)
server_button_layout.addWidget(remove_server_button)
# Layouts
info_layout = QVBoxLayout()
info_layout.addWidget(server_list_group, stretch=True)
info_layout.addWidget(server_info_group)
info_layout.setContentsMargins(0, 0, 0, 0)
server_list_group.setFixedWidth(260)
self.server_picked()
# Job list
self.job_list_view = QTableWidget()
self.job_list_view.setRowCount(0)
self.job_list_view.setColumnCount(8)
self.job_list_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.job_list_view.verticalHeader().setVisible(False)
self.job_list_view.itemSelectionChanged.connect(self.job_picked)
self.job_list_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
# 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)
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)
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(job_list_group)
main_layout.addLayout(right_layout)
def closeEvent(self, event):
"""Handle window close event with job running confirmation.
Args:
event: The close event triggered by user.
"""
running_jobs = len(RenderQueue.running_jobs())
if running_jobs:
reply = QMessageBox.question(self, "Running Jobs",
f"You have {running_jobs} jobs running.\n"
f"Quitting will cancel these renders. Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel)
if reply == QMessageBox.StandardButton.Yes:
event.accept()
else:
event.ignore()
# -- Server Code -- #
def refresh_job_list(self):
"""Refresh the job list display."""
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)
def server_picked(self):
"""Update the UI elements relevant to the server selection."""
try:
# Retrieve the new hostname selected by the user
new_hostname = self.server_list_view.currentItem().text()
# Check if the hostname has changed to avoid unnecessary updates
if new_hostname != self.current_hostname:
# Update the current hostname and clear the job list
self.current_hostname = new_hostname
self.job_list_view.setRowCount(0)
self.refresh_job_list()
# Select the first row if there are jobs listed
if self.job_list_view.rowCount():
self.job_list_view.selectRow(0)
# Update server information display
self.update_server_info_display(new_hostname)
except AttributeError:
# Handle cases where the server list view might not be properly initialized
pass
def update_server_info_display(self, hostname):
"""Updates the server information section of the UI."""
self.server_info_hostname.setText(f"Name: {hostname}")
server_info = ZeroconfServer.get_hostname_properties(hostname)
# Use the get method with defaults to avoid KeyError
os_info = f"OS: {server_info.get('system_os', 'Unknown')} {server_info.get('system_os_version', '')}"
cleaned_cpu_name = server_info.get('system_cpu_brand', 'Unknown').replace(' CPU','').replace('(TM)','').replace('(R)', '')
cpu_info = f"CPU: {cleaned_cpu_name} ({server_info.get('system_cpu_cores', 'Unknown')} cores)"
memory_info = f"RAM: {server_info.get('system_memory', 'Unknown')} GB"
# Get and format GPU info
try:
gpu_list = ast.literal_eval(server_info.get('gpu_info', []))
# Format all GPUs
gpu_info_parts = []
for gpu in gpu_list:
gpu_name = gpu.get('name', 'Unknown').replace('(TM)','').replace('(R)', '')
gpu_memory = gpu.get('memory', 'Unknown')
# Add " GB" suffix if memory is a number
if isinstance(gpu_memory, (int, float)) or (isinstance(gpu_memory, str) and gpu_memory.isdigit()):
gpu_memory_str = f"{gpu_memory} GB"
else:
gpu_memory_str = str(gpu_memory)
gpu_info_parts.append(f"{gpu_name} ({gpu_memory_str})")
gpu_info = f"GPU: {', '.join(gpu_info_parts)}" if gpu_info_parts else "GPU: Unknown"
except Exception as e:
logger.error(f"Error parsing GPU info: {e}")
gpu_info = "GPU: Unknown"
self.server_info_os.setText(os_info.strip())
self.server_info_cpu.setText(cpu_info)
self.server_info_ram.setText(memory_info)
self.server_info_gpu.setText(gpu_info)
def update_ui_data(self):
"""Update UI data with current server and job information."""
self.update_servers()
if not self.current_server_proxy:
return
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(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
tags = (job['status'],)
start_time = datetime.datetime.fromisoformat(job['start_time']) if job['start_time'] else None
end_time = datetime.datetime.fromisoformat(job['end_time']) if job['end_time'] else None
time_elapsed = "" if (job['status'] != RenderStatus.RUNNING.value and not end_time) else \
get_time_elapsed(start_time, end_time)
name = job.get('name') or os.path.basename(job.get('input_path', ''))
engine_name = f"{job.get('engine', '')}-{job.get('engine_version')}"
priority = str(job.get('priority', ''))
total_frames = str(job.get('total_frames', ''))
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(humanized_time)]
for col, item in enumerate(items):
self.job_list_view.setItem(row, col, item)
# -- Job Code -- #
def job_picked(self):
def fetch_preview(job_id):
try:
default_image_path = "error.png"
before_fetch_hostname = self.current_server_proxy.hostname
response = self.current_server_proxy.request(f'job/{job_id}/thumbnail?size=big')
if response.ok:
try:
with io.BytesIO(response.content) as image_data_stream:
image = Image.open(image_data_stream)
if self.current_server_proxy.hostname == before_fetch_hostname and job_id == \
self.selected_job_ids()[0]:
self.load_image_data(image)
return
except PIL.UnidentifiedImageError:
default_image_path = response.text
else:
default_image_path = default_image_path or response.text
self.load_image_path(os.path.join(resources_dir(), default_image_path))
except ConnectionError as e:
logger.error(f"Connection error fetching image: {e}")
except Exception as e:
logger.error(f"Error fetching image: {e}")
job_id = self.selected_job_ids()[0] if self.selected_job_ids() else None
local_server = is_localhost(self.current_hostname)
if job_id:
fetch_thread = threading.Thread(target=fetch_preview, args=(job_id,))
fetch_thread.daemon = True
fetch_thread.start()
selected_row = self.job_list_view.selectionModel().selectedRows()[0]
current_status = self.job_list_view.item(selected_row.row(), 4).text()
# show / hide the stop button
show_stop_button = "%" in current_status
self.topbar.actions_call['Stop Job'].setEnabled(show_stop_button)
self.topbar.actions_call['Stop Job'].setVisible(show_stop_button)
self.topbar.actions_call['Delete Job'].setEnabled(not show_stop_button)
self.topbar.actions_call['Delete Job'].setVisible(not show_stop_button)
self.topbar.actions_call['Render Log'].setEnabled(True)
self.topbar.actions_call['Download'].setEnabled(not local_server)
self.topbar.actions_call['Download'].setVisible(not local_server)
self.topbar.actions_call['Open Files'].setEnabled(local_server)
self.topbar.actions_call['Open Files'].setVisible(local_server)
else:
# load default
default_image_path = os.path.join(resources_dir(), 'Rectangle.png')
self.load_image_path(default_image_path)
self.topbar.actions_call['Stop Job'].setVisible(False)
self.topbar.actions_call['Stop Job'].setEnabled(False)
self.topbar.actions_call['Delete Job'].setEnabled(False)
self.topbar.actions_call['Render Log'].setEnabled(False)
self.topbar.actions_call['Download'].setEnabled(False)
self.topbar.actions_call['Download'].setVisible(True)
self.topbar.actions_call['Open Files'].setEnabled(False)
self.topbar.actions_call['Open Files'].setVisible(False)
def selected_job_ids(self):
"""Get list of selected job IDs from the job list.
Returns:
List[str]: List of selected job ID strings.
"""
try:
selected_rows = self.job_list_view.selectionModel().selectedRows()
job_ids = []
for selected_row in selected_rows:
id_item = self.job_list_view.item(selected_row.row(), 0)
job_ids.append(id_item.text())
return job_ids
except AttributeError:
return []
# -- Image Code -- #
def load_image_path(self, image_path):
"""Load and display an image from file path.
Args:
image_path: Path to the image file to load.
"""
# Load and set image using QPixmap
try:
pixmap = QPixmap(image_path)
if not pixmap:
logger.error("Error loading image")
return
self.image_label.setPixmap(pixmap)
except Exception as e:
logger.error(f"Error loading image path: {e}")
def load_image_data(self, pillow_image):
try:
# Convert the Pillow Image to a QByteArray (byte buffer)
byte_array = QByteArray()
buffer = QBuffer(byte_array)
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
pillow_image.save(buffer, "PNG")
buffer.close()
# Create a QImage from the QByteArray
image = QImage.fromData(byte_array)
# Create a QPixmap from the QImage
pixmap = QPixmap.fromImage(image)
if not pixmap:
logger.error("Error loading image")
return
self.image_label.setPixmap(pixmap)
except Exception as e:
logger.error(f"Error loading image data: {e}")
def update_servers(self):
# Always make sure local hostname is first
if self.found_servers and not is_localhost(self.found_servers[0]):
for hostname in self.found_servers:
if is_localhost(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 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 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")
list_widget = QListWidgetItem(QIcon(image_path), hostname)
self.server_list_view.addItem(list_widget)
# find any servers that shouldn't be shown any longer
servers_to_remove = []
for i in range(self.server_list_view.count()):
name = self.server_list_view.item(i).text()
if name not in self.found_servers:
servers_to_remove.append(name)
# remove any servers that shouldn't be shown any longer
for server in servers_to_remove:
# Find and remove the item with the specified text
for i in range(self.server_list_view.count()):
item = self.server_list_view.item(i)
if item is not None and item.text() == server:
self.server_list_view.takeItem(i)
break # Stop searching after the first match is found
if not old_count and self.server_list_view.count():
self.server_list_view.setCurrentRow(0)
self.server_picked()
def create_toolbars(self) -> None:
"""
Creates and adds the top and right toolbars to the main window.
"""
# Top Toolbar [PyQt6.QtWidgets.QToolBar]
self.topbar = ToolBar(self, orientation=Qt.Orientation.Horizontal,
style=Qt.ToolButtonStyle.ToolButtonTextUnderIcon, icon_size=(24, 24))
self.topbar.setMovable(False)
resources_directory = resources_dir()
# Top Toolbar Buttons
self.topbar.add_button(
"Settings", f"{resources_directory}/Gear.png", self.menuBar().show_settings)
self.topbar.add_button(
"Console", f"{resources_directory}/Console.png", self.open_console_window)
self.topbar.add_separator()
self.topbar.add_button(
"Stop Job", f"{resources_directory}/StopSign.png", self.stop_job)
self.topbar.add_button(
"Delete Job", f"{resources_directory}/Trash.png", self.delete_job)
self.topbar.add_button(
"Render Log", f"{resources_directory}/Document.png", self.job_logs)
self.topbar.add_button(
"Download", f"{resources_directory}/Download.png", self.download_files)
self.topbar.add_button(
"Open Files", f"{resources_directory}/SearchFolder.png", self.open_files)
self.topbar.add_button(
"New Job", f"{resources_directory}/AddProduct.png", self.new_job)
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.topbar)
# -- Toolbar Buttons -- #
def open_console_window(self) -> None:
"""
Event handler for the "Open Console" button
"""
self.console_window = ConsoleWindow(self.buffer_handler)
self.console_window.buffer_handler = self.buffer_handler
self.console_window.show()
def engine_browser(self):
self.engine_browser_window = EngineBrowserWindow(hostname=self.current_hostname)
self.engine_browser_window.show()
def job_logs(self) -> None:
"""Open log viewer for selected job.
Opens a log viewer window showing the logs for the currently selected job.
"""
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'
self.log_viewer_window = LogViewer(url)
self.log_viewer_window.show()
def stop_job(self, event):
"""Stop selected render jobs with user confirmation.
Args:
event: The button click event.
"""
job_ids = self.selected_job_ids()
if not job_ids:
return
if len(job_ids) == 1:
job = next((job for job in self.current_server_proxy.get_all_jobs() if job.get('id') == job_ids[0]), None)
if job:
display_name = job.get('name', os.path.basename(job.get('input_path', '')))
message = f"Are you sure you want to stop job: {display_name}?"
else:
return # Job not found, handle this case as needed
else:
message = f"Are you sure you want to stop these {len(job_ids)} jobs?"
# Display the message box and check the response in one go
msg_box = QMessageBox(QMessageBox.Icon.Warning, "Stop Job", message,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self)
if msg_box.exec() == QMessageBox.StandardButton.Yes:
for job_id in job_ids:
self.current_server_proxy.cancel_job(job_id, confirm=True)
self.refresh_job_list()
def delete_job(self, event):
"""Delete selected render jobs with user confirmation.
Args:
event: The button click event.
"""
job_ids = self.selected_job_ids()
if not job_ids:
return
if len(job_ids) == 1:
job = next((job for job in self.current_server_proxy.get_all_jobs() if job.get('id') == job_ids[0]), None)
if job:
display_name = job.get('name', os.path.basename(job.get('input_path', '')))
message = f"Are you sure you want to delete the job:\n{display_name}?"
else:
return # Job not found, handle this case as needed
else:
message = f"Are you sure you want to delete these {len(job_ids)} jobs?"
# Display the message box and check the response in one go
msg_box = QMessageBox(QMessageBox.Icon.Warning, "Delete Job", message,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self)
if msg_box.exec() == QMessageBox.StandardButton.Yes:
for job_id in job_ids:
self.current_server_proxy.delete_job(job_id, confirm=True)
self.refresh_job_list()
def download_files(self, event):
job_ids = self.selected_job_ids()
if not job_ids:
return
import webbrowser
download_url = (f"http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}"
f"/api/job/{job_ids[0]}/download_all")
webbrowser.open(download_url)
def open_files(self, event):
job_ids = self.selected_job_ids()
if not job_ids:
return
for job_id in job_ids:
job_info = self.current_server_proxy.get_job_info(job_id)
path = os.path.dirname(job_info['output_path'])
launch_url(path)
def new_job(self) -> None:
file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
if file_name:
self.new_job_window = NewRenderJobForm(file_name)
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):
"""Main background thread execution loop.
Continuously fetches server and job data, updating the main UI
every second or when updates are needed.
"""
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
# load application
# QtCore.QCoreApplication.setAttribute(QtCore.Qt.ApplicationAttribute.AA_MacDontSwapCtrlAndMeta)
app: QApplication = QApplication(sys.argv)
# configure main main_window
main_window = MainWindow()
# main_window.buffer_handler = buffer_handler
app.setActiveWindow(main_window)
main_window.show()
sys.exit(app.exec())