#!/usr/bin/env python3 import copy import logging import os.path import pathlib import socket from tkinter import * from tkinter import filedialog, messagebox from tkinter.simpledialog import askstring from tkinter.ttk import Frame, Label, Entry, Combobox import psutil import requests from lib.utilities.server_helper import post_job_to_server logger = logging.getLogger() prefs_name = 'config/.scheduler_prefs' label_width = 9 header_padding = 6 server_setup_timeout = 5 def request_data(server_ip, payload, server_port=8080, timeout=2): try: req = requests.get(f'http://{server_ip}:{server_port}/api/{payload}', timeout=timeout) if req.ok: return req.json() except Exception as e: pass return None # CheckListBox source - https://stackoverflow.com/questions/50398649/python-tkinter-tk-support-checklist-box class ChecklistBox(Frame): def __init__(self, parent, choices, **kwargs): super().__init__(parent, **kwargs) self.vars = [] for choice in choices: var = StringVar(value="") self.vars.append(var) cb = Checkbutton(self, text=choice, onvalue=choice, offvalue="", anchor="w", width=20, relief="flat", highlightthickness=0, variable=var) cb.pack(side="top", fill="x", anchor="w") def getCheckedItems(self): values = [] for var in self.vars: value = var.get() if value: values.append(value) return values def resetCheckedItems(self): values = [] for var in self.vars: var.set(value='') return values class ScheduleJob(Frame): def __init__(self): super().__init__() self.server_hostname = None self.chosen_file = None self.clients = [] self.presets = {} self.renderer_info = {} self.priority = IntVar(value=2) self.master.title("Schedule Job") self.pack(fill=BOTH, expand=True) self.server_button = Button(self, text="", width=6, command=self.request_new_hostname) self.server_button.pack(fill=X, padx=5, expand=False) # project frame project_frame = Frame(self) project_frame.pack(fill=X) Label(project_frame, text="Project Settings").pack(side=TOP, pady=header_padding) project_label = Label(project_frame, text="Project", width=label_width) project_label.pack(side=LEFT, padx=5, pady=5) self.project_button = Button(project_frame, text="no file selected", width=6, command=self.choose_file_button) self.project_button.pack(fill=X, padx=5, expand=True) # client frame client_frame = Frame(self) client_frame.pack(fill=X) Label(client_frame, text="Client", width=label_width).pack(side=LEFT, padx=5, pady=5) self.client_combo = Combobox(client_frame, state="readonly") self.client_combo.pack(fill=X, padx=5, expand=True) # renderer frame renderer_frame = Frame(self) renderer_frame.pack(fill=X) Label(renderer_frame, text="Renderer", width=label_width).pack(side=LEFT, padx=5, pady=5) self.renderer_combo = Combobox(renderer_frame, state="readonly") self.renderer_combo.pack(fill=X, padx=5, expand=True) self.renderer_combo.bind('<>', self.refresh_renderer_settings) # priority frame priority_frame = Frame(self) priority_frame.pack(fill=X) Label(priority_frame, text="Priority", width=label_width).pack(side=LEFT, padx=5, pady=5) Radiobutton(priority_frame, text="1", value=1, variable=self.priority).pack(anchor=W, side=LEFT, padx=5) Radiobutton(priority_frame, text="2", value=2, variable=self.priority).pack(anchor=W, side=LEFT, padx=5) Radiobutton(priority_frame, text="3", value=3, variable=self.priority).pack(anchor=W, side=LEFT, padx=5) # presets presets_frame = Frame(self) presets_frame.pack(fill=X) Label(presets_frame, text="Presets", width=label_width).pack(side=LEFT, padx=5, pady=5) self.presets_combo = Combobox(presets_frame, state="readonly") self.presets_combo.pack(side=LEFT, padx=5, pady=5, expand=True, fill=X) self.presets_combo.bind('<>', self.chose_preset) # output frame output_frame = Frame(self) output_frame.pack(fill=X) Label(output_frame, text="Output", width=label_width).pack(side=LEFT, padx=5, pady=5) self.output_entry = Entry(output_frame) self.output_entry.pack(side=LEFT, padx=5, expand=True, fill=X) self.output_format = Combobox(output_frame, state="readonly", values=['JPG', 'MOV', 'PNG'], width=9) self.output_format.pack(padx=5, pady=5) self.output_format['state'] = DISABLED # Blender self.blender_frame = None self.blender_cameras_frame = None self.blender_engine = StringVar(value='CYCLES') self.blender_pack_textures = BooleanVar(value=False) self.blender_render_all_frames = BooleanVar(value=False) self.blender_multiple_cameras = BooleanVar(value=False) self.blender_cameras_list = None # Custom Args / Submit Button self.custom_args_frame = None self.custom_args_entry = None self.submit_frame = None if os.path.exists(prefs_name): with open(prefs_name, 'r') as file: hostname = file.read() server_data = request_data(hostname, 'status', timeout=server_setup_timeout) if server_data: self.set_hostname(hostname) if not self.server_hostname: server_data = request_data('localhost', 'status', timeout=server_setup_timeout) if server_data: self.set_hostname(server_data['host_name']) else: self.request_new_hostname() self.fetch_server_data() def fetch_server_data(self): self.clients = request_data(self.server_hostname, 'clients', timeout=3) or [] self.renderer_info = request_data(self.server_hostname, 'renderer_info', timeout=3) or {} self.presets = request_data(self.server_hostname, 'presets', timeout=3) or {} # update clients self.client_combo['values'] = self.clients if self.clients: self.client_combo.current(0) # update available renders available_renderers = [x for x in self.renderer_info.keys() if self.renderer_info[x].get('available', False)] self.renderer_combo['values'] = available_renderers if available_renderers: self.renderer_combo.current(0) self.refresh_renderer_settings() def request_new_hostname(self): hostname = None while not hostname: user_hostname_input = askstring("Server Name", "What is the server's hostname?") if not user_hostname_input: # user input is none if they press cancel return server_status = request_data(user_hostname_input, 'status', timeout=server_setup_timeout) if not server_status: messagebox.showerror("Cannot connect", f"Cannot connect to server \"{user_hostname_input}\"") else: hostname = user_hostname_input self.set_hostname(hostname) def set_hostname(self, hostname): self.server_hostname = hostname self.server_button.configure(text=self.server_hostname) with open(prefs_name, 'w') as file: # save to prefs file.write(hostname) def choose_file_button(self): self.chosen_file = filedialog.askopenfilename() button_text = os.path.basename(self.chosen_file) if self.chosen_file else "no file selected" self.project_button.configure(text=button_text) # Update the output label self.output_entry.delete(0, END) if self.chosen_file: # Generate a default output name output_name = os.path.basename(self.chosen_file).split('.')[0] + '-output' self.output_entry.insert(0, os.path.basename(output_name)) # Try to determine file type extension = self.chosen_file.split('.')[-1] # not the best way to do this for renderer, renderer_info in self.renderer_info.items(): supported = [x.lower().strip('.') for x in renderer_info.get('supported_extensions', [])] if extension.lower().strip('.') in supported: if renderer in self.renderer_combo['values']: self.renderer_combo.set(renderer) self.refresh_renderer_settings() def chose_preset(self, event=None): preset_name = self.presets_combo.get() renderer = self.renderer_combo.get() presets_to_show = {key: value for key, value in self.presets.items() if value.get("renderer") == renderer} matching_dict = next((value for value in presets_to_show.values() if value.get("name") == preset_name), None) if matching_dict: self.custom_args_entry.delete(0, END) self.custom_args_entry.insert(0, matching_dict['args']) def refresh_renderer_settings(self, event=None): renderer = self.renderer_combo.get() # clear old settings if self.blender_frame: self.blender_frame.pack_forget() if renderer == 'blender': self.draw_blender_settings() self.draw_custom_args() self.draw_submit_button() if self.renderer_info.get(renderer, {}).get('supported_export_formats', None): formats = self.renderer_info[renderer]['supported_export_formats'] formats.sort() self.output_format['values'] = formats self.output_format['state'] = NORMAL self.output_format.current(formats.index('JPEG')) else: self.output_format['values'] = [] self.output_format['state'] = DISABLED # update presets presets_to_show = {key: value for key, value in self.presets.items() if value.get("renderer") == renderer} self.presets_combo['values'] = [value['name'] for value in presets_to_show.values()] def draw_custom_args(self): if hasattr(self, 'custom_args_frame') and self.custom_args_frame: self.custom_args_frame.forget() self.custom_args_frame = Frame(self) self.custom_args_frame.pack(side=TOP, fill=X, expand=False) Label(self.custom_args_frame, text="Custom Args", width=label_width).pack(side=LEFT, padx=5, pady=5) self.custom_args_entry = Entry(self.custom_args_frame) self.custom_args_entry.pack(side=TOP, padx=5, expand=True, fill=X) def draw_submit_button(self): if hasattr(self, 'submit_frame') and self.submit_frame: self.submit_frame.forget() self.submit_frame = Frame(self) self.submit_frame.pack(fill=BOTH, expand=True) Label(self.submit_frame, text="").pack(fill=BOTH, expand=True) submit_button = Button(self.submit_frame, text="Submit", command=self.submit_job) submit_button.pack(fill=Y, anchor="s", pady=header_padding) def draw_blender_settings(self): scene_data = None # get file stats if self.chosen_file: from lib.render_workers.blender_worker import get_scene_info scene_data = get_scene_info(self.chosen_file) # blender settings self.blender_frame = Frame(self) self.blender_frame.pack(fill=X) Label(self.blender_frame, text="Blender Settings").pack(side=TOP, pady=header_padding) blender_engine_frame = Frame(self.blender_frame) blender_engine_frame.pack(fill=X) Label(blender_engine_frame, text="Engine", width=label_width).pack(side=LEFT, padx=5, pady=5) Radiobutton(blender_engine_frame, text="Cycles", value="CYCLES", variable=self.blender_engine).pack( anchor=W, side=LEFT, padx=5) Radiobutton(blender_engine_frame, text="Eevee", value="BLENDER_EEVEE", variable=self.blender_engine).pack( anchor=W, side=LEFT, padx=5) # options pack_frame = Frame(self.blender_frame) pack_frame.pack(fill=X) Label(pack_frame, text="Options", width=label_width).pack(side=LEFT, padx=5, pady=5) Checkbutton(pack_frame, text="Pack Textures", variable=self.blender_pack_textures, onvalue=True, offvalue=False ).pack(anchor=W, side=LEFT, padx=5) Checkbutton(pack_frame, text="Render All Frames", variable=self.blender_render_all_frames, onvalue=True, offvalue=False).pack(anchor=W, side=LEFT, padx=5) # multi cams def draw_scene_cams(event=None): if scene_data: show_cams_checkbutton['state'] = NORMAL if self.blender_multiple_cameras.get(): self.blender_cameras_frame = Frame(self.blender_frame) self.blender_cameras_frame.pack(fill=X) Label(self.blender_cameras_frame, text="Cameras", width=label_width).pack(side=LEFT, padx=5, pady=5) choices = [f"{x['name']} - {int(float(x['lens']))}mm" for x in scene_data['cameras']] self.blender_cameras_list = ChecklistBox(self.blender_cameras_frame, choices, relief="sunken") self.blender_cameras_list.pack(padx=5, fill=X) elif self.blender_cameras_frame: self.blender_cameras_frame.pack_forget() else: show_cams_checkbutton['state'] = DISABLED if self.blender_cameras_frame: self.blender_cameras_frame.pack_forget() # multiple cameras checkbox camera_count = len(scene_data.get('cameras', [])) if scene_data else 0 show_cams_checkbutton = Checkbutton(pack_frame, text=f'Multiple Cameras ({camera_count})', offvalue=False, onvalue=True, variable=self.blender_multiple_cameras, command=draw_scene_cams) show_cams_checkbutton.pack(side=LEFT, padx=5) show_cams_checkbutton['state'] = NORMAL if camera_count > 1 else DISABLED def submit_job(self): client = self.client_combo.get() renderer = self.renderer_combo.get() job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(), 'renderer': renderer, 'client': client, 'output_path': os.path.join(os.path.dirname(self.chosen_file), self.output_entry.get()), 'args': {'raw': self.custom_args_entry.get()}, 'name': None} job_list = [] input_path = self.chosen_file temp_files = [] if renderer == 'blender': if self.blender_pack_textures.get(): from lib.render_workers.blender_worker import pack_blender_files new_path = pack_blender_files(path=input_path) if new_path: logger.info(f'Packed Blender file successfully: {new_path}') input_path = new_path temp_files.append(new_path) else: err_msg = f'Failed to pack Blender file: {input_path}' messagebox.showinfo("Error", err_msg) return # add all Blender args job_json['args']['engine'] = self.blender_engine.get() job_json['args']['render_all_frames'] = self.blender_render_all_frames.get() job_json['args']['export_format'] = self.output_format.get() # multiple camera rendering if self.blender_cameras_list and self.blender_multiple_cameras.get(): selected_cameras = self.blender_cameras_list.getCheckedItems() for cam in selected_cameras: job_copy = copy.deepcopy(job_json) job_copy['args']['camera'] = cam.split('-')[0].strip() job_copy['name'] = pathlib.Path(input_path).stem.replace(' ', '_') + "-" + cam.replace(' ', '') job_list.append(job_copy) # Submit to server job_list = job_list or [job_json] result = post_job_to_server(input_path=input_path, job_list=job_list, client=client) if result.ok: messagebox.showinfo("Success", "Job successfully submitted to server.") else: messagebox.showinfo("Error", result.text or "Unknown error") # clean up for temp in temp_files: os.remove(temp) def main(): root = Tk() root.geometry("500x600+300+300") root.maxsize(width=600, height=800) root.minsize(width=500, height=600) app = ScheduleJob() root.mainloop() if __name__ == '__main__': main()