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

View File

@@ -8,6 +8,7 @@ import time
import requests import requests
from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor
from src.utilities.misc_helper import is_localhost
from src.utilities.status_utils import RenderStatus from src.utilities.status_utils import RenderStatus
status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green', 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): def post_job_to_server(self, file_path, job_list, callback=None):
# bypass uploading file if posting to localhost # 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] jobs_with_path = [{**item, "local_path": file_path} for item in job_list]
return requests.post(f'http://{self.hostname}:{self.port}/api/add_job', data=json.dumps(jobs_with_path), return requests.post(f'http://{self.hostname}:{self.port}/api/add_job', data=json.dumps(jobs_with_path),
headers={'Content-Type': 'application/json'}) headers={'Content-Type': 'application/json'})
@@ -181,8 +182,8 @@ class RenderServerProxy:
# --- Renderer --- # # --- Renderer --- #
def get_renderer_info(self, timeout=5, simple=False): def get_renderer_info(self, timeout=5):
all_data = self.request_data(f'renderer_info?simple={simple}', timeout=timeout) all_data = self.request_data(f'renderer_info', timeout=timeout)
return all_data return all_data
def delete_engine(self, engine, version, system_cpu=None): 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 from src.api.server_proxy import RenderServerProxy
@@ -5,6 +8,21 @@ class ServerProxyManager:
server_proxys = {} 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 @classmethod
def get_proxy_for_hostname(cls, hostname): def get_proxy_for_hostname(cls, hostname):
found_proxy = cls.server_proxys.get(hostname) found_proxy = cls.server_proxys.get(hostname)
@@ -14,3 +32,4 @@ class ServerProxyManager:
cls.server_proxys[hostname] = new_proxy cls.server_proxys[hostname] = new_proxy
found_proxy = new_proxy found_proxy = new_proxy
return found_proxy return found_proxy

View File

@@ -22,7 +22,7 @@ class DistributedJobManager:
pass pass
@classmethod @classmethod
def start(cls): def subscribe_to_listener(cls):
""" """
Subscribes the private class method '__local_job_status_changed' to the 'status_change' pubsub message. 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. 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 FileNotFoundError(f'Python script not found: {script_path}')
raise Exception("Uncaught exception") raise Exception("Uncaught exception")
def get_scene_info(self, project_path, timeout=10): def get_project_info(self, project_path, timeout=10):
scene_info = {} scene_info = {}
try: try:
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_file_info.py') 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 return options
def get_detected_gpus(self): def get_detected_gpus(self):
# no longer works on 4.0
engine_output = subprocess.run([self.renderer_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT, engine_output = subprocess.run([self.renderer_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT,
capture_output=True).stdout.decode('utf-8') capture_output=True).stdout.decode('utf-8')
gpu_names = re.findall(r"DETECTED GPU: (.+)", engine_output) 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()] render_engines = [x.strip() for x in engine_output.split('Blender Engine Listing:')[-1].strip().splitlines()]
return render_engines 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__": if __name__ == "__main__":
x = Blender.get_detected_gpus() x = Blender.get_detected_gpus()

View File

@@ -24,7 +24,7 @@ class BlenderRenderWorker(BaseRenderWorker):
self.__frame_percent_complete = 0.0 self.__frame_percent_complete = 0.0
# Scene Info # 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.start_frame = int(self.scene_info.get('start_frame', 1))
self.end_frame = int(self.scene_info.get('end_frame', self.start_frame)) self.end_frame = int(self.scene_info.get('end_frame', self.start_frame))
self.project_length = (self.end_frame - self.start_frame) + 1 self.project_length = (self.end_frame - self.start_frame) + 1
@@ -140,7 +140,7 @@ class BlenderRenderWorker(BaseRenderWorker):
if __name__ == '__main__': if __name__ == '__main__':
import pprint 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) pprint.pprint(x)
# logging.basicConfig(format='%(asctime)s - %(message)s', datefmt='%d-%b-%y %H:%M:%S', level=logging.DEBUG) # 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 import bpy
# Get all cameras # Get all cameras
scene = bpy.data.scenes[0]
cameras = [] cameras = []
for cam_obj in bpy.data.cameras: for cam_obj in bpy.data.cameras:
user_map = bpy.data.user_map(subset={cam_obj}, value_types={'OBJECT'}) 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': cam_obj.lens,
'lens_unit': cam_obj.lens_unit, 'lens_unit': cam_obj.lens_unit,
'sensor_height': cam_obj.sensor_height, '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) cameras.append(cam)
scene = bpy.data.scenes[0]
data = {'cameras': cameras, data = {'cameras': cameras,
'engine': scene.render.engine, 'engine': scene.render.engine,
'frame_start': scene.frame_start, 'frame_start': scene.frame_start,

View File

@@ -55,6 +55,9 @@ class BaseRenderEngine(object):
timeout=SUBPROCESS_TIMEOUT).decode('utf-8') timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
return help_doc return help_doc
def get_project_info(self, project_path, timeout=10):
raise NotImplementedError(f"get_project_info not implemented for {cls.__name__}")
@classmethod @classmethod
def get_output_formats(cls): def get_output_formats(cls):
raise NotImplementedError(f"get_output_formats not implemented for {cls.__name__}") raise NotImplementedError(f"get_output_formats not implemented for {cls.__name__}")
@@ -62,3 +65,7 @@ class BaseRenderEngine(object):
@classmethod @classmethod
def get_arguments(cls): def get_arguments(cls):
pass 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(): for engine in cls.supported_engines():
if extension in engine.supported_extensions: if extension in engine.supported_extensions:
return engine 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__': if __name__ == '__main__':

View File

@@ -1,3 +1,4 @@
import json
import re import re
from src.engines.core.base_engine import * from src.engines.core.base_engine import *
@@ -29,6 +30,46 @@ class FFMPEG(BaseRenderEngine):
logger.error("Failed to get FFMPEG version: {}".format(e)) logger.error("Failed to get FFMPEG version: {}".format(e))
return version 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): def get_encoders(self):
raw_stdout = subprocess.check_output([self.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL, raw_stdout = subprocess.check_output([self.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL,
timeout=SUBPROCESS_TIMEOUT).decode('utf-8') timeout=SUBPROCESS_TIMEOUT).decode('utf-8')

View File

@@ -1,12 +1,16 @@
import copy
import os.path import os.path
import pathlib
import socket import socket
import threading import threading
import psutil import psutil
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox, 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.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager from src.engines.engine_manager import EngineManager
@@ -15,10 +19,20 @@ from src.utilities.zeroconf_server import ZeroconfServer
class NewRenderJobForm(QWidget): class NewRenderJobForm(QWidget):
def __init__(self): def __init__(self, project_path=None):
super().__init__() super().__init__()
self.project_path = project_path
# UI # 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.engine_help_viewer = None
self.raw_args = None self.raw_args = None
self.submit_progress_label = None self.submit_progress_label = None
@@ -34,21 +48,21 @@ class NewRenderJobForm(QWidget):
self.priority_input = None self.priority_input = None
self.end_frame_input = None self.end_frame_input = None
self.start_frame_input = None self.start_frame_input = None
self.output_path_browse_button = None
self.output_path_input = None self.output_path_input = None
self.scene_file_input = None self.scene_file_input = None
self.scene_file_browse_button = None self.scene_file_browse_button = None
self.job_name_input = None self.job_name_input = None
# Setup
self.setWindowTitle("New Job")
self.setup_ui()
# Job / Server Data # Job / Server Data
self.server_proxy = RenderServerProxy(socket.gethostname()) self.server_proxy = RenderServerProxy(socket.gethostname())
self.renderer_info = None self.renderer_info = None
self.project_info = None self.project_info = None
# Setup
self.setWindowTitle("New Job")
self.setup_ui()
self.setup_project()
# get renderer info in bg thread # get renderer info in bg thread
t = threading.Thread(target=self.update_renderer_info) t = threading.Thread(target=self.update_renderer_info)
t.start() t.start()
@@ -59,39 +73,12 @@ class NewRenderJobForm(QWidget):
# Main Layout # Main Layout
main_layout = QVBoxLayout(self) 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
scene_file_group = QGroupBox("Project") scene_file_group = QGroupBox("Project")
scene_file_layout = QVBoxLayout(scene_file_group) scene_file_layout = QVBoxLayout(scene_file_group)
scene_file_picker_layout = QHBoxLayout() scene_file_picker_layout = QHBoxLayout()
self.scene_file_input = QLineEdit() 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 = QPushButton("Browse...")
self.scene_file_browse_button.clicked.connect(self.browse_scene_file) 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_input)
@@ -110,10 +97,51 @@ class NewRenderJobForm(QWidget):
scene_file_layout.addLayout(progress_layout) scene_file_layout.addLayout(progress_layout)
main_layout.addWidget(scene_file_group) 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
output_settings_group = QGroupBox("Output Settings") self.output_settings_group = QGroupBox("Output Settings")
output_settings_layout = QVBoxLayout(output_settings_group) output_settings_layout = QVBoxLayout(self.output_settings_group)
frame_range_layout = QHBoxLayout(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 = QSpinBox()
self.start_frame_input.setRange(1, 99999) self.start_frame_input.setRange(1, 99999)
self.end_frame_input = QSpinBox() self.end_frame_input = QSpinBox()
@@ -123,39 +151,70 @@ class NewRenderJobForm(QWidget):
frame_range_layout.addWidget(QLabel("to")) frame_range_layout.addWidget(QLabel("to"))
frame_range_layout.addWidget(self.end_frame_input) frame_range_layout.addWidget(self.end_frame_input)
output_settings_layout.addLayout(frame_range_layout) output_settings_layout.addLayout(frame_range_layout)
# output path # resolution
output_path_layout = QHBoxLayout() resolution_layout = QHBoxLayout(self.output_settings_group)
output_path_layout.addWidget(QLabel("Render name:")) self.resolution_x_input = QSpinBox()
self.output_path_input = QLineEdit() self.resolution_x_input.setRange(1, 9999) # Assuming max resolution width 9999
# self.output_path_browse_button = QPushButton("Browse...") self.resolution_x_input.setValue(1920)
# self.output_path_browse_button.clicked.connect(self.browse_output_path) self.resolution_y_input = QSpinBox()
output_path_layout.addWidget(self.output_path_input) self.resolution_y_input.setRange(1, 9999) # Assuming max resolution height 9999
output_path_layout.addWidget(self.output_path_browse_button) self.resolution_y_input.setValue(1080)
output_settings_layout.addLayout(output_path_layout) self.frame_rate_input = QDoubleSpinBox()
main_layout.addWidget(output_settings_group) 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
renderer_group = QGroupBox("Renderer Settings") self.renderer_group = QGroupBox("Renderer Settings")
renderer_layout = QVBoxLayout(renderer_group) renderer_group_layout = QVBoxLayout(self.renderer_group)
renderer_layout = QHBoxLayout()
renderer_layout.addWidget(QLabel("Renderer:"))
self.renderer_type = QComboBox() self.renderer_type = QComboBox()
self.renderer_type.currentIndexChanged.connect(self.renderer_changed)
renderer_layout.addWidget(self.renderer_type) 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
raw_args_layout = QHBoxLayout(renderer_group) raw_args_layout = QHBoxLayout(self.renderer_group)
raw_args_layout.addWidget(QLabel("Raw Args:")) raw_args_layout.addWidget(QLabel("Raw Args:"))
self.raw_args = QLineEdit() self.raw_args = QLineEdit()
raw_args_layout.addWidget(self.raw_args) raw_args_layout.addWidget(self.raw_args)
args_help_button = QPushButton("?") args_help_button = QPushButton("?")
args_help_button.clicked.connect(self.args_help_button_clicked) args_help_button.clicked.connect(self.args_help_button_clicked)
raw_args_layout.addWidget(args_help_button) raw_args_layout.addWidget(args_help_button)
renderer_layout.addLayout(raw_args_layout) renderer_group_layout.addLayout(raw_args_layout)
main_layout.addWidget(renderer_group) 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
notes_group = QGroupBox("Additional Notes") self.notes_group = QGroupBox("Additional Notes")
notes_layout = QVBoxLayout(notes_group) notes_layout = QVBoxLayout(self.notes_group)
self.notes_input = QPlainTextEdit() self.notes_input = QPlainTextEdit()
notes_layout.addWidget(self.notes_input) notes_layout.addWidget(self.notes_input)
main_layout.addWidget(notes_group) main_layout.addWidget(self.notes_group)
# Submit Button # Submit Button
self.submit_button = QPushButton("Submit Job") self.submit_button = QPushButton("Submit Job")
@@ -177,6 +236,17 @@ class NewRenderJobForm(QWidget):
def update_renderer_info(self): def update_renderer_info(self):
self.renderer_info = self.server_proxy.get_renderer_info() self.renderer_info = self.server_proxy.get_renderer_info()
self.renderer_type.addItems(self.renderer_info.keys()) 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): def update_server_list(self):
clients = ZeroconfServer.found_hostnames() clients = ZeroconfServer.found_hostnames()
@@ -184,34 +254,26 @@ class NewRenderJobForm(QWidget):
self.server_input.addItems(clients) self.server_input.addItems(clients)
def browse_scene_file(self): 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") file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
if file_name: if file_name:
self.scene_file_input.setText(file_name) self.scene_file_input.setText(file_name)
# analyze the file self.setup_project()
update_thread = threading.Thread(target=get_project_info)
update_thread.start()
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): def browse_output_path(self):
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory") directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
@@ -226,66 +288,233 @@ class NewRenderJobForm(QWidget):
# -------- Update -------- # -------- Update --------
def update_project_ui(self): def post_get_project_info_update(self):
self.start_frame_input.setValue(self.project_info.get('frame_start')) """Called by the GetProjectInfoWorker - Do not call directly."""
self.end_frame_input.setValue(self.project_info.get('frame_end')) 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): def toggle_renderer_enablement(self, enabled=False):
self.start_frame_input.setEnabled(enabled) """Toggle on/off all the render settings"""
self.end_frame_input.setEnabled(enabled) self.server_group.setHidden(not enabled)
self.notes_input.setEnabled(enabled) self.output_settings_group.setHidden(not enabled)
self.output_path_input.setEnabled(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) 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 -------- # -------- Submit Job Calls --------
def submit_job(self): 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) # submit job in background thread
self.submit_progress_label.setHidden(False) self.worker_thread = SubmitWorker(window=self)
self.submit_button.setHidden(True) 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] 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 # presubmission tasks
print(result.json()) engine = EngineManager.engine_with_name(self.window.renderer_type.currentText().lower())
self.submit_button.setHidden(False) input_path = engine().perform_presubmission_tasks(input_path)
self.submit_progress.setHidden(True) # submit
self.submit_progress_label.setHidden(True) 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 message_signal = pyqtSignal()
worker_thread = threading.Thread(target=submit_job_worker)
worker_thread.start() 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 # Run the application
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -11,6 +11,7 @@ from PyQt6.QtWidgets import (
from src.api.server_proxy import RenderServerProxy from src.api.server_proxy import RenderServerProxy
from src.engines.engine_manager import EngineManager from src.engines.engine_manager import EngineManager
from src.utilities.misc_helper import is_localhost
class EngineBrowserWindow(QMainWindow): class EngineBrowserWindow(QMainWindow):
@@ -84,7 +85,7 @@ class EngineBrowserWindow(QMainWindow):
def update_table(self): def update_table(self):
def update_table_worker(): 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: if not raw_server_data:
return return
@@ -116,15 +117,16 @@ class EngineBrowserWindow(QMainWindow):
def engine_picked(self): def engine_picked(self):
engine_info = self.engine_data[self.table_widget.currentRow()] engine_info = self.engine_data[self.table_widget.currentRow()]
self.delete_button.setEnabled(engine_info['type'] == 'managed') 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): 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_bar.setHidden(hide_progress)
self.progress_label.setHidden(hide_progress) self.progress_label.setHidden(hide_progress)
# todo: update progress bar with status # 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): def launch_button_click(self):
engine_info = self.engine_data[self.table_widget.currentRow()] 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.QtCore import Qt, QByteArray, QBuffer, QIODevice, QThread
from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon
from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QListWidget, QTableWidget, QAbstractItemView, \ 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.api.server_proxy import RenderServerProxy
from src.render_queue import RenderQueue 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.status_utils import RenderStatus
from src.utilities.zeroconf_server import ZeroconfServer from src.utilities.zeroconf_server import ZeroconfServer
from .add_job import NewRenderJobForm from .add_job import NewRenderJobForm
@@ -292,7 +293,7 @@ class MainWindow(QMainWindow):
logger.error(f"Error fetching image: {e}") logger.error(f"Error fetching image: {e}")
job_id = self.selected_job_ids()[0] if self.selected_job_ids() else None 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: if job_id:
fetch_thread = threading.Thread(target=fetch_preview, args=(job_id,)) fetch_thread = threading.Thread(target=fetch_preview, args=(job_id,))
@@ -380,11 +381,12 @@ class MainWindow(QMainWindow):
def update_servers(self): def update_servers(self):
found_servers = list(set(ZeroconfServer.found_hostnames() + self.added_hostnames)) found_servers = list(set(ZeroconfServer.found_hostnames() + self.added_hostnames))
# Always make sure local hostname is first # Always make sure local hostname is first
current_hostname = socket.gethostname() if found_servers and not is_localhost(found_servers[0]):
if found_servers and found_servers[0] != current_hostname: for hostname in found_servers:
if current_hostname in found_servers: if is_localhost(hostname):
found_servers.remove(current_hostname) found_servers.remove(hostname)
found_servers.insert(0, current_hostname) found_servers.insert(0, hostname)
break
old_count = self.server_list_view.count() old_count = self.server_list_view.count()
@@ -555,5 +557,8 @@ class MainWindow(QMainWindow):
raise OSError("Unsupported operating system") raise OSError("Unsupported operating system")
def new_job(self) -> None: 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 logging
import os import os
import platform import platform
import socket
import subprocess import subprocess
from datetime import datetime 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_directory = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'config') 'config')
return config_directory 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 logging
import socket import socket
import zeroconf from pubsub import pub
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange, NonUniqueNameException
logger = logging.getLogger() logger = logging.getLogger()
@@ -52,7 +52,7 @@ 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 zeroconf.NonUniqueNameException as e: except NonUniqueNameException as e:
logger.error(f"Error establishing zeroconf: {e}") logger.error(f"Error establishing zeroconf: {e}")
@classmethod @classmethod
@@ -76,6 +76,7 @@ class ZeroconfServer:
cls.client_cache[name] = info cls.client_cache[name] = info
else: else:
cls.client_cache.pop(name) cls.client_cache.pop(name)
pub.sendMessage('zeroconf_state_change', hostname=name, state_change=state_change, info=info)
@classmethod @classmethod
def found_hostnames(cls): def found_hostnames(cls):