mirror of
https://github.com/blw1138/Zordon.git
synced 2025-12-17 08:48:13 +00:00
* Add pubsub to render_queue and base_worker * Refactor: Convert ZeroconfServer to Singleton with Class Methods * New API for subjob servers to notify parent job servers of status changes * Refactor: Move all subjob related methods to distributed_job_manager.py * Rewrite for wait_for_subjobs * Fix: DistributedJobManager.find_available_servers() takes 1 positional argument but 3 were given * DistributedJobManager should now notify / be notified abotu background job changes * Fix the make_ready api. Change children keyname to be id@hostname so it can be unique * Fixes * Image sequence to movie needs to find the actual start frame * Fix: subjob_status_change did not return a valid response * Fix client renderer selection * Small fix for subjob status checking * Fix issue with divide_frames_equally * Fix issue where downloads were not occurring * Fix issue where old status was being reported * Add docstrings and code cleanup
453 lines
19 KiB
Python
Executable File
453 lines
19 KiB
Python
Executable File
#!/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, Progressbar
|
|
|
|
import psutil
|
|
import requests
|
|
import threading
|
|
from lib.workers.blender_worker import Blender
|
|
from lib.workers.ffmpeg_worker import FFMPEG
|
|
from lib.server.server_proxy import RenderServerProxy
|
|
|
|
logger = logging.getLogger()
|
|
|
|
label_width = 9
|
|
header_padding = 6
|
|
|
|
|
|
# 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 NewJobWindow(Frame):
|
|
|
|
def __init__(self, parent=None, clients=None):
|
|
super().__init__(parent)
|
|
|
|
self.root = parent
|
|
self.clients = clients or []
|
|
self.server_proxy = RenderServerProxy(hostname=clients[0] if clients else None)
|
|
self.chosen_file = None
|
|
self.project_info = {}
|
|
self.presets = {}
|
|
self.renderer_info = {}
|
|
self.priority = IntVar(value=2)
|
|
|
|
self.master.title("New Job")
|
|
self.pack(fill=BOTH, expand=True)
|
|
|
|
# project frame
|
|
job_frame = LabelFrame(self, text="Job Settings")
|
|
job_frame.pack(fill=X, padx=5, pady=5)
|
|
|
|
# project frame
|
|
project_frame = Frame(job_frame)
|
|
project_frame.pack(fill=X)
|
|
|
|
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(job_frame)
|
|
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)
|
|
self.client_combo.bind('<<ComboboxSelected>>', self.client_picked)
|
|
self.client_combo['values'] = self.clients
|
|
if self.clients:
|
|
self.client_combo.current(0)
|
|
|
|
# renderer frame
|
|
renderer_frame = Frame(job_frame)
|
|
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('<<ComboboxSelected>>', self.refresh_renderer_settings)
|
|
|
|
# priority frame
|
|
priority_frame = Frame(job_frame)
|
|
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(job_frame)
|
|
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('<<ComboboxSelected>>', self.chose_preset)
|
|
|
|
# output frame
|
|
output_frame = Frame(job_frame)
|
|
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(side=LEFT, padx=5, pady=5)
|
|
self.output_format['state'] = DISABLED
|
|
|
|
# frame_range frame
|
|
frame_range_frame = Frame(job_frame)
|
|
frame_range_frame.pack(fill=X)
|
|
|
|
Label(frame_range_frame, text="Frames", width=label_width).pack(side=LEFT, padx=5, pady=5, expand=False)
|
|
|
|
self.start_frame_spinbox = Spinbox(frame_range_frame, from_=0, to=50000, width=5)
|
|
self.start_frame_spinbox.pack(side=LEFT, expand=False, padx=5, pady=5)
|
|
|
|
Label(frame_range_frame, text="to").pack(side=LEFT, pady=5, expand=False)
|
|
self.end_frame_spinbox = Spinbox(frame_range_frame, from_=0, to=50000, width=5)
|
|
self.end_frame_spinbox.pack(side=LEFT, expand=False, padx=5, pady=5)
|
|
|
|
# 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_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
|
|
|
|
self.progress_frame = None
|
|
self.progress_label = None
|
|
self.progress_bar = None
|
|
self.upload_status = None
|
|
|
|
self.fetch_server_data()
|
|
|
|
def client_picked(self, event=None):
|
|
self.server_proxy.hostname = self.client_combo.get()
|
|
self.fetch_server_data()
|
|
|
|
def fetch_server_data(self):
|
|
self.renderer_info = self.server_proxy.request_data('renderer_info', timeout=3) or {}
|
|
self.presets = self.server_proxy.request_data('presets', timeout=3) or {}
|
|
|
|
# update available renders
|
|
self.renderer_combo['values'] = list(self.renderer_info.keys())
|
|
if self.renderer_info.keys():
|
|
self.renderer_combo.current(0)
|
|
|
|
self.refresh_renderer_settings()
|
|
|
|
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.splitext(os.path.basename(self.chosen_file))[-1].strip('.')
|
|
self.output_entry.insert(0, os.path.basename(output_name))
|
|
|
|
# Try to determine file type
|
|
extension = os.path.splitext(self.chosen_file)[-1].strip('.') # 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 not self.chosen_file:
|
|
return
|
|
|
|
if renderer == 'blender':
|
|
self.project_info = Blender.get_scene_info(self.chosen_file)
|
|
self.draw_blender_settings()
|
|
elif renderer == 'ffmpeg':
|
|
f = FFMPEG.get_frame_count(self.chosen_file)
|
|
self.project_info['frame_end'] = f
|
|
|
|
# set frame start / end numbers fetched from fils
|
|
if self.project_info.get('frame_start'):
|
|
self.start_frame_spinbox.delete(0, 'end')
|
|
self.start_frame_spinbox.insert(0, self.project_info['frame_start'])
|
|
if self.project_info.get('frame_end'):
|
|
self.end_frame_spinbox.delete(0, 'end')
|
|
self.end_frame_spinbox.insert(0, self.project_info['frame_end'])
|
|
|
|
# redraw lower ui
|
|
self.draw_custom_args()
|
|
self.draw_submit_button()
|
|
|
|
# check supported export formats
|
|
if self.renderer_info.get(renderer, {}).get('supported_export_formats', None):
|
|
formats = self.renderer_info[renderer]['supported_export_formats']
|
|
if formats and isinstance(formats[0], dict):
|
|
formats = [x.get('name', str(x)) for x in formats]
|
|
formats.sort()
|
|
self.output_format['values'] = formats
|
|
self.output_format['state'] = NORMAL
|
|
self.output_format.current(0)
|
|
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 = LabelFrame(self, text="Advanced")
|
|
self.custom_args_frame.pack(side=TOP, fill=X, expand=False, padx=5, pady=5)
|
|
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_progress_frame(self):
|
|
if hasattr(self, 'progress_frame') and self.progress_frame:
|
|
self.progress_frame.forget()
|
|
self.progress_frame = LabelFrame(self, text="Job Submission")
|
|
self.progress_frame.pack(side=TOP, fill=X, expand=False, padx=5, pady=5)
|
|
self.progress_bar = Progressbar(self.progress_frame, length=300, mode="determinate")
|
|
self.progress_bar.pack()
|
|
self.progress_label = Label(self.progress_frame, text="Starting Up")
|
|
self.progress_label.pack(pady=5, padx=5)
|
|
|
|
def draw_blender_settings(self):
|
|
|
|
# blender settings
|
|
self.blender_frame = LabelFrame(self, text="Blender Settings")
|
|
self.blender_frame.pack(fill=X, padx=5)
|
|
|
|
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)
|
|
|
|
# multi cams
|
|
def draw_scene_cams(event=None):
|
|
if self.project_info:
|
|
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 self.project_info['cameras']]
|
|
choices.sort()
|
|
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(self.project_info.get('cameras', [])) if self.project_info 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):
|
|
|
|
def submit_job_worker():
|
|
|
|
self.draw_progress_frame()
|
|
self.progress_bar['value'] = 0
|
|
self.progress_bar.configure(mode='determinate')
|
|
self.progress_bar.start()
|
|
self.progress_label.configure(text="Preparing files...")
|
|
|
|
# start the progress UI
|
|
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()},
|
|
'start_frame': self.start_frame_spinbox.get(),
|
|
'end_frame': self.end_frame_spinbox.get(),
|
|
'name': None}
|
|
job_list = []
|
|
|
|
input_path = self.chosen_file
|
|
|
|
temp_files = []
|
|
if renderer == 'blender':
|
|
if self.blender_pack_textures.get():
|
|
self.progress_label.configure(text="Packing Blender file...")
|
|
new_path = Blender.pack_project_file(project_path=input_path, timeout=300)
|
|
if new_path:
|
|
logger.info(f"New Path is now {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']['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.rsplit('-', 1)[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]
|
|
self.progress_label.configure(text="Posting to server...")
|
|
self.progress_bar.stop()
|
|
self.progress_bar.configure(mode='determinate')
|
|
self.progress_bar.start()
|
|
|
|
def create_callback(encoder):
|
|
encoder_len = encoder.len
|
|
|
|
def callback(monitor):
|
|
percent = f"{monitor.bytes_read / encoder_len * 100:.0f}"
|
|
self.progress_label.configure(text=f"Transferring to {client} - {percent}%")
|
|
self.progress_bar['value'] = int(percent)
|
|
return callback
|
|
|
|
result = self.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list,
|
|
callback=create_callback)
|
|
|
|
self.progress_bar.stop()
|
|
# clean up
|
|
for temp in temp_files:
|
|
os.remove(temp)
|
|
|
|
def finish_on_main():
|
|
if result.ok:
|
|
message = "Job successfully submitted to server."
|
|
self.progress_label.configure(text=message)
|
|
messagebox.showinfo("Success", message)
|
|
logger.info(message)
|
|
else:
|
|
message = result.text or "Unknown error"
|
|
self.progress_label.configure(text=message)
|
|
logger.warning(message)
|
|
messagebox.showinfo("Error", message)
|
|
self.progress_label.configure(text="")
|
|
self.progress_frame.forget()
|
|
|
|
self.root.after(0, finish_on_main)
|
|
|
|
# Start the job submit task as a bg thread
|
|
bg_thread = threading.Thread(target=submit_job_worker)
|
|
bg_thread.start()
|
|
|
|
|
|
def main():
|
|
|
|
root = Tk()
|
|
root.geometry("500x600+300+300")
|
|
root.maxsize(width=1000, height=2000)
|
|
root.minsize(width=600, height=600)
|
|
app = NewJobWindow(root)
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|