diff --git a/.gitignore b/.gitignore index 1c53fff..99e2a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /uploads *.pyc /server_state.json +/.scheduler_prefs diff --git a/scheduler_gui.py b/scheduler_gui.py new file mode 100755 index 0000000..b6f00e9 --- /dev/null +++ b/scheduler_gui.py @@ -0,0 +1,367 @@ +#!/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.ttk import Frame, Label, Entry, Combobox +from tkinter.simpledialog import askstring + +import psutil +import requests + +from lib.job_server import post_job_to_server + +logger = logging.getLogger() + +prefs_name = '.scheduler_prefs' +label_width = 7 +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}/{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.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) + + # 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 + + # Submit Button + 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.server_hostname = hostname + self.server_button.configure(text=hostname) + if not self.server_hostname: + 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 {} + + # 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.server_hostname = hostname + self.server_button.configure(text=self.server_hostname) + + # save to prefs + with open(prefs_name, 'w') as file: + 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 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_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 + + 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 utilities.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() + + show_cams_checkbutton = Checkbutton(pack_frame, text='Multiple Cameras', 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 scene_data and scene_data.get('cameras', None) 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': {}, + 'name': None} + job_list = [] + + input_path = self.chosen_file + + temp_files = [] + if renderer == 'blender': + if self.blender_pack_textures.get(): + from utilities.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 + selected_cameras = self.blender_cameras_list.getCheckedItems() if self.blender_cameras_list else None + 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()