import os.path import socket import psutil from PyQt6.QtCore import QThread, pyqtSignal, Qt, pyqtSlot from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox, QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem, QTabWidget ) from src.api.server_proxy import RenderServerProxy from src.engines.engine_manager import EngineManager from src.ui.engine_help_window import EngineHelpViewer from src.utilities.zeroconf_server import ZeroconfServer from src.utilities.misc_helper import COMMON_RESOLUTIONS, COMMON_FRAME_RATES class NewRenderJobForm(QWidget): def __init__(self, project_path=None): super().__init__() self.resolution_options_list = None self.resolution_x_input = None self.resolution_y_input = None self.fps_options_list = None self.fps_input = None self.engine_group = None self.notes_group = None self.output_settings_group = None self.project_path = project_path # UI self.project_group = None self.load_file_group = None self.current_engine_options = None self.file_format_combo = None self.engine_options_layout = None self.cameras_list = None self.cameras_group = None self.engine_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 self.submit_progress = None self.engine_type = None self.process_label = None self.process_progress_bar = None self.splitjobs_same_os = None self.enable_splitjobs = None self.server_input = None self.submit_button = None self.notes_input = None self.priority_input = None self.end_frame_input = None self.start_frame_input = None self.job_name_input = None self.scene_file_input = None self.scene_file_browse_button = None self.tabs = None # Job / Server Data self.server_proxy = RenderServerProxy(socket.gethostname()) self.project_info = None self.installed_engines = {} self.preferred_engine = None # Setup self.setWindowTitle("New Job") self.setup_ui() self.setup_project() self.show() def setup_ui(self): # Main widget layout main_layout = QVBoxLayout(self) # Tabs self.tabs = QTabWidget() # ==================== Loading Section (outside tabs) ==================== self.load_file_group = QGroupBox("Loading") load_file_layout = QVBoxLayout(self.load_file_group) progress_layout = QHBoxLayout() self.process_label = QLabel("Processing") self.process_progress_bar = QProgressBar() self.process_progress_bar.setMinimum(0) self.process_progress_bar.setMaximum(0) # Indeterminate progress_layout.addWidget(self.process_label) progress_layout.addWidget(self.process_progress_bar) load_file_layout.addLayout(progress_layout) # Scene File job_overview_group = QGroupBox("Project File") file_group_layout = QVBoxLayout(job_overview_group) # Job Name job_name_layout = QHBoxLayout() job_name_layout.addWidget(QLabel("Job name:")) self.job_name_input = QLineEdit() job_name_layout.addWidget(self.job_name_input) self.engine_type = QComboBox() job_name_layout.addWidget(self.engine_type) file_group_layout.addLayout(job_name_layout) # Job File scene_file_picker_layout = QHBoxLayout() scene_file_picker_layout.addWidget(QLabel("File:")) 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) scene_file_picker_layout.addWidget(self.scene_file_browse_button) file_group_layout.addLayout(scene_file_picker_layout) main_layout.addWidget(job_overview_group) main_layout.addWidget(self.load_file_group) main_layout.addWidget(self.tabs) # ==================== Tab 1: Job Settings ==================== self.project_group = QWidget() project_layout = QVBoxLayout(self.project_group) # Fixed: proper parent # Server / Hostname server_list_layout = QHBoxLayout() server_list_layout.addWidget(QLabel("Render Target:")) self.server_input = QComboBox() server_list_layout.addWidget(self.server_input) project_layout.addLayout(server_list_layout) # Priority priority_layout = QHBoxLayout() priority_layout.addWidget(QLabel("Priority:")) self.priority_input = QComboBox() self.priority_input.addItems(["High", "Medium", "Low"]) self.priority_input.setCurrentIndex(1) priority_layout.addWidget(self.priority_input) project_layout.addLayout(priority_layout) # Split Jobs Options self.enable_splitjobs = QCheckBox("Automatically split render across multiple servers") project_layout.addWidget(self.enable_splitjobs) self.splitjobs_same_os = QCheckBox("Only render on same OS") project_layout.addWidget(self.splitjobs_same_os) project_layout.addStretch() # Push everything up # ==================== Tab 2: Output Settings ==================== self.output_settings_group = QWidget() output_settings_layout = QVBoxLayout(self.output_settings_group) # File Format format_group = QGroupBox("Format / Range") output_settings_layout.addWidget(format_group) format_group_layout = QVBoxLayout() format_group.setLayout(format_group_layout) file_format_layout = QHBoxLayout() file_format_layout.addWidget(QLabel("Format:")) self.file_format_combo = QComboBox() self.file_format_combo.setFixedWidth(200) file_format_layout.addWidget(self.file_format_combo) file_format_layout.addStretch() format_group_layout.addLayout(file_format_layout) # Frame Range frame_range_layout = QHBoxLayout() frame_range_layout.addWidget(QLabel("Frames:")) self.start_frame_input = QSpinBox() self.start_frame_input.setRange(1, 99999) self.start_frame_input.setFixedWidth(80) self.end_frame_input = QSpinBox() self.end_frame_input.setRange(1, 99999) self.end_frame_input.setFixedWidth(80) frame_range_layout.addWidget(self.start_frame_input) frame_range_layout.addWidget(QLabel("to")) frame_range_layout.addWidget(self.end_frame_input) frame_range_layout.addStretch() format_group_layout.addLayout(frame_range_layout) # --- Resolution & FPS Group --- resolution_group = QGroupBox("Resolution / Frame Rate") output_settings_layout.addWidget(resolution_group) resolution_group_layout = QVBoxLayout() resolution_group.setLayout(resolution_group_layout) # Resolution resolution_layout = QHBoxLayout() self.resolution_options_list = QComboBox() self.resolution_options_list.setFixedWidth(200) self.resolution_options_list.addItem("Original Size") for res in COMMON_RESOLUTIONS: self.resolution_options_list.addItem(res) self.resolution_options_list.currentIndexChanged.connect(self._resolution_preset_changed) resolution_layout.addWidget(self.resolution_options_list) resolution_group_layout.addLayout(resolution_layout) self.resolution_x_input = QSpinBox() self.resolution_x_input.setRange(1, 9999) self.resolution_x_input.setValue(1920) self.resolution_x_input.setFixedWidth(80) resolution_layout.addWidget(self.resolution_x_input) self.resolution_y_input = QSpinBox() self.resolution_y_input.setRange(1, 9999) self.resolution_y_input.setValue(1080) self.resolution_y_input.setFixedWidth(80) resolution_layout.addWidget(QLabel("x")) resolution_layout.addWidget(self.resolution_y_input) resolution_layout.addStretch() fps_layout = QHBoxLayout() self.fps_options_list = QComboBox() self.fps_options_list.setFixedWidth(200) self.fps_options_list.addItem("Original FPS") for fps_option in COMMON_FRAME_RATES: self.fps_options_list.addItem(fps_option) self.fps_options_list.currentIndexChanged.connect(self._fps_preset_changed) fps_layout.addWidget(self.fps_options_list) self.fps_input = QDoubleSpinBox() self.fps_input.setDecimals(3) self.fps_input.setRange(1.0, 999.0) self.fps_input.setValue(23.976) self.fps_input.setFixedWidth(80) fps_layout.addWidget(self.fps_input) fps_layout.addWidget(QLabel("fps")) fps_layout.addStretch() resolution_group_layout.addLayout(fps_layout) output_settings_layout.addStretch() # ==================== Tab 3: Engine Settings ==================== self.engine_group = QWidget() engine_group_layout = QVBoxLayout(self.engine_group) engine_layout = QHBoxLayout() engine_layout.addWidget(QLabel("Engine Version:")) self.engine_version_combo = QComboBox() self.engine_version_combo.addItem('latest') engine_layout.addWidget(self.engine_version_combo) engine_group_layout.addLayout(engine_layout) # Dynamic engine options self.engine_options_layout = QVBoxLayout() engine_group_layout.addLayout(self.engine_options_layout) # Raw Args raw_args_layout = QHBoxLayout() 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) engine_group_layout.addLayout(raw_args_layout) engine_group_layout.addStretch() # ==================== Tab 4: Cameras ==================== self.cameras_group = QWidget() cameras_layout = QVBoxLayout(self.cameras_group) self.cameras_list = QListWidget() self.cameras_list.itemChanged.connect(self.update_job_count) cameras_layout.addWidget(self.cameras_list) # ==================== Tab 5: Misc / Notes ==================== self.notes_group = QWidget() notes_layout = QVBoxLayout(self.notes_group) self.notes_input = QPlainTextEdit() notes_layout.addWidget(self.notes_input) # == Create Tabs self.tabs.addTab(self.project_group, "Job Settings") self.tabs.addTab(self.output_settings_group, "Output Settings") self.tabs.addTab(self.engine_group, "Engine Settings") self.tabs.addTab(self.cameras_group, "Cameras") self.tabs.addTab(self.notes_group, "Notes") self.update_server_list() index = self.tabs.indexOf(self.cameras_group) if index != -1: self.tabs.setTabEnabled(index, False) # ==================== Submit Section (outside tabs) ==================== self.submit_button = QPushButton("Submit Job") self.submit_button.clicked.connect(self.submit_job) main_layout.addWidget(self.submit_button) self.submit_progress = QProgressBar() self.submit_progress.setMinimum(0) self.submit_progress.setMaximum(0) self.submit_progress.setHidden(True) main_layout.addWidget(self.submit_progress) self.submit_progress_label = QLabel("Submitting...") self.submit_progress_label.setHidden(True) main_layout.addWidget(self.submit_progress_label) # Initial engine state self.toggle_engine_enablement(False) self.tabs.setCurrentIndex(0) def update_job_count(self, changed_item=None): checked = 0 total = self.cameras_list.count() for i in range(total): item = self.cameras_list.item(i) if item.checkState() == Qt.CheckState.Checked: checked += 1 message = f"Submit {checked} Jobs" if checked > 1 else "Submit Job" self.submit_button.setText(message) self.submit_button.setEnabled(bool(checked)) def _resolution_preset_changed(self, index): selected_res = COMMON_RESOLUTIONS.get(self.resolution_options_list.currentText()) if selected_res: self.resolution_x_input.setValue(selected_res[0]) self.resolution_y_input.setValue(selected_res[1]) elif index == 0: self.resolution_x_input.setValue(self.project_info.get('resolution_x')) self.resolution_y_input.setValue(self.project_info.get('resolution_y')) def _fps_preset_changed(self, index): selected_fps = COMMON_FRAME_RATES.get(self.fps_options_list.currentText()) if selected_fps: self.fps_input.setValue(selected_fps) elif index == 0: self.fps_input.setValue(self.project_info.get('fps')) def engine_changed(self): # load the version numbers current_engine = self.engine_type.currentText().lower() or self.engine_type.itemText(0) self.engine_version_combo.clear() self.engine_version_combo.addItem('latest') self.file_format_combo.clear() if current_engine: engine_info = self.server_proxy.get_engine_info(current_engine, 'full', timeout=10) self.current_engine_options = engine_info.get('options', []) if not engine_info: raise FileNotFoundError(f"Cannot get information about engine '{current_engine}'") engine_vers = [v['version'] for v in engine_info['versions']] self.engine_version_combo.addItems(engine_vers) self.file_format_combo.addItems(engine_info.get('supported_export_formats')) def update_server_list(self): clients = ZeroconfServer.found_hostnames() self.server_input.clear() self.server_input.addItems(clients) def browse_scene_file(self): file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File") if file_name: self.scene_file_input.setText(file_name) 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_engine_enablement(False) output_name, _ = os.path.splitext(os.path.basename(self.scene_file_input.text())) output_name = output_name.replace(' ', '_') self.job_name_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.error_signal.connect(self.show_error_message) self.worker_thread.start() def browse_output_path(self): directory = QFileDialog.getExistingDirectory(self, "Select Output Directory") if directory: self.job_name_input.setText(directory) def args_help_button_clicked(self): url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/engine/' f'{self.engine_type.currentText()}/help') self.engine_help_viewer = EngineHelpViewer(url) self.engine_help_viewer.show() def show_error_message(self, message): msg = QMessageBox(self) msg.setIcon(QMessageBox.Icon.Critical) msg.setWindowTitle("Error") msg.setText(message) msg.exec() # -------- Update -------- def post_get_project_info_update(self): """Called by the GetProjectInfoWorker - Do not call directly.""" try: self.engine_type.addItems(self.installed_engines.keys()) self.engine_type.setCurrentText(self.preferred_engine) self.engine_changed() # Set the best engine we can find input_path = self.scene_file_input.text() engine = EngineManager.engine_class_for_project_path(input_path) engine_index = self.engine_type.findText(engine.name().lower()) if engine_index >= 0: self.engine_type.setCurrentIndex(engine_index) else: self.engine_type.setCurrentIndex(0) #todo: find out why we don't have engine info yet # not ideal but if we don't have the engine info we have to pick something # cleanup progress UI self.load_file_group.setHidden(True) self.toggle_engine_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.fps_input.setValue(self.project_info.get('fps')) # Cameras self.cameras_list.clear() index = self.tabs.indexOf(self.cameras_group) if self.project_info.get('cameras'): self.tabs.setTabEnabled(index, True) 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.tabs.setTabEnabled(index, False) self.update_job_count() # Dynamic Engine Options clear_layout(self.engine_options_layout) # clear old options # dynamically populate option list for option in self.current_engine_options: h_layout = QHBoxLayout() label = QLabel(option['name'].replace('_', ' ').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.engine_options_layout.addLayout(h_layout) except AttributeError: pass def toggle_engine_enablement(self, enabled=False): """Toggle on/off all the render settings""" indexes = [self.tabs.indexOf(self.project_group), self.tabs.indexOf(self.output_settings_group), self.tabs.indexOf(self.engine_group), self.tabs.indexOf(self.cameras_group), self.tabs.indexOf(self.notes_group)] for idx in indexes: self.tabs.setTabEnabled(idx, enabled) self.submit_button.setEnabled(enabled) def after_job_submission(self, error_string): # 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_engine_enablement(True) self.msg_box = QMessageBox() if not error_string: 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(error_string) self.msg_box.setWindowTitle("Error") self.msg_box.exec() # -------- Submit Job Calls -------- def submit_job(self): # Pre-worker UI self.submit_progress.setHidden(False) self.submit_progress_label.setHidden(False) self.submit_button.setHidden(True) self.submit_progress.setMaximum(0) # submit job in background thread self.worker_thread = SubmitWorker(window=self) self.worker_thread.update_ui_signal.connect(self.update_submit_progress) self.worker_thread.message_signal.connect(self.after_job_submission) self.worker_thread.start() @pyqtSlot(str, str) def update_submit_progress(self, hostname, percent): # Update the UI here. This slot will be executed in the main thread self.submit_progress_label.setText(f"Transferring to {hostname} - {percent}%") self.submit_progress.setMaximum(100) self.submit_progress.setValue(int(percent)) class SubmitWorker(QThread): """Worker class called to submit all the jobs to the server and update the UI accordingly""" message_signal = pyqtSignal(str) update_ui_signal = pyqtSignal(str, str) 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.update_ui_signal.emit(hostname, percent) return callback try: hostname = self.window.server_input.currentText() resolution = (self.window.resolution_x_input.text(), self.window.resolution_y_input.text()) job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(), 'engine_name': self.window.engine_type.currentText().lower(), 'engine_version': self.window.engine_version_combo.currentText(), 'args': {'raw': self.window.raw_args.text(), 'export_format': self.window.file_format_combo.currentText(), 'resolution': resolution, 'fps': self.window.fps_input.text(),}, 'output_path': self.window.job_name_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(), 'name': self.window.job_name_input.text()} # get the dynamic args for i in range(self.window.engine_options_layout.count()): item = self.window.engine_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 and self.window.cameras_list.count() > 1: children_jobs = [] for cam in selected_cameras: child_job_data = dict() child_job_data['args'] = {} child_job_data['args']['camera'] = cam child_job_data['name'] = job_json['name'].replace(' ', '-') + "_" + cam.replace(' ', '') child_job_data['output_path'] = child_job_data['name'] children_jobs.append(child_job_data) job_json['child_jobs'] = children_jobs # presubmission tasks - use local installs engine_class = EngineManager.engine_class_with_name(self.window.engine_type.currentText().lower()) latest_engine = EngineManager.get_latest_engine_instance(engine_class) input_path = latest_engine.perform_presubmission_tasks(input_path) # submit err_msg = "" result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_data=job_json, callback=create_callback) if not (result and result.ok): err_msg = f"Error posting job to server: {result.message}" self.message_signal.emit(err_msg) except Exception as e: self.message_signal.emit(str(e)) class GetProjectInfoWorker(QThread): """Worker class called to retrieve information about a project file on a background thread and update the UI""" message_signal = pyqtSignal() error_signal = pyqtSignal(str) def __init__(self, window, project_path): super().__init__() self.window = window self.project_path = project_path def run(self): try: # get the engine info and add them all to the ui self.window.installed_engines = self.window.server_proxy.get_installed_engines() # select the best engine for the file type self.window.preferred_engine = self.window.server_proxy.get_engine_for_filename(self.project_path) # this should be the only time we use a local engine instead of using the proxy besides submitting engine_class = EngineManager.engine_class_for_project_path(self.project_path) engine = EngineManager.get_latest_engine_instance(engine_class) self.window.project_info = engine.get_project_info(self.project_path) self.message_signal.emit() except Exception as e: self.error_signal.emit(str(e)) 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__': app = QApplication([]) window = NewRenderJobForm() app.exec()