Add job polish (#63)

* Remove legacy client

* Misc cleanup

* Add message box after submission success / fail

* Use a new is_localhost method to handle localhost not including '.local'

* Code cleanup

* Fix issue where engine browser would think we're downloading forever

* Add message box after submission success / fail

* Use a new is_localhost method to handle localhost not including '.local'

* Code cleanup

* Fix issue where engine browser would think we're downloading forever

* Add pubsub messages to serverproxy_manager.py

* Add resolution, fps and renderer versions to add_job.py

* Add cameras to add_job.py

* Add message box after submission success / fail

* Use a new is_localhost method to handle localhost not including '.local'

* Code cleanup

* Fix issue where engine browser would think we're downloading forever

* Add message box after submission success / fail

* Code cleanup

* Add cameras to add_job.py

* Add dynamic engine options and output format

* Move UI work out of BG threads and add engine presubmission tasks

* Submit dynamic args when creating a new job

* Hide groups and show messagebox after submission

* Choose file when pressing New Job in main window now
This commit is contained in:
2023-11-11 07:35:56 -06:00
committed by GitHub
parent 0271abf705
commit da61bf72f8
15 changed files with 486 additions and 156 deletions

View File

@@ -418,7 +418,6 @@ def status():
@server.get('/api/renderer_info')
def renderer_info():
return_simple = request.args.get('simple', False)
renderer_data = {}
for engine in EngineManager.supported_engines():
# Get all installed versions of engine
@@ -426,10 +425,9 @@ def renderer_info():
if installed_versions:
install_path = installed_versions[0]['path']
renderer_data[engine.name()] = {'is_available': RenderQueue.is_available_for_job(engine.name()),
'versions': installed_versions}
if not return_simple:
renderer_data[engine.name()]['supported_extensions'] = engine.supported_extensions
renderer_data[engine.name()]['supported_export_formats'] = engine(install_path).get_output_formats()
'versions': installed_versions,
'supported_extensions': engine.supported_extensions,
'supported_export_formats': engine(install_path).get_output_formats()}
return renderer_data
@@ -542,7 +540,8 @@ def start_server():
# Set up the RenderQueue object
RenderQueue.load_state(database_directory=server.config['UPLOAD_FOLDER'])
DistributedJobManager.start()
ServerProxyManager.subscribe_to_listener()
DistributedJobManager.subscribe_to_listener()
thread = threading.Thread(target=eval_loop, kwargs={'delay_sec': Config.queue_eval_seconds}, daemon=True)
thread.start()

View File

@@ -8,6 +8,7 @@ import time
import requests
from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor
from src.utilities.misc_helper import is_localhost
from src.utilities.status_utils import RenderStatus
status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green',
@@ -145,7 +146,7 @@ class RenderServerProxy:
def post_job_to_server(self, file_path, job_list, callback=None):
# bypass uploading file if posting to localhost
if self.hostname == socket.gethostname():
if is_localhost(self.hostname):
jobs_with_path = [{**item, "local_path": file_path} for item in job_list]
return requests.post(f'http://{self.hostname}:{self.port}/api/add_job', data=json.dumps(jobs_with_path),
headers={'Content-Type': 'application/json'})
@@ -181,8 +182,8 @@ class RenderServerProxy:
# --- Renderer --- #
def get_renderer_info(self, timeout=5, simple=False):
all_data = self.request_data(f'renderer_info?simple={simple}', timeout=timeout)
def get_renderer_info(self, timeout=5):
all_data = self.request_data(f'renderer_info', timeout=timeout)
return all_data
def delete_engine(self, engine, version, system_cpu=None):

View File

@@ -1,3 +1,6 @@
from pubsub import pub
from zeroconf import ServiceStateChange
from src.api.server_proxy import RenderServerProxy
@@ -5,6 +8,21 @@ class ServerProxyManager:
server_proxys = {}
@classmethod
def subscribe_to_listener(cls):
"""
Subscribes the private class method '__local_job_status_changed' to the 'status_change' pubsub message.
This should be called once, typically during the initialization phase.
"""
pub.subscribe(cls.__zeroconf_state_change, 'zeroconf_state_change')
@classmethod
def __zeroconf_state_change(cls, hostname, state_change, info):
if state_change == ServiceStateChange.Added or state_change == ServiceStateChange.Updated:
cls.get_proxy_for_hostname(hostname)
else:
cls.server_proxys.pop(hostname)
@classmethod
def get_proxy_for_hostname(cls, hostname):
found_proxy = cls.server_proxys.get(hostname)
@@ -14,3 +32,4 @@ class ServerProxyManager:
cls.server_proxys[hostname] = new_proxy
found_proxy = new_proxy
return found_proxy

View File

@@ -22,7 +22,7 @@ class DistributedJobManager:
pass
@classmethod
def start(cls):
def subscribe_to_listener(cls):
"""
Subscribes the private class method '__local_job_status_changed' to the 'status_change' pubsub message.
This should be called once, typically during the initialization phase.

View File

@@ -63,7 +63,7 @@ class Blender(BaseRenderEngine):
raise FileNotFoundError(f'Python script not found: {script_path}')
raise Exception("Uncaught exception")
def get_scene_info(self, project_path, timeout=10):
def get_project_info(self, project_path, timeout=10):
scene_info = {}
try:
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_file_info.py')
@@ -138,6 +138,7 @@ class Blender(BaseRenderEngine):
return options
def get_detected_gpus(self):
# no longer works on 4.0
engine_output = subprocess.run([self.renderer_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT,
capture_output=True).stdout.decode('utf-8')
gpu_names = re.findall(r"DETECTED GPU: (.+)", engine_output)
@@ -149,6 +150,17 @@ class Blender(BaseRenderEngine):
render_engines = [x.strip() for x in engine_output.split('Blender Engine Listing:')[-1].strip().splitlines()]
return render_engines
# UI and setup
def get_options(self):
options = [
{'name': 'engine', 'options': self.supported_render_engines()},
]
return options
def perform_presubmission_tasks(self, project_path):
packed_path = self.pack_project_file(project_path, timeout=30)
return packed_path
if __name__ == "__main__":
x = Blender.get_detected_gpus()

View File

@@ -24,7 +24,7 @@ class BlenderRenderWorker(BaseRenderWorker):
self.__frame_percent_complete = 0.0
# Scene Info
self.scene_info = Blender(engine_path).get_scene_info(input_path)
self.scene_info = Blender(engine_path).get_project_info(input_path)
self.start_frame = int(self.scene_info.get('start_frame', 1))
self.end_frame = int(self.scene_info.get('end_frame', self.start_frame))
self.project_length = (self.end_frame - self.start_frame) + 1
@@ -140,7 +140,7 @@ class BlenderRenderWorker(BaseRenderWorker):
if __name__ == '__main__':
import pprint
x = Blender.get_scene_info('/Users/brett/Desktop/TC Previz/nallie_farm.blend')
x = Blender.get_project_info('/Users/brett/Desktop/TC Previz/nallie_farm.blend')
pprint.pprint(x)
# logging.basicConfig(format='%(asctime)s - %(message)s', datefmt='%d-%b-%y %H:%M:%S', level=logging.DEBUG)

View File

@@ -2,6 +2,7 @@ import json
import bpy
# Get all cameras
scene = bpy.data.scenes[0]
cameras = []
for cam_obj in bpy.data.cameras:
user_map = bpy.data.user_map(subset={cam_obj}, value_types={'OBJECT'})
@@ -12,10 +13,10 @@ for cam_obj in bpy.data.cameras:
'lens': cam_obj.lens,
'lens_unit': cam_obj.lens_unit,
'sensor_height': cam_obj.sensor_height,
'sensor_width': cam_obj.sensor_width}
'sensor_width': cam_obj.sensor_width,
'is_active': scene.camera.name_full == cam_obj.name_full}
cameras.append(cam)
scene = bpy.data.scenes[0]
data = {'cameras': cameras,
'engine': scene.render.engine,
'frame_start': scene.frame_start,

View File

@@ -55,6 +55,9 @@ class BaseRenderEngine(object):
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
return help_doc
def get_project_info(self, project_path, timeout=10):
raise NotImplementedError(f"get_project_info not implemented for {cls.__name__}")
@classmethod
def get_output_formats(cls):
raise NotImplementedError(f"get_output_formats not implemented for {cls.__name__}")
@@ -62,3 +65,7 @@ class BaseRenderEngine(object):
@classmethod
def get_arguments(cls):
pass
def perform_presubmission_tasks(self, project_path):
return project_path

View File

@@ -247,6 +247,8 @@ class EngineManager:
for engine in cls.supported_engines():
if extension in engine.supported_extensions:
return engine
undefined_renderer_support = [x for x in cls.supported_engines() if not x.supported_extensions]
return undefined_renderer_support[0]
if __name__ == '__main__':

View File

@@ -1,3 +1,4 @@
import json
import re
from src.engines.core.base_engine import *
@@ -29,6 +30,46 @@ class FFMPEG(BaseRenderEngine):
logger.error("Failed to get FFMPEG version: {}".format(e))
return version
def get_project_info(self, project_path, timeout=10):
return self.get_video_info_ffprobe(project_path)
@staticmethod
def get_video_info_ffprobe(video_path):
try:
# Run ffprobe and parse the output as JSON
cmd = [
'ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_streams', '-select_streams', 'v', video_path
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
video_info = json.loads(result.stdout)
# Extract the necessary information
video_stream = video_info['streams'][0]
frame_rate = eval(video_stream['r_frame_rate'])
duration = float(video_stream['duration'])
width = video_stream['width']
height = video_stream['height']
# Calculate total frames (end frame)
total_frames = int(duration * frame_rate)
end_frame = total_frames - 1
# The start frame is typically 0
start_frame = 0
return {
'frame_start': start_frame,
'frame_end': end_frame,
'fps': frame_rate,
'resolution_x': width,
'resolution_y': height
}
except Exception as e:
print(f"An error occurred: {e}")
return None
def get_encoders(self):
raw_stdout = subprocess.check_output([self.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL,
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')

View File

@@ -1,12 +1,16 @@
import copy
import os.path
import pathlib
import socket
import threading
import psutil
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox,
QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit
QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem
)
from requests import Response
from src.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager
@@ -15,10 +19,20 @@ from src.utilities.zeroconf_server import ZeroconfServer
class NewRenderJobForm(QWidget):
def __init__(self):
def __init__(self, project_path=None):
super().__init__()
self.project_path = project_path
# UI
self.current_engine_options = None
self.file_format_combo = None
self.renderer_options_layout = None
self.cameras_list = None
self.cameras_group = None
self.renderer_version_combo = None
self.worker_thread = None
self.msg_box = None
self.engine_help_viewer = None
self.raw_args = None
self.submit_progress_label = None
@@ -34,21 +48,21 @@ class NewRenderJobForm(QWidget):
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
# Setup
self.setWindowTitle("New Job")
self.setup_ui()
self.setup_project()
# get renderer info in bg thread
t = threading.Thread(target=self.update_renderer_info)
t.start()
@@ -59,39 +73,12 @@ class NewRenderJobForm(QWidget):
# 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_input.setText(self.project_path)
self.scene_file_browse_button = QPushButton("Browse...")
self.scene_file_browse_button.clicked.connect(self.browse_scene_file)
scene_file_picker_layout.addWidget(self.scene_file_input)
@@ -110,10 +97,51 @@ class NewRenderJobForm(QWidget):
scene_file_layout.addLayout(progress_layout)
main_layout.addWidget(scene_file_group)
# Server Group
# Server List
self.server_group = QGroupBox("Server")
server_layout = QVBoxLayout(self.server_group)
server_list_layout = QHBoxLayout()
server_list_layout.setSpacing(0)
self.server_input = QComboBox()
server_list_layout.addWidget(QLabel("Hostname:"), 1)
server_list_layout.addWidget(self.server_input, 3)
server_layout.addLayout(server_list_layout)
main_layout.addWidget(self.server_group)
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)
# Output Settings Group
output_settings_group = QGroupBox("Output Settings")
output_settings_layout = QVBoxLayout(output_settings_group)
frame_range_layout = QHBoxLayout(output_settings_group)
self.output_settings_group = QGroupBox("Output Settings")
output_settings_layout = QVBoxLayout(self.output_settings_group)
# output path
output_path_layout = QHBoxLayout()
output_path_layout.addWidget(QLabel("Render name:"))
self.output_path_input = QLineEdit()
output_path_layout.addWidget(self.output_path_input)
output_settings_layout.addLayout(output_path_layout)
# file format
file_format_layout = QHBoxLayout()
file_format_layout.addWidget(QLabel("Format:"))
self.file_format_combo = QComboBox()
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)
self.start_frame_input = QSpinBox()
self.start_frame_input.setRange(1, 99999)
self.end_frame_input = QSpinBox()
@@ -123,39 +151,70 @@ class NewRenderJobForm(QWidget):
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)
# resolution
resolution_layout = QHBoxLayout(self.output_settings_group)
self.resolution_x_input = QSpinBox()
self.resolution_x_input.setRange(1, 9999) # Assuming max resolution width 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:"))
resolution_layout.addWidget(self.resolution_x_input)
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)
# Renderer Group
renderer_group = QGroupBox("Renderer Settings")
renderer_layout = QVBoxLayout(renderer_group)
self.renderer_group = QGroupBox("Renderer Settings")
renderer_group_layout = QVBoxLayout(self.renderer_group)
renderer_layout = QHBoxLayout()
renderer_layout.addWidget(QLabel("Renderer:"))
self.renderer_type = QComboBox()
self.renderer_type.currentIndexChanged.connect(self.renderer_changed)
renderer_layout.addWidget(self.renderer_type)
# Version
renderer_layout.addWidget(QLabel("Version:"))
self.renderer_version_combo = QComboBox()
renderer_layout.addWidget(self.renderer_version_combo)
renderer_group_layout.addLayout(renderer_layout)
# dynamic options
self.renderer_options_layout = QVBoxLayout()
renderer_group_layout.addLayout(self.renderer_options_layout)
# Raw Args
raw_args_layout = QHBoxLayout(renderer_group)
raw_args_layout = QHBoxLayout(self.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)
renderer_group_layout.addLayout(raw_args_layout)
main_layout.addWidget(self.renderer_group)
# Cameras Group
self.cameras_group = QGroupBox("Cameras")
cameras_layout = QVBoxLayout(self.cameras_group)
self.cameras_list = QListWidget()
self.cameras_group.setHidden(True)
cameras_layout.addWidget(self.cameras_list)
main_layout.addWidget(self.cameras_group)
# Notes Group
notes_group = QGroupBox("Additional Notes")
notes_layout = QVBoxLayout(notes_group)
self.notes_group = QGroupBox("Additional Notes")
notes_layout = QVBoxLayout(self.notes_group)
self.notes_input = QPlainTextEdit()
notes_layout.addWidget(self.notes_input)
main_layout.addWidget(notes_group)
main_layout.addWidget(self.notes_group)
# Submit Button
self.submit_button = QPushButton("Submit Job")
@@ -177,6 +236,17 @@ class NewRenderJobForm(QWidget):
def update_renderer_info(self):
self.renderer_info = self.server_proxy.get_renderer_info()
self.renderer_type.addItems(self.renderer_info.keys())
self.renderer_changed()
def renderer_changed(self):
# load the version numbers
current_renderer = self.renderer_type.currentText().lower() or self.renderer_type.itemText(0)
self.renderer_version_combo.clear()
self.file_format_combo.clear()
if current_renderer:
renderer_vers = [version_info['version'] for version_info in self.renderer_info[current_renderer]['versions']]
self.renderer_version_combo.addItems(renderer_vers)
self.file_format_combo.addItems(self.renderer_info[current_renderer]['supported_export_formats'])
def update_server_list(self):
clients = ZeroconfServer.found_hostnames()
@@ -184,34 +254,26 @@ class NewRenderJobForm(QWidget):
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()
self.setup_project()
def setup_project(self):
# UI stuff on main thread
self.process_progress_bar.setHidden(False)
self.process_label.setHidden(False)
self.toggle_renderer_enablement(False)
output_name, _ = os.path.splitext(os.path.basename(self.scene_file_input.text()))
output_name = output_name.replace(' ', '_')
self.output_path_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.start()
def browse_output_path(self):
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
@@ -226,66 +288,233 @@ class NewRenderJobForm(QWidget):
# -------- 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 post_get_project_info_update(self):
"""Called by the GetProjectInfoWorker - Do not call directly."""
try:
# Set the best renderer we can find
input_path = self.scene_file_input.text()
engine = EngineManager.engine_for_project_path(input_path)
index = self.renderer_type.findText(engine.name().lower())
if index >= 0:
self.renderer_type.setCurrentIndex(index)
self.output_path_input.setText(os.path.basename(input_path))
# cleanup progress UI
self.process_progress_bar.setHidden(True)
self.process_label.setHidden(True)
self.toggle_renderer_enablement(True)
# Load scene data
self.start_frame_input.setValue(self.project_info.get('frame_start'))
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'))
# Cameras
self.cameras_list.clear()
if self.project_info.get('cameras'):
self.cameras_group.setHidden(False)
found_active = False
for camera in self.project_info['cameras']:
# create the list items and make them checkable
item = QListWidgetItem(f"{camera['name']} - {camera['lens']}mm")
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
is_checked = camera['is_active'] or len(self.project_info['cameras']) == 1
found_active = found_active or is_checked
item.setCheckState(Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked)
self.cameras_list.addItem(item)
if not found_active:
self.cameras_list.item(0).setCheckState(Qt.CheckState.Checked)
else:
self.cameras_group.setHidden(True)
# Dynamic Engine Options
engine_name = self.renderer_type.currentText()
engine = EngineManager.engine_with_name(engine_name)
# clear old options
clear_layout(self.renderer_options_layout)
# dynamically populate option list
self.current_engine_options = engine().get_options()
for option in self.current_engine_options:
h_layout = QHBoxLayout()
label = QLabel(option['name'].capitalize() + ':')
h_layout.addWidget(label)
if option.get('options'):
combo_box = QComboBox()
for opt in option['options']:
combo_box.addItem(opt)
h_layout.addWidget(combo_box)
else:
text_box = QLineEdit()
h_layout.addWidget(text_box)
self.renderer_options_layout.addLayout(h_layout)
except AttributeError as e:
pass
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)
"""Toggle on/off all the render settings"""
self.server_group.setHidden(not enabled)
self.output_settings_group.setHidden(not enabled)
self.renderer_group.setHidden(not enabled)
self.notes_group.setHidden(not enabled)
if not enabled:
self.cameras_group.setHidden(True)
self.submit_button.setEnabled(enabled)
def after_job_submission(self, result):
# UI cleanup
self.submit_progress.setMaximum(0)
self.submit_button.setHidden(False)
self.submit_progress.setHidden(True)
self.submit_progress_label.setHidden(True)
self.process_progress_bar.setHidden(True)
self.process_label.setHidden(True)
self.toggle_renderer_enablement(True)
self.msg_box = QMessageBox()
if result.ok:
self.msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
self.msg_box.setIcon(QMessageBox.Icon.Information)
self.msg_box.setText("Job successfully submitted to server. Submit another?")
self.msg_box.setWindowTitle("Success")
x = self.msg_box.exec()
if x == QMessageBox.StandardButton.No:
self.close()
else:
self.msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
self.msg_box.setIcon(QMessageBox.Icon.Critical)
self.msg_box.setText(result.text or "Unknown error")
self.msg_box.setWindowTitle("Error")
self.msg_box.exec()
# -------- 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
# Pre-worker UI
self.submit_progress.setHidden(False)
self.submit_progress_label.setHidden(False)
self.submit_button.setHidden(True)
self.submit_progress.setMaximum(0)
self.submit_progress.setHidden(False)
self.submit_progress_label.setHidden(False)
self.submit_button.setHidden(True)
# submit job in background thread
self.worker_thread = SubmitWorker(window=self)
self.worker_thread.message_signal.connect(self.after_job_submission)
self.worker_thread.start()
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()
class SubmitWorker(QThread):
"""Worker class called to submit all the jobs to the server and update the UI accordingly"""
message_signal = pyqtSignal(Response)
def __init__(self, window):
super().__init__()
self.window = window
def run(self):
def create_callback(encoder):
encoder_len = encoder.len
def callback(monitor):
percent = f"{monitor.bytes_read / encoder_len * 100:.0f}"
self.window.submit_progress_label.setText(f"Transferring to {hostname} - {percent}%")
self.window.submit_progress.setMaximum(100)
self.window.submit_progress.setValue(int(percent))
return callback
hostname = self.window.server_input.currentText()
job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(),
'renderer': self.window.renderer_type.currentText().lower(),
'renderer_version': self.window.renderer_version_combo.currentText(),
'args': {'raw': self.window.raw_args.text()},
'output_path': self.window.output_path_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()}
# get the dynamic args
for i in range(self.window.renderer_options_layout.count()):
item = self.window.renderer_options_layout.itemAt(i)
layout = item.layout() # get the layout
for x in range(layout.count()):
z = layout.itemAt(x)
widget = z.widget()
if isinstance(widget, QComboBox):
job_json['args'][self.window.current_engine_options[i]['name']] = widget.currentText()
elif isinstance(widget, QLineEdit):
job_json['args'][self.window.current_engine_options[i]['name']] = widget.text()
# determine if any cameras are checked
selected_cameras = []
if self.window.cameras_list.count() and not self.window.cameras_group.isHidden():
for index in range(self.window.cameras_list.count()):
item = self.window.cameras_list.item(index)
if item.checkState() == Qt.CheckState.Checked:
selected_cameras.append(item.text().rsplit('-', 1)[0].strip()) # cleanup to just camera name
# process cameras into nested format
input_path = self.window.scene_file_input.text()
if selected_cameras:
job_list = []
for cam in selected_cameras:
job_copy = copy.deepcopy(job_json)
job_copy['args']['camera'] = cam
job_copy['name'] = pathlib.Path(input_path).stem.replace(' ', '_') + "-" + cam.replace(' ', '')
job_list.append(job_copy)
else:
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)
# todo: error handle
print(result.json())
self.submit_button.setHidden(False)
self.submit_progress.setHidden(True)
self.submit_progress_label.setHidden(True)
# presubmission tasks
engine = EngineManager.engine_with_name(self.window.renderer_type.currentText().lower())
input_path = engine().perform_presubmission_tasks(input_path)
# submit
result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list,
callback=create_callback)
self.message_signal.emit(result)
class GetProjectInfoWorker(QThread):
"""Worker class called to retrieve information about a project file on a background thread and update the UI"""
# submit thread
worker_thread = threading.Thread(target=submit_job_worker)
worker_thread.start()
message_signal = pyqtSignal()
def __init__(self, window, project_path):
super().__init__()
self.window = window
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()
def clear_layout(layout):
if layout is not None:
# Go through the layout's items in reverse order
for i in reversed(range(layout.count())):
# Take the item at the current position
item = layout.takeAt(i)
# Check if the item is a widget
if item.widget():
# Remove the widget and delete it
widget_to_remove = item.widget()
widget_to_remove.setParent(None)
widget_to_remove.deleteLater()
elif item.layout():
# If the item is a sub-layout, clear its contents recursively
clear_layout(item.layout())
# Then delete the layout
item.layout().deleteLater()
# Run the application
if __name__ == '__main__':

View File

@@ -11,6 +11,7 @@ from PyQt6.QtWidgets import (
from src.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager
from src.utilities.misc_helper import is_localhost
class EngineBrowserWindow(QMainWindow):
@@ -84,7 +85,7 @@ class EngineBrowserWindow(QMainWindow):
def update_table(self):
def update_table_worker():
raw_server_data = RenderServerProxy(self.hostname).get_renderer_info(simple=True)
raw_server_data = RenderServerProxy(self.hostname).get_renderer_info()
if not raw_server_data:
return
@@ -116,15 +117,16 @@ class EngineBrowserWindow(QMainWindow):
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())
self.launch_button.setEnabled(is_localhost(self.hostname))
def update_download_status(self):
hide_progress = not bool(EngineManager.download_tasks)
running_tasks = [x for x in EngineManager.download_tasks if x.is_alive()]
hide_progress = not bool(running_tasks)
self.progress_bar.setHidden(hide_progress)
self.progress_label.setHidden(hide_progress)
# todo: update progress bar with status
self.progress_label.setText(f"Downloading {len(EngineManager.download_tasks)} engines")
self.progress_label.setText(f"Downloading {len(running_tasks)} engines")
def launch_button_click(self):
engine_info = self.engine_data[self.table_widget.currentRow()]

View File

@@ -12,11 +12,12 @@ 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
QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem, \
QFileDialog
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.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 .add_job import NewRenderJobForm
@@ -292,7 +293,7 @@ class MainWindow(QMainWindow):
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()
local_server = is_localhost(self.current_hostname)
if job_id:
fetch_thread = threading.Thread(target=fetch_preview, args=(job_id,))
@@ -380,11 +381,12 @@ class MainWindow(QMainWindow):
def update_servers(self):
found_servers = list(set(ZeroconfServer.found_hostnames() + 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)
if found_servers and not is_localhost(found_servers[0]):
for hostname in found_servers:
if is_localhost(hostname):
found_servers.remove(hostname)
found_servers.insert(0, hostname)
break
old_count = self.server_list_view.count()
@@ -555,5 +557,8 @@ class MainWindow(QMainWindow):
raise OSError("Unsupported operating system")
def new_job(self) -> None:
self.new_job_window = NewRenderJobForm()
self.new_job_window.show()
file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
if file_name:
self.new_job_window = NewRenderJobForm(file_name)
self.new_job_window.show()

View File

@@ -1,6 +1,7 @@
import logging
import os
import platform
import socket
import subprocess
from datetime import datetime
@@ -135,3 +136,13 @@ 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
def is_localhost(comparison_hostname):
# this is necessary because socket.gethostname() does not always include '.local' - This is a sanitized comparison
try:
comparison_hostname = comparison_hostname.lower().replace('.local', '')
local_hostname = socket.gethostname().lower().replace('.local', '')
return comparison_hostname == local_hostname
except AttributeError:
return False

View File

@@ -1,8 +1,8 @@
import logging
import socket
import zeroconf
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange
from pubsub import pub
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange, NonUniqueNameException
logger = logging.getLogger()
@@ -52,7 +52,7 @@ class ZeroconfServer:
cls.service_info = info
cls.zeroconf.register_service(info)
logger.info(f"Registered zeroconf service: {cls.service_info.name}")
except zeroconf.NonUniqueNameException as e:
except NonUniqueNameException as e:
logger.error(f"Error establishing zeroconf: {e}")
@classmethod
@@ -76,6 +76,7 @@ class ZeroconfServer:
cls.client_cache[name] = info
else:
cls.client_cache.pop(name)
pub.sendMessage('zeroconf_state_change', hostname=name, state_change=state_change, info=info)
@classmethod
def found_hostnames(cls):