New UI Redesign in pyqt6 (#56)

* Initial commit for new UI

* Initial commit for new UI

* WIP

* Status bar updates and has an icon for online / offline

* Add log_viewer.py

* Use JSON for delete_engine_download API

* Fix class issue with Downloaders

* Move Config class to new ui

* Add engine_browser.py

* Add a close event handler to the main window

* Fix issue with engine manager not deleting engines properly

* Rearrange all the files

* Add icons and resources

* Cache system info in RenderServerProxy

* Toolbar polish

* Fix resource path in status bar

* Add config_dir to misc_helper.py

* Add try block to zeroconf setup

* Add add_job.py

* Add raw args to add_job.py
This commit is contained in:
2023-11-04 09:52:15 -05:00
committed by GitHub
parent bc8e88ea59
commit 65c256b641
45 changed files with 1491 additions and 53 deletions

View File

@@ -3,6 +3,7 @@ update_engines_on_launch: true
max_content_path: 100000000 max_content_path: 100000000
server_log_level: info server_log_level: info
log_buffer_length: 250 log_buffer_length: 250
subjob_connection_timeout: 120
flask_log_level: error flask_log_level: error
flask_debug_enable: false flask_debug_enable: false
queue_eval_seconds: 1 queue_eval_seconds: 1

View File

@@ -202,7 +202,7 @@ if __name__ == '__main__':
start_server_input = input("Local server not running. Start server? (y/n) ") start_server_input = input("Local server not running. Start server? (y/n) ")
if start_server_input and start_server_input[0].lower() == "y": if start_server_input and start_server_input[0].lower() == "y":
# Startup the local server # Startup the local server
start_server(background_thread=True) start_server()
test = server_proxy.connect() test = server_proxy.connect()
print(f"connected? {test}") print(f"connected? {test}")
else: else:

6
main.py Normal file
View File

@@ -0,0 +1,6 @@
''' main.py '''
from src import init
if __name__ == '__main__':
import sys
sys.exit(init.run())

View File

@@ -1,5 +1,4 @@
requests==2.31.0 requests==2.31.0
requests_toolbelt==1.0.0
psutil==5.9.6 psutil==5.9.6
PyYAML==6.0.1 PyYAML==6.0.1
Flask==3.0.0 Flask==3.0.0
@@ -12,6 +11,6 @@ Pillow==10.1.0
zeroconf==0.119.0 zeroconf==0.119.0
Pypubsub~=4.0.3 Pypubsub~=4.0.3
tqdm==4.66.1 tqdm==4.66.1
dmglib==0.9.4
plyer==2.1.0 plyer==2.1.0
pyobjus==1.2.3 PyQt6~=6.5.3
PySide6~=6.6.0

BIN
resources/Rectangle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
resources/icons/Blender.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
resources/icons/Console.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 B

BIN
resources/icons/FFmpeg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
resources/icons/Gear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
resources/icons/Monitor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
resources/icons/Server.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
resources/icons/Trash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

View File

@@ -127,7 +127,7 @@ def process_zipped_project(zip_path):
return extracted_project_path return extracted_project_path
def create_render_jobs(jobs_list, loaded_project_local_path, job_dir, enable_split_jobs=False): def create_render_jobs(jobs_list, loaded_project_local_path, job_dir):
results = [] results = []
for job_data in jobs_list: for job_data in jobs_list:
@@ -162,7 +162,7 @@ def create_render_jobs(jobs_list, loaded_project_local_path, job_dir, enable_spl
worker.end_frame = int(job_data.get("end_frame", worker.end_frame)) worker.end_frame = int(job_data.get("end_frame", worker.end_frame))
# determine if we can / should split the job # determine if we can / should split the job
if enable_split_jobs and (worker.total_frames > 1) and not worker.parent: if job_data.get("enable_split_jobs", False) and (worker.total_frames > 1) and not worker.parent:
DistributedJobManager.split_into_subjobs(worker, job_data, loaded_project_local_path) DistributedJobManager.split_into_subjobs(worker, job_data, loaded_project_local_path)
else: else:
logger.debug("Not splitting into subjobs") logger.debug("Not splitting into subjobs")

View File

@@ -24,7 +24,8 @@ from src.engines.core.base_worker import string_to_status, RenderStatus
from src.engines.engine_manager import EngineManager from src.engines.engine_manager import EngineManager
from src.render_queue import RenderQueue, JobNotFoundError from src.render_queue import RenderQueue, JobNotFoundError
from src.utilities.config import Config from src.utilities.config import Config
from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu, current_system_os_version from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu, \
current_system_os_version, config_dir
from src.utilities.server_helper import generate_thumbnail_for_job from src.utilities.server_helper import generate_thumbnail_for_job
from src.utilities.zeroconf_server import ZeroconfServer from src.utilities.zeroconf_server import ZeroconfServer
@@ -53,7 +54,7 @@ def sorted_jobs(all_jobs, sort_by_date=True):
@server.route('/') @server.route('/')
@server.route('/index') @server.route('/index')
def index(): def index():
with open(system_safe_path('config/presets.yaml')) as f: with open(system_safe_path(os.path.join(config_dir(), 'presets.yaml'))) as f:
render_presets = yaml.load(f, Loader=yaml.FullLoader) render_presets = yaml.load(f, Loader=yaml.FullLoader)
return render_template('index.html', all_jobs=sorted_jobs(RenderQueue.all_jobs()), return render_template('index.html', all_jobs=sorted_jobs(RenderQueue.all_jobs()),
@@ -312,8 +313,7 @@ def add_job_handler():
if loaded_project_local_path.lower().endswith('.zip'): if loaded_project_local_path.lower().endswith('.zip'):
loaded_project_local_path = process_zipped_project(loaded_project_local_path) loaded_project_local_path = process_zipped_project(loaded_project_local_path)
results = create_render_jobs(jobs_list, loaded_project_local_path, referred_name, results = create_render_jobs(jobs_list, loaded_project_local_path, referred_name)
server.config['enable_split_jobs'])
for response in results: for response in results:
if response.get('error', None): if response.get('error', None):
return results, 400 return results, 400
@@ -417,18 +417,18 @@ def status():
@server.get('/api/renderer_info') @server.get('/api/renderer_info')
def renderer_info(): def renderer_info():
return_simple = request.args.get('simple', False)
renderer_data = {} renderer_data = {}
for engine_name in EngineManager.supported_engines(): for engine in EngineManager.supported_engines():
engine = EngineManager.engine_with_name(engine_name)
# 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'] install_path = 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, if not return_simple:
'supported_export_formats': engine(install_path).get_output_formats()} renderer_data[engine.name()]['supported_extensions'] = engine.supported_extensions
renderer_data[engine.name()]['supported_export_formats'] = engine(install_path).get_output_formats()
return renderer_data return renderer_data
@@ -471,19 +471,20 @@ def download_engine():
@server.post('/api/delete_engine') @server.post('/api/delete_engine')
def delete_engine_download(): def delete_engine_download():
delete_result = EngineManager.delete_engine_download(request.args.get('engine'), json_data = request.json
request.args.get('version'), delete_result = EngineManager.delete_engine_download(json_data.get('engine'),
request.args.get('system_os'), json_data.get('version'),
request.args.get('cpu')) json_data.get('system_os'),
json_data.get('cpu'))
return "Success" if delete_result else \ return "Success" if delete_result else \
(f"Error deleting {request.args.get('engine')} {request.args.get('version')}", 500) (f"Error deleting {json_data.get('engine')} {json_data.get('version')}", 500)
@server.get('/api/renderer/<renderer>/args') @server.get('/api/renderer/<renderer>/args')
def get_renderer_args(renderer): def get_renderer_args(renderer):
try: try:
renderer_engine_class = EngineManager.engine_with_name(renderer) renderer_engine_class = EngineManager.engine_with_name(renderer)
return renderer_engine_class.get_arguments() return renderer_engine_class().get_arguments()
except LookupError: except LookupError:
return f"Cannot find renderer '{renderer}'", 400 return f"Cannot find renderer '{renderer}'", 400
@@ -499,13 +500,6 @@ def start_server():
RenderQueue.evaluate_queue() RenderQueue.evaluate_queue()
time.sleep(delay_sec) time.sleep(delay_sec)
# Load Config YAML
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'config')
Config.load_config(system_safe_path(os.path.join(config_dir, 'config.yaml')))
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
level=Config.server_log_level.upper())
# get hostname # get hostname
local_hostname = socket.gethostname() local_hostname = socket.gethostname()
local_hostname = local_hostname + (".local" if not local_hostname.endswith(".local") else "") local_hostname = local_hostname + (".local" if not local_hostname.endswith(".local") else "")

View File

@@ -34,9 +34,14 @@ class RenderServerProxy:
self.__offline_flags = 0 self.__offline_flags = 0
self.update_cadence = 5 self.update_cadence = 5
# Cache some basic server info
self.system_cpu = None
self.system_cpu_count = None
self.system_os = None
self.system_os_version = None
def connect(self): def connect(self):
status = self.request_data('status') return self.status()
return status
def is_online(self): def is_online(self):
if self.__update_in_background: if self.__update_in_background:
@@ -48,7 +53,7 @@ class RenderServerProxy:
if not self.is_online(): if not self.is_online():
return "Offline" return "Offline"
running_jobs = [x for x in self.__jobs_cache if x['status'] == 'running'] if self.__jobs_cache else [] running_jobs = [x for x in self.__jobs_cache if x['status'] == 'running'] if self.__jobs_cache else []
return f"{len(running_jobs)} running" if running_jobs else "Available" return f"{len(running_jobs)} running" if running_jobs else "Ready"
def request_data(self, payload, timeout=5): def request_data(self, payload, timeout=5):
try: try:
@@ -72,6 +77,8 @@ class RenderServerProxy:
return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout) return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout)
def start_background_update(self): def start_background_update(self):
if self.__update_in_background:
return
self.__update_in_background = True self.__update_in_background = True
def thread_worker(): def thread_worker():
@@ -113,12 +120,24 @@ class RenderServerProxy:
def cancel_job(self, job_id, confirm=False): def cancel_job(self, job_id, confirm=False):
return self.request_data(f'job/{job_id}/cancel?confirm={confirm}') return self.request_data(f'job/{job_id}/cancel?confirm={confirm}')
def delete_job(self, job_id, confirm=False):
return self.request_data(f'job/{job_id}/delete?confirm={confirm}')
def get_status(self): def get_status(self):
return self.request_data('status') status = self.request_data('status')
if not self.system_cpu:
self.system_cpu = status['system_cpu']
self.system_cpu_count = status['cpu_count']
self.system_os = status['system_os']
self.system_os_version = status['system_os_version']
return status
def is_engine_available(self, engine_name): def is_engine_available(self, engine_name):
return self.request_data(f'{engine_name}/is_available') return self.request_data(f'{engine_name}/is_available')
def get_all_engines(self):
return self.request_data('all_engines')
def notify_parent_of_status_change(self, parent_id, subjob): 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.hostname}:{self.port}/api/job/{parent_id}/notify_parent_of_status_change',
json=subjob.json()) json=subjob.json())
@@ -160,3 +179,12 @@ class RenderServerProxy:
f.write(chunk) f.write(chunk)
return filename return filename
# --- Renderer --- #
def get_renderer_info(self, timeout=5, simple=False):
all_data = self.request_data(f'renderer_info?simple={simple}', timeout=timeout)
return all_data
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)

View File

@@ -108,7 +108,7 @@ class BlenderDownloader(EngineDownloader):
minor_versions = [x for x in cls.__get_minor_versions(major_version, system_os, cpu) if x['version'] == version] minor_versions = [x for x in cls.__get_minor_versions(major_version, system_os, cpu) if x['version'] == version]
# we get the URL instead of calculating it ourselves. May change this # we get the URL instead of calculating it ourselves. May change this
cls.__download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location, cls.download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location,
timeout=timeout) timeout=timeout)
except IndexError: except IndexError:
logger.error("Cannot find requested engine") logger.error("Cannot find requested engine")

View File

@@ -31,7 +31,7 @@ class EngineDownloader:
raise NotImplementedError # implement this method in your engine subclass raise NotImplementedError # implement this method in your engine subclass
@classmethod @classmethod
def __download_and_extract_app(cls, remote_url, download_location, timeout=120): def download_and_extract_app(cls, remote_url, download_location, timeout=120):
# Create a temp download directory # Create a temp download directory
temp_download_dir = tempfile.mkdtemp() temp_download_dir = tempfile.mkdtemp()
@@ -154,5 +154,7 @@ def copy_directory_contents(src_dir, dest_dir):
# Otherwise, copy the file # Otherwise, copy the file
shutil.copy2(item_path, dest_item_path) shutil.copy2(item_path, dest_item_path)
except PermissionError as ex:
logger.error(f"Permissions error: {ex}")
except Exception as e: except Exception as e:
logger.exception(f"Error copying directory contents: {e}") logger.exception(f"Error copying directory contents: {e}")

View File

@@ -170,14 +170,22 @@ class EngineManager:
@classmethod @classmethod
def delete_engine_download(cls, engine, version, system_os=None, cpu=None): def delete_engine_download(cls, engine, version, system_os=None, cpu=None):
logger.info(f"Requested deletion of engine: {engine}-{version}") logger.info(f"Requested deletion of engine: {engine}-{version}")
found = cls.is_version_downloaded(engine, version, system_os, cpu) found = cls.is_version_downloaded(engine, version, system_os, cpu)
if found: if found and found['type'] == 'managed': # don't delete system installs
dir_path = os.path.dirname(found['path']) # find the root directory of the engine executable
shutil.rmtree(dir_path, ignore_errors=True) root_dir_name = '-'.join([engine, version, found['system_os'], found['cpu']])
remove_path = os.path.join(found['path'].split(root_dir_name)[0], root_dir_name)
# delete the file path
logger.info(f"Deleting engine at path: {remove_path}")
shutil.rmtree(remove_path, ignore_errors=False)
logger.info(f"Engine {engine}-{version}-{found['system_os']}-{found['cpu']} successfully deleted") logger.info(f"Engine {engine}-{version}-{found['system_os']}-{found['cpu']} successfully deleted")
return True return True
elif found: # these are managed by the system / user. Don't delete these.
logger.error(f'Cannot delete requested {engine} {version}. Managed externally.')
else: else:
logger.error(f"Cannot find engine: {engine}-{version}") logger.error(f"Cannot find engine: {engine}-{version}")
return False
@classmethod @classmethod
def update_all_engines(cls): def update_all_engines(cls):
@@ -233,6 +241,13 @@ class EngineManager:
return worker_class(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args, return worker_class(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args,
parent=parent, name=name) parent=parent, name=name)
@classmethod
def engine_for_project_path(cls, path):
name, extension = os.path.splitext(path)
for engine in cls.supported_engines():
if extension in engine.supported_extensions:
return engine
if __name__ == '__main__': if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')

View File

@@ -162,7 +162,7 @@ class FFMPEGDownloader(EngineDownloader):
# Download and extract # Download and extract
try: try:
logger.info(f"Requesting download of ffmpeg-{version}-{system_os}-{cpu}") logger.info(f"Requesting download of ffmpeg-{version}-{system_os}-{cpu}")
cls.__download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout) cls.download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout)
# naming cleanup to match existing naming convention # naming cleanup to match existing naming convention
output_path = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}') output_path = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}')

65
src/init.py Normal file
View File

@@ -0,0 +1,65 @@
''' app/init.py '''
import logging
import os
import sys
import threading
from collections import deque
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWidgets import QApplication
from .ui.main_window import MainWindow
from src.api.api_server import start_server
from src.utilities.config import Config
from src.utilities.misc_helper import system_safe_path
def run() -> int:
"""
Initializes the application and runs it.
Returns:
int: The exit status code.
"""
# Load Config YAML
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config')
Config.load_config(system_safe_path(os.path.join(config_dir, 'config.yaml')))
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
level=Config.server_log_level.upper())
app: QApplication = QApplication(sys.argv)
# Start server in background
background_server = threading.Thread(target=start_server)
background_server.daemon = True
background_server.start()
# Setup logging for console ui
buffer_handler = BufferingHandler()
buffer_handler.setFormatter(logging.getLogger().handlers[0].formatter)
logger = logging.getLogger()
logger.addHandler(buffer_handler)
window: MainWindow = MainWindow()
window.buffer_handler = buffer_handler
window.show()
return sys.exit(app.exec())
class BufferingHandler(logging.Handler, QObject):
new_record = pyqtSignal(str)
def __init__(self, capacity=100):
logging.Handler.__init__(self)
QObject.__init__(self)
self.buffer = deque(maxlen=capacity) # Define a buffer with a fixed capacity
def emit(self, record):
msg = self.format(record)
self.buffer.append(msg) # Add message to the buffer
self.new_record.emit(msg) # Emit signal
def get_buffer(self):
return list(self.buffer) # Return a copy of the buffer

0
src/ui/__init__.py Normal file
View File

289
src/ui/add_job.py Normal file
View File

@@ -0,0 +1,289 @@
import os.path
import socket
import threading
import psutil
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox,
QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit
)
from src.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager
from src.utilities.zeroconf_server import ZeroconfServer
class NewRenderJobForm(QWidget):
def __init__(self):
super().__init__()
# UI
self.raw_args = None
self.submit_progress_label = None
self.submit_progress = None
self.renderer_type = None
self.process_label = None
self.process_progress_bar = None
self.splitjobs_same_os = None
self.enable_splitjobs = None
self.server_input = None
self.submit_button = None
self.notes_input = None
self.priority_input = None
self.end_frame_input = None
self.start_frame_input = None
self.output_path_browse_button = None
self.output_path_input = None
self.scene_file_input = None
self.scene_file_browse_button = None
self.job_name_input = None
# Setup
self.setWindowTitle("New Job")
self.setup_ui()
# Job / Server Data
self.server_proxy = RenderServerProxy(socket.gethostname())
self.renderer_info = None
self.project_info = None
# 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_layout = QVBoxLayout(self)
# Server Group
# Server List
server_group = QGroupBox("Server")
server_layout = QVBoxLayout(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(server_group)
self.update_server_list()
# Priority
priority_layout = QHBoxLayout()
priority_layout.addWidget(QLabel("Priority:"), 1)
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)
# 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_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_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)
# Output Settings Group
output_settings_group = QGroupBox("Output Settings")
output_settings_layout = QVBoxLayout(output_settings_group)
frame_range_layout = QHBoxLayout(output_settings_group)
self.start_frame_input = QSpinBox()
self.start_frame_input.setRange(1, 99999)
self.end_frame_input = QSpinBox()
self.end_frame_input.setRange(1, 99999)
frame_range_layout.addWidget(QLabel("Frames:"))
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)
# output path
output_path_layout = QHBoxLayout()
output_path_layout.addWidget(QLabel("Render name:"))
self.output_path_input = QLineEdit()
# self.output_path_browse_button = QPushButton("Browse...")
# self.output_path_browse_button.clicked.connect(self.browse_output_path)
output_path_layout.addWidget(self.output_path_input)
output_path_layout.addWidget(self.output_path_browse_button)
output_settings_layout.addLayout(output_path_layout)
main_layout.addWidget(output_settings_group)
# Renderer Group
renderer_group = QGroupBox("Renderer Settings")
renderer_layout = QVBoxLayout(renderer_group)
self.renderer_type = QComboBox()
renderer_layout.addWidget(self.renderer_type)
# Raw Args
raw_args_layout = QHBoxLayout(renderer_group)
raw_args_layout.addWidget(QLabel("Raw Args:"))
self.raw_args = QLineEdit()
raw_args_layout.addWidget(self.raw_args)
args_help_button = QPushButton("?")
args_help_button.clicked.connect(self.args_help_button_clicked)
raw_args_layout.addWidget(args_help_button)
renderer_layout.addLayout(raw_args_layout)
main_layout.addWidget(renderer_group)
# Notes Group
notes_group = QGroupBox("Additional Notes")
notes_layout = QVBoxLayout(notes_group)
self.notes_input = QPlainTextEdit()
notes_layout.addWidget(self.notes_input)
main_layout.addWidget(notes_group)
# Submit Button
self.submit_button = QPushButton("Submit Job")
self.submit_button.clicked.connect(self.submit_job)
main_layout.addWidget(self.submit_button)
self.submit_progress = QProgressBar()
self.submit_progress.setMinimum(0)
self.submit_progress.setMaximum(0)
self.submit_progress.setHidden(True)
main_layout.addWidget(self.submit_progress)
self.submit_progress_label = QLabel("Submitting...")
self.submit_progress_label.setHidden(True)
main_layout.addWidget(self.submit_progress_label)
self.toggle_renderer_enablement(False)
def update_renderer_info(self):
self.renderer_info = self.server_proxy.get_renderer_info()
self.renderer_type.addItems(self.renderer_info.keys())
def update_server_list(self):
clients = ZeroconfServer.found_clients()
self.server_input.clear()
self.server_input.addItems(clients)
def browse_scene_file(self):
def get_project_info():
self.process_progress_bar.setHidden(False)
self.process_label.setHidden(False)
self.toggle_renderer_enablement(False)
output_name, _ = os.path.splitext(os.path.basename(file_name))
self.output_path_input.setText(output_name)
engine = EngineManager.engine_for_project_path(file_name)
self.project_info = engine().get_scene_info(file_name)
index = self.renderer_type.findText(engine.name().lower())
if index >= 0:
self.renderer_type.setCurrentIndex(index)
self.update_project_ui()
self.process_progress_bar.setHidden(True)
self.process_label.setHidden(True)
self.toggle_renderer_enablement(True)
file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
if file_name:
self.scene_file_input.setText(file_name)
# analyze the file
update_thread = threading.Thread(target=get_project_info)
update_thread.start()
def browse_output_path(self):
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
if directory:
self.output_path_input.setText(directory)
def args_help_button_clicked(self):
#todo: create a popup window showing args
pass
# -------- Update --------
def update_project_ui(self):
self.start_frame_input.setValue(self.project_info.get('frame_start'))
self.end_frame_input.setValue(self.project_info.get('frame_end'))
def toggle_renderer_enablement(self, enabled=False):
self.start_frame_input.setEnabled(enabled)
self.end_frame_input.setEnabled(enabled)
self.notes_input.setEnabled(enabled)
self.output_path_input.setEnabled(enabled)
self.submit_button.setEnabled(enabled)
# -------- Submit Job Calls --------
def submit_job(self):
def submit_job_worker():
def create_callback(encoder):
encoder_len = encoder.len
def callback(monitor):
percent = f"{monitor.bytes_read / encoder_len * 100:.0f}"
self.submit_progress_label.setText(f"Transferring to {hostname} - {percent}%")
self.submit_progress.setMaximum(100)
self.submit_progress.setValue(int(percent))
return callback
self.submit_progress.setHidden(False)
self.submit_progress_label.setHidden(False)
self.submit_button.setHidden(True)
hostname = self.server_input.currentText()
job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(),
'renderer': self.renderer_type.currentText().lower(),
# 'input_path': self.scene_file_input.text(),
# 'output_path': os.path.join(os.path.dirname(self.chosen_file), self.output_entry.get()),
'args': {'raw': self.raw_args.text()},
'output_path': self.output_path_input.text(),
'start_frame': self.start_frame_input.value(),
'end_frame': self.end_frame_input.value(),
'priority': self.priority_input.currentIndex() + 1,
'notes': self.notes_input.toPlainText(),
'enable_split_jobs': self.enable_splitjobs.isChecked()}
input_path = self.scene_file_input.text()
job_list = [job_json]
self.submit_progress.setMaximum(0)
result = self.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list,
callback=create_callback)
self.submit_progress.setMaximum(0)
print(result.json())
self.submit_button.setHidden(False)
self.submit_progress.setHidden(True)
self.submit_progress_label.setHidden(True)
# submit thread
worker_thread = threading.Thread(target=submit_job_worker)
worker_thread.start()
# Run the application
if __name__ == '__main__':
app = QApplication([])
window = NewRenderJobForm()
app.exec()

60
src/ui/console.py Normal file
View File

@@ -0,0 +1,60 @@
import sys
import logging
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget, QPlainTextEdit
from PyQt6.QtCore import pyqtSignal, QObject
# Create a custom logging handler that emits a signal
class QSignalHandler(logging.Handler, QObject):
new_record = pyqtSignal(str)
def __init__(self):
logging.Handler.__init__(self)
QObject.__init__(self)
def emit(self, record):
msg = self.format(record)
self.new_record.emit(msg) # Emit signal
class ConsoleWindow(QMainWindow):
def __init__(self, buffer_handler):
super().__init__()
self.buffer_handler = buffer_handler
self.log_handler = None
self.init_ui()
self.init_logging()
def init_ui(self):
self.setGeometry(100, 100, 600, 800)
self.setWindowTitle("Log Output")
self.text_edit = QPlainTextEdit(self)
self.text_edit.setReadOnly(True)
self.text_edit.setFont(QFont("Courier", 10))
layout = QVBoxLayout()
layout.addWidget(self.text_edit)
layout.setContentsMargins(0, 0, 0, 0)
central_widget = QWidget()
central_widget.setLayout(layout)
self.setCentralWidget(central_widget)
def init_logging(self):
self.buffer_handler.new_record.connect(self.append_log_record)
# Display all messages that were buffered before the window was opened
for record in self.buffer_handler.get_buffer():
self.text_edit.appendPlainText(record)
self.log_handler = QSignalHandler()
# self.log_handler.new_record.connect(self.append_log_record)
self.log_handler.setFormatter(self.buffer_handler.formatter)
logging.getLogger().addHandler(self.log_handler)
logging.getLogger().setLevel(logging.INFO)
def append_log_record(self, record):
self.text_edit.appendPlainText(record)

159
src/ui/engine_browser.py Normal file
View File

@@ -0,0 +1,159 @@
import os
import socket
import subprocess
import sys
import threading
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QPushButton, QTableWidget, QTableWidgetItem, QHBoxLayout, QAbstractItemView,
QHeaderView, QProgressBar, QLabel, QMessageBox
)
from src.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager
class EngineBrowserWindow(QMainWindow):
def __init__(self, hostname=None):
super().__init__()
self.delete_button = None
self.install_button = None
self.progress_label = None
self.progress_bar = None
self.table_widget = None
self.launch_button = None
self.hostname = hostname or socket.gethostname()
self.setWindowTitle(f'Engine Browser ({self.hostname})')
self.setGeometry(100, 100, 500, 300)
self.engine_data = []
self.initUI()
def initUI(self):
# Central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Layout
layout = QVBoxLayout(central_widget)
# Table
self.table_widget = QTableWidget(0, 4)
self.table_widget.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table_widget.verticalHeader().setVisible(False)
self.table_widget.itemSelectionChanged.connect(self.engine_picked)
self.table_widget.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
layout.addWidget(self.table_widget)
self.update_table()
# Progress Bar Layout
self.progress_bar = QProgressBar()
self.progress_bar.setMinimum(0)
self.progress_bar.setMaximum(0)
# self.progress_bar.setHidden(True)
layout.addWidget(self.progress_bar)
# Progress Bar Label
self.progress_label = QLabel('Downloading blah blah')
layout.addWidget(self.progress_label)
# Buttons Layout
buttons_layout = QHBoxLayout()
# Install Button
self.install_button = QPushButton('Install')
self.install_button.clicked.connect(self.install_button_click) # Connect to slot
# buttons_layout.addWidget(self.install_button)
# Launch Button
self.launch_button = QPushButton('Launch')
self.launch_button.clicked.connect(self.launch_button_click) # Connect to slot
self.launch_button.setEnabled(False)
buttons_layout.addWidget(self.launch_button)
#Delete Button
self.delete_button = QPushButton('Delete')
self.delete_button.clicked.connect(self.delete_button_click) # Connect to slot
self.delete_button.setEnabled(False)
buttons_layout.addWidget(self.delete_button)
# Add Buttons Layout to the Main Layout
layout.addLayout(buttons_layout)
self.update_download_status()
def update_table(self):
def update_table_worker():
raw_server_data = RenderServerProxy(self.hostname).get_renderer_info(simple=True)
if not raw_server_data:
return
table_data = [] # convert the data into a flat list
for engine_name, engine_data in raw_server_data.items():
table_data.extend(engine_data['versions'])
self.engine_data = table_data
self.table_widget.setRowCount(len(self.engine_data))
self.table_widget.setColumnCount(4)
for row, engine in enumerate(self.engine_data):
self.table_widget.setItem(row, 0, QTableWidgetItem(engine['engine']))
self.table_widget.setItem(row, 1, QTableWidgetItem(engine['version']))
self.table_widget.setItem(row, 2, QTableWidgetItem(engine['type']))
self.table_widget.setItem(row, 3, QTableWidgetItem(engine['path']))
self.table_widget.selectRow(0)
self.table_widget.clear()
self.table_widget.setHorizontalHeaderLabels(['Engine', 'Version', 'Type', 'Path'])
self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
self.table_widget.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
self.table_widget.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
update_thread = threading.Thread(target=update_table_worker,)
update_thread.start()
def engine_picked(self):
engine_info = self.engine_data[self.table_widget.currentRow()]
self.delete_button.setEnabled(engine_info['type'] == 'managed')
self.launch_button.setEnabled(self.hostname == socket.gethostname())
def update_download_status(self):
hide_progress = not bool(EngineManager.download_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(EngineManager.download_tasks)} engines")
def launch_button_click(self):
engine_info = self.engine_data[self.table_widget.currentRow()]
path = engine_info['path']
if sys.platform.startswith('darwin'):
subprocess.run(['open', path])
elif sys.platform.startswith('win32'):
os.startfile(path)
elif sys.platform.startswith('linux'):
subprocess.run(['xdg-open', path])
else:
raise OSError("Unsupported operating system")
def install_button_click(self):
self.update_download_status()
def delete_button_click(self):
engine_info = self.engine_data[self.table_widget.currentRow()]
reply = QMessageBox.question(self, f"Delete {engine_info['engine']} {engine_info['version']}?",
f"Do you want to delete {engine_info['engine']} {engine_info['version']}?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
if reply is not QMessageBox.StandardButton.Yes:
return
result = RenderServerProxy(self.hostname).delete_engine(engine_info['engine'], engine_info['version'])
if result.ok:
self.update_table()
else:
QMessageBox.warning(self, f"Delete {engine_info['engine']} {engine_info['version']} Failed",
f"Failed to delete {engine_info['engine']} {engine_info['version']}.",
QMessageBox.StandardButton.Ok)

30
src/ui/log_viewer.py Normal file
View File

@@ -0,0 +1,30 @@
import requests
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget, QPlainTextEdit
class LogViewer(QMainWindow):
def __init__(self, log_path):
super().__init__()
self.log_path = log_path
self.setGeometry(100, 100, 600, 800)
self.setWindowTitle("Log Output")
self.text_edit = QPlainTextEdit(self)
self.text_edit.setReadOnly(True)
self.text_edit.setFont(QFont("Courier", 10))
layout = QVBoxLayout()
layout.addWidget(self.text_edit)
layout.setContentsMargins(0, 0, 0, 0)
central_widget = QWidget()
central_widget.setLayout(layout)
self.setCentralWidget(central_widget)
self.fetch_logs()
def fetch_logs(self):
result = requests.get(self.log_path)
self.text_edit.setPlainText(result.text)

562
src/ui/main_window.py Normal file
View File

@@ -0,0 +1,562 @@
''' app/ui/main_window.py '''
import datetime
import logging
import os
import socket
import subprocess
import sys
import threading
import time
from PIL import Image
from PyQt6.QtCore import Qt, QByteArray, QBuffer, QIODevice, QThread
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
from src.api.server_proxy import RenderServerProxy
from src.render_queue import RenderQueue
from src.utilities.misc_helper import get_time_elapsed, resources_dir
from src.utilities.status_utils import RenderStatus
from src.utilities.zeroconf_server import ZeroconfServer
from .add_job import NewRenderJobForm
from .console import ConsoleWindow
from .engine_browser import EngineBrowserWindow
from .log_viewer import LogViewer
from .widgets.menubar import MenuBar
from .widgets.proportional_image_label import ProportionalImageLabel
from .widgets.statusbar import StatusBar
from .widgets.toolbar import ToolBar
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.engine_browser_window = None
self.server_info_group = None
self.server_proxies = {}
self.current_hostname = None
self.subprocess_runner = None
# To pass to console
self.buffer_handler = None
# Window-Settings
self.setWindowTitle("Zordon")
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(500)
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)
self.create_toolbars()
# Add Widgets to Window
self.setMenuBar(MenuBar(self))
self.setStatusBar(StatusBar(self))
# start background update
self.bg_update_thread = QThread()
self.bg_update_thread.run = self.__background_update
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):
# 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()
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(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)
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)
# Job Layout
job_list_group = QGroupBox("Render Jobs")
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)
# 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)
main_layout.addLayout(right_layout)
def __background_update(self):
while True:
self.update_servers()
# self.fetch_jobs()
# todo: fix job updates - issues with threading
time.sleep(0.5)
def closeEvent(self, event):
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 -- #
@property
def current_server_proxy(self):
return self.server_proxies.get(self.current_hostname, None)
def server_picked(self):
"""Update the table and Server Info box when a server is changed"""
try:
new_hostname = self.server_list_view.currentItem().text()
if new_hostname != self.current_hostname:
self.current_hostname = new_hostname
self.job_list_view.setRowCount(0)
self.fetch_jobs(clear_table=True)
if self.job_list_view.rowCount():
self.job_list_view.selectRow(0)
# Update the Server Info box when a server is changed
self.server_info_hostname.setText(self.current_hostname or "unknown")
if self.current_server_proxy.system_os:
self.server_info_os.setText(f"OS: {self.current_server_proxy.system_os} "
f"{self.current_server_proxy.system_os_version}")
self.server_info_cpu.setText(f"CPU: {self.current_server_proxy.system_cpu} - "
f"{self.current_server_proxy.system_cpu_count} cores")
else:
self.server_info_os.setText(f"OS: Loading...")
self.server_info_cpu.setText(f"CPU: Loading...")
def update_server_info_worker():
server_details = self.current_server_proxy.get_status()
if server_details['hostname'] == self.current_hostname:
self.server_info_os.setText(f"OS: {server_details.get('system_os')} "
f"{server_details.get('system_os_version')}")
self.server_info_cpu.setText(f"CPU: {server_details.get('system_cpu')} - "
f"{server_details.get('cpu_count')} cores")
update_thread = threading.Thread(target=update_server_info_worker)
update_thread.start()
except AttributeError:
pass
def fetch_jobs(self, clear_table=False):
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=clear_table)
if job_fetch:
num_jobs = len(job_fetch)
self.job_list_view.setRowCount(num_jobs)
for row, job in enumerate(job_fetch):
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', ''))
renderer = f"{job.get('renderer', '')}-{job.get('renderer_version')}"
priority = str(job.get('priority', ''))
total_frames = str(job.get('total_frames', ''))
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(renderer),
QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed),
QTableWidgetItem(total_frames), QTableWidgetItem(job['date_created'])]
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:
before_fetch_hostname = self.current_server_proxy.hostname
response = self.current_server_proxy.request(f'job/{job_id}/thumbnail?size=big')
if response.ok:
import io
image_data = response.content
image = Image.open(io.BytesIO(image_data))
if self.current_server_proxy.hostname == before_fetch_hostname and job_id == \
self.selected_job_ids()[0]:
self.load_image_data(image)
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 = self.current_hostname == socket.gethostname()
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 = current_status.lower() == 'running'
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):
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
def refresh_job_headers(self):
self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Renderer", "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 -- #
def load_image_path(self, image_path):
# Load and set the image using QPixmap
pixmap = QPixmap(image_path)
if not pixmap:
logger.error("Error loading image")
return
self.image_label.setPixmap(pixmap)
def load_image_data(self, pillow_image):
# 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)
def update_servers(self):
found_servers = list(set(ZeroconfServer.found_clients() + self.added_hostnames))
# Always make sure local hostname is first
current_hostname = socket.gethostname()
if found_servers and found_servers[0] != current_hostname:
if current_hostname in found_servers:
found_servers.remove(current_hostname)
found_servers.insert(0, current_hostname)
old_count = self.server_list_view.count()
# Update proxys
for hostname in found_servers:
if not self.server_proxies.get(hostname, None):
new_proxy = RenderServerProxy(hostname=hostname)
new_proxy.start_background_update()
self.server_proxies[hostname] = new_proxy
# 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:
if hostname not in current_server_list:
image_path = os.path.join(resources_dir(), 'icons', '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 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(
"New Job", f"{resources_directory}/icons/AddProduct.png", self.new_job)
self.topbar.add_button(
"Engines", f"{resources_directory}/icons/SoftwareInstaller.png", self.engine_browser)
self.topbar.add_button(
"Console", f"{resources_directory}/icons/Console.png", self.open_console_window)
self.topbar.add_separator()
self.topbar.add_button(
"Stop Job", f"{resources_directory}/icons/StopSign.png", self.stop_job)
self.topbar.add_button(
"Delete Job", f"{resources_directory}/icons/Trash.png", self.delete_job)
self.topbar.add_button(
"Render Log", f"{resources_directory}/icons/Document.png", self.job_logs)
self.topbar.add_button(
"Download", f"{resources_directory}/icons/Download.png", self.download_files)
self.topbar.add_button(
"Open Files", f"{resources_directory}/icons/SearchFolder.png", self.open_files)
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:
"""
Event handler for the "Logs" button.
"""
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):
"""
Event handler for the "Exit" button. Closes the application.
"""
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.cancel_job(job_id, confirm=True)
self.fetch_jobs(clear_table=True)
def delete_job(self, event):
"""
Event handler for the Delete Job button
"""
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.fetch_jobs(clear_table=True)
def download_files(self, event):
pass
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'])
if sys.platform.startswith('darwin'):
subprocess.run(['open', path])
elif sys.platform.startswith('win32'):
os.startfile(path)
elif sys.platform.startswith('linux'):
subprocess.run(['xdg-open', path])
else:
raise OSError("Unsupported operating system")
def new_job(self) -> None:
self.new_job_window = NewRenderJobForm()
self.new_job_window.show()

View File

1
src/ui/widgets/dialog.py Normal file
View File

@@ -0,0 +1 @@
''' app/ui/widgets/dialog.py '''

23
src/ui/widgets/menubar.py Normal file
View File

@@ -0,0 +1,23 @@
''' app/ui/widgets/menubar.py '''
from PyQt6.QtWidgets import QMenuBar
class MenuBar(QMenuBar):
"""
Initialize the menu bar.
Args:
parent: The parent widget.
"""
def __init__(self, parent=None) -> None:
super().__init__(parent)
file_menu = self.addMenu("File")
# edit_menu = self.addMenu("Edit")
# view_menu = self.addMenu("View")
# help_menu = self.addMenu("Help")
# Add actions to the menus
# file_menu.addAction(self.parent().topbar.actions_call["Open"]) # type: ignore
# file_menu.addAction(self.parent().topbar.actions_call["Save"]) # type: ignore
# file_menu.addAction(self.parent().topbar.actions_call["Exit"]) # type: ignore

View File

@@ -0,0 +1,40 @@
from PyQt6.QtCore import QRectF
from PyQt6.QtGui import QPainter
from PyQt6.QtWidgets import QLabel
class ProportionalImageLabel(QLabel):
def __init__(self):
super().__init__()
def setPixmap(self, pixmap):
self._pixmap = pixmap
super().setPixmap(self._pixmap)
def paintEvent(self, event):
if self._pixmap.isNull():
super().paintEvent(event)
return
painter = QPainter(self)
targetRect = event.rect()
# Calculate the aspect ratio of the pixmap
aspectRatio = self._pixmap.width() / self._pixmap.height()
# Calculate the size of the pixmap within the target rectangle while maintaining the aspect ratio
if aspectRatio > targetRect.width() / targetRect.height():
scaledWidth = targetRect.width()
scaledHeight = targetRect.width() / aspectRatio
else:
scaledHeight = targetRect.height()
scaledWidth = targetRect.height() * aspectRatio
# Calculate the position to center the pixmap within the target rectangle
x = targetRect.x() + (targetRect.width() - scaledWidth) / 2
y = targetRect.y() + (targetRect.height() - scaledHeight) / 2
sourceRect = QRectF(0.0, 0.0, self._pixmap.width(), self._pixmap.height())
targetRect = QRectF(x, y, scaledWidth, scaledHeight)
painter.drawPixmap(targetRect, self._pixmap, sourceRect)

View File

@@ -0,0 +1,61 @@
''' app/ui/widgets/statusbar.py '''
import os.path
import socket
import threading
import time
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QStatusBar, QLabel
from src.api.server_proxy import RenderServerProxy
from src.utilities.misc_helper import resources_dir
class StatusBar(QStatusBar):
"""
Initialize the status bar.
Args:
parent: The parent widget.
"""
def __init__(self, parent) -> None:
super().__init__(parent)
def background_update():
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
time.sleep(1)
background_thread = threading.Thread(target=background_update,)
background_thread.daemon = True
background_thread.start()
# Create a label that holds an image
self.label = QLabel()
image_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'resources', 'icons',
'RedSquare.png')
pixmap = (QPixmap(image_path).scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio))
self.label.setPixmap(pixmap)
self.addWidget(self.label)
# Create a label for the message
self.messageLabel = QLabel()
self.addWidget(self.messageLabel)
# Call this method to display a message
self.messageLabel.setText("Loading...")

49
src/ui/widgets/toolbar.py Normal file
View File

@@ -0,0 +1,49 @@
''' app/ui/widgets/toolbar.py '''
from PyQt6.QtCore import Qt, QSize
from PyQt6.QtGui import QAction, QIcon
from PyQt6.QtWidgets import QToolBar, QWidget, QSizePolicy
class ToolBar(QToolBar):
"""
Initialize the toolbar.
Args:
parent: The parent widget.
orientation: The toolbar's orientation.
style: The toolbar's tool button style.
icon_size: The toolbar's icon size.
"""
def __init__(self, parent,
orientation: Qt.Orientation = Qt.Orientation.Horizontal,
style: Qt.ToolButtonStyle = Qt.ToolButtonStyle.ToolButtonTextUnderIcon,
icon_size: tuple[int, int] = (32, 32)) -> None:
super().__init__(parent)
self.actions_call = {}
self.setOrientation(orientation)
self.setToolButtonStyle(style)
self.setIconSize(QSize(icon_size[0], icon_size[1]))
def add_button(self, text: str, icon: str, trigger_action) -> None:
"""
Add a button to the toolbar.
Args:
text: The button's text.
icon: The button's icon.
trigger_action: The action to be executed when the button is clicked.
"""
self.actions_call[text] = QAction(QIcon(icon), text, self)
self.actions_call[text].triggered.connect(trigger_action)
self.addAction(self.actions_call[text])
def add_separator(self) -> None:
"""
Add a separator to the toolbar.
"""
separator = QWidget(self)
separator.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.addWidget(separator)

View File

@@ -0,0 +1,29 @@
''' app/ui/widgets/treeview.py '''
from PyQt6.QtWidgets import QTreeView
from PyQt6.QtGui import QFileSystemModel
from PyQt6.QtCore import QDir
class TreeView(QTreeView):
"""
Initialize the TreeView widget.
Args:
parent (QWidget, optional): Parent widget of the TreeView. Defaults to None.
"""
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.file_system_model: QFileSystemModel = QFileSystemModel()
self.file_system_model.setRootPath(QDir.currentPath())
self.setModel(self.file_system_model)
self.setRootIndex(self.file_system_model.index(QDir.currentPath()))
self.setColumnWidth(0, 100)
self.setFixedWidth(150)
self.setSortingEnabled(True)
def clear_view(self) -> None:
"""
Clearing the TreeView
"""
self.destroy(destroySubWindows=True)

View File

@@ -123,3 +123,15 @@ def current_system_os_version():
def current_system_cpu(): def current_system_cpu():
# convert all x86 64 to "x64" # convert all x86 64 to "x64"
return platform.machine().lower().replace('amd64', 'x64').replace('x86_64', 'x64') return platform.machine().lower().replace('amd64', 'x64').replace('x86_64', 'x64')
def resources_dir():
resources_directory = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'resources')
return resources_directory
def config_dir():
config_directory = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'config')
return config_directory

View File

@@ -35,6 +35,7 @@ class ZeroconfServer:
@classmethod @classmethod
def _register_service(cls): def _register_service(cls):
try:
cls.server_ip = socket.gethostbyname(socket.gethostname()) cls.server_ip = socket.gethostbyname(socket.gethostname())
info = ServiceInfo( info = ServiceInfo(
@@ -48,6 +49,8 @@ class ZeroconfServer:
cls.service_info = info cls.service_info = info
cls.zeroconf.register_service(info) cls.zeroconf.register_service(info)
logger.info(f"Registered zeroconf service: {cls.service_info.name}") logger.info(f"Registered zeroconf service: {cls.service_info.name}")
except socket.gaierror as e:
logger.error(f"Error starting zeroconf service: {e}")
@classmethod @classmethod
def _unregister_service(cls): def _unregister_service(cls):
@@ -73,7 +76,17 @@ class ZeroconfServer:
@classmethod @classmethod
def found_clients(cls): def found_clients(cls):
return [x.split(f'.{cls.service_type}')[0] for x in cls.client_cache.keys()]
fetched_hostnames = [x.split(f'.{cls.service_type}')[0] for x in cls.client_cache.keys()]
local_hostname = socket.gethostname()
# Define a sort key function
def sort_key(hostname):
# Return 0 if it's the local hostname so it comes first, else return 1
return 0 if hostname == local_hostname else 1
# Sort the list with the local hostname first
sorted_hostnames = sorted(fetched_hostnames, key=sort_key)
return sorted_hostnames
# Example usage: # Example usage: