diff --git a/src/api/api_server.py b/src/api/api_server.py index b3e4e98..900bf05 100755 --- a/src/api/api_server.py +++ b/src/api/api_server.py @@ -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() diff --git a/src/api/server_proxy.py b/src/api/server_proxy.py index 043f0c6..e46cd92 100644 --- a/src/api/server_proxy.py +++ b/src/api/server_proxy.py @@ -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): diff --git a/src/api/serverproxy_manager.py b/src/api/serverproxy_manager.py index a6b45ba..da67268 100644 --- a/src/api/serverproxy_manager.py +++ b/src/api/serverproxy_manager.py @@ -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 + diff --git a/src/distributed_job_manager.py b/src/distributed_job_manager.py index 1abf944..a90dbc1 100644 --- a/src/distributed_job_manager.py +++ b/src/distributed_job_manager.py @@ -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. diff --git a/src/engines/blender/blender_engine.py b/src/engines/blender/blender_engine.py index 3066867..274259b 100644 --- a/src/engines/blender/blender_engine.py +++ b/src/engines/blender/blender_engine.py @@ -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() diff --git a/src/engines/blender/blender_worker.py b/src/engines/blender/blender_worker.py index 7c10a88..2e53a10 100644 --- a/src/engines/blender/blender_worker.py +++ b/src/engines/blender/blender_worker.py @@ -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) diff --git a/src/engines/blender/scripts/get_file_info.py b/src/engines/blender/scripts/get_file_info.py index c7c0900..614b636 100644 --- a/src/engines/blender/scripts/get_file_info.py +++ b/src/engines/blender/scripts/get_file_info.py @@ -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, diff --git a/src/engines/core/base_engine.py b/src/engines/core/base_engine.py index c09d56b..cfff998 100644 --- a/src/engines/core/base_engine.py +++ b/src/engines/core/base_engine.py @@ -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 + diff --git a/src/engines/engine_manager.py b/src/engines/engine_manager.py index bb96a7c..1f91072 100644 --- a/src/engines/engine_manager.py +++ b/src/engines/engine_manager.py @@ -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__': diff --git a/src/engines/ffmpeg/ffmpeg_engine.py b/src/engines/ffmpeg/ffmpeg_engine.py index 1908f87..65d911e 100644 --- a/src/engines/ffmpeg/ffmpeg_engine.py +++ b/src/engines/ffmpeg/ffmpeg_engine.py @@ -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') diff --git a/src/ui/add_job.py b/src/ui/add_job.py index a734905..115cac7 100644 --- a/src/ui/add_job.py +++ b/src/ui/add_job.py @@ -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__': diff --git a/src/ui/engine_browser.py b/src/ui/engine_browser.py index 5c14132..0b169f2 100644 --- a/src/ui/engine_browser.py +++ b/src/ui/engine_browser.py @@ -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()] diff --git a/src/ui/main_window.py b/src/ui/main_window.py index f26d5c2..5caaa3e 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -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() diff --git a/src/utilities/misc_helper.py b/src/utilities/misc_helper.py index d809a79..25f3c0d 100644 --- a/src/utilities/misc_helper.py +++ b/src/utilities/misc_helper.py @@ -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 diff --git a/src/utilities/zeroconf_server.py b/src/utilities/zeroconf_server.py index acd8c91..3a95d5a 100644 --- a/src/utilities/zeroconf_server.py +++ b/src/utilities/zeroconf_server.py @@ -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):