diff --git a/dashboard.py b/dashboard.py index 6320193..b1f08c7 100755 --- a/dashboard.py +++ b/dashboard.py @@ -30,7 +30,7 @@ status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', R RenderStatus.RUNNING: 'cyan'} categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED, - RenderStatus.COMPLETED, RenderStatus.CANCELLED] + RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.UNDEFINED] renderer_colors = {'ffmpeg': '[magenta]', 'blender': '[orange1]', 'aerender': '[purple]'} diff --git a/lib/client/client.py b/lib/client/client.py new file mode 100644 index 0000000..63f4a8d --- /dev/null +++ b/lib/client/client.py @@ -0,0 +1,242 @@ +import subprocess + +import requests +import tkinter as tk +import threading +import time +import socket +import os +from tkinter import ttk, messagebox +from PIL import Image, ImageTk +from new_job_window import NewJobWindow +from server_proxy import RenderServerProxy + + +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 + + +def sort_column(tree, col, reverse=False): + data = [(tree.set(child, col), child) for child in tree.get_children('')] + data.sort(reverse=reverse) + for index, (_, child) in enumerate(data): + tree.move(child, '', index) + + +def make_sortable(tree): + for col in tree["columns"]: + tree.heading(col, text=col, command=lambda c=col: sort_column(tree, c)) + + +def available_servers(): + return [socket.gethostname()] + + +class ZordonClient: + + def __init__(self): + + servers = available_servers() + + # Create a Treeview widget + self.root = tk.Tk() + self.root.title("Zordon Render Client") + self.local_host = socket.gethostname() + self.server_proxy = RenderServerProxy(hostname=servers[0]) + self.job_cache = [] + + # Setup photo preview + photo_pad = tk.Frame(self.root, background="gray") + photo_pad.pack(fill=tk.BOTH, pady=5, padx=5) + self.photo_label = tk.Label(photo_pad, height=500) + self.photo_label.pack(fill=tk.BOTH, expand=True) + + server_frame = tk.LabelFrame(self.root, text="Server") + server_frame.pack(fill=tk.BOTH, pady=5, padx=5, expand=True) + server_picker_frame = tk.Frame(server_frame) + server_picker_frame.pack(fill=tk.X, expand=True) + server_label = tk.Label(server_picker_frame, text="Current Server:") + server_label.pack(side=tk.LEFT, padx=5) + + self.server_combo = tk.ttk.Combobox(server_picker_frame) + self.server_combo.pack(fill=tk.X) + self.server_combo.bind("<>", self.server_picked) + self.server_combo['values'] = servers + self.server_combo.current(0) + + # Setup the Tree + self.tree = ttk.Treeview(server_frame, show="headings", height=20) + self.tree.tag_configure('running', background='lawn green', font=('', 0, 'bold')) + self.tree.bind("<>", self.on_row_select) + self.tree["columns"] = ("id", "Name", "Renderer", "Priority", "Status", "Time Elapsed", "Frames") + + # Format the columns + self.tree.column("id", width=0, stretch=False) + self.tree.column("Name", width=50) + self.tree.column("Renderer", width=100, stretch=False) + self.tree.column("Priority", width=50, stretch=False) + self.tree.column("Status", width=100, stretch=False) + self.tree.column("Time Elapsed", width=100, stretch=False) + self.tree.column("Frames", width=50, stretch=False) + + # Create the column headings + for name in self.tree['columns']: + self.tree.heading(name, text=name) + + # Pack the Treeview widget + self.tree.pack(fill=tk.BOTH, expand=True) + + button_frame = tk.Frame(server_frame) + button_frame.pack(pady=5, fill=tk.BOTH, expand=True) + + # Create buttons + logs_button = tk.Button(button_frame, text="Logs", command=self.open_logs) + finder_button = tk.Button(button_frame, text="Reveal in Finder", command=self.reveal_in_finder) + self.stop_button = tk.Button(button_frame, text="Stop", command=self.stop_job) + delete_button = tk.Button(button_frame, text="Delete", command=self.delete_job) + add_job_button = tk.Button(button_frame, text="Add Job", command=self.show_new_job_window) + + # Pack the buttons in the frame + self.stop_button.pack(side=tk.LEFT) + delete_button.pack(side=tk.LEFT) + finder_button.pack(side=tk.LEFT) + logs_button.pack(side=tk.LEFT) + add_job_button.pack(side=tk.RIGHT) + + # Start the Tkinter event loop + self.root.geometry("500x600+300+300") + self.root.maxsize(width=2000, height=1200) + self.root.minsize(width=900, height=1000) + make_sortable(self.tree) + + self.update_jobs() + selected_item = self.tree.get_children()[0] + self.tree.selection_set(selected_item) + self.start_update_thread() + + def server_picked(self, event): + new_hostname = self.server_combo.get() + self.server_proxy.hostname = new_hostname + self.update_jobs(clear_table=True) + + def stop_job(self): + selected_item = self.tree.selection()[0] # Get the selected item + row_data = self.tree.item(selected_item) # Get the text of the selected item + job_id = row_data['values'][0] + self.server_proxy.request_data(f'job/{job_id}/cancel?confirm=true') + + def delete_job(self): + selected_item = self.tree.selection()[0] # Get the selected item + row_data = self.tree.item(selected_item) # Get the text of the selected item + job_id = row_data['values'][0] + job = next((d for d in self.job_cache if d.get('id') == job_id), None) + display_name = job['name'] or os.path.basename(job['input_path']) + result = messagebox.askyesno("Confirmation", f"Are you sure you want to delete the job:\n{display_name}?") + if result: + self.server_proxy.request_data(f'job/{job_id}/delete?confirm=true') + self.update_jobs(clear_table=True) + + def on_row_select(self, event): + if self.tree.selection(): + selected_item = self.tree.selection()[0] # Get the selected item + row_data = self.tree.item(selected_item) # Get the text of the selected item + job_id = row_data['values'][0] + + # update thumb + thumb_url = f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/ui/job/{job_id}/thumbnail?size=big' + response = requests.get(thumb_url) + if response.status_code == 200: + try: + import io + image_data = response.content + image = Image.open(io.BytesIO(image_data)) + thumb_image = ImageTk.PhotoImage(image) + if thumb_image: + self.photo_label.configure(image=thumb_image) + self.photo_label.image = thumb_image + except Exception as e: + print(f"error getting image: {e}") + + # update button status + job = next((d for d in self.job_cache if d.get('id') == job_id), None) + button_state = 'normal' if job['status'] == 'running' else 'disabled' + self.stop_button.config(state=button_state) + + def reveal_in_finder(self): + selected_item = self.tree.selection()[0] # Get the selected item + row_data = self.tree.item(selected_item) # Get the text of the selected item + job_id = row_data['values'][0] + + job = next((d for d in self.job_cache if d.get('id') == job_id), None) + output_dir = os.path.dirname(job['output_path']) + subprocess.run(['open', output_dir]) + + def open_logs(self): + selected_item = self.tree.selection()[0] # Get the selected item + row_data = self.tree.item(selected_item) # Get the text of the selected item + job_id = row_data['values'][0] + + job = next((d for d in self.job_cache if d.get('id') == job_id), None) + subprocess.run(['open', job['log_path']]) + + def mainloop(self): + self.root.mainloop() + + def start_update_thread(self): + x = threading.Thread(target=self.__background_update) + x.daemon = True + x.start() + + def __background_update(self): + while True: + self.update_jobs(clear_table=False) + time.sleep(1) + + def update_jobs(self, clear_table=False): + + def update_row(tree, id, new_values, tags=None): + for item in tree.get_children(): + values = tree.item(item, "values") + if values[0] == id: + tree.item(item, values=new_values, tags=tags) + break + if clear_table: + self.tree.delete(*self.tree.get_children()) + self.job_cache.clear() + job_fetch = self.server_proxy.get_jobs() + if job_fetch: + self.job_cache = job_fetch # update the cache only if its good data + for job in self.job_cache: + display_status = job['status'] if job['status'] != 'running' else \ + ('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percentage, otherwise just show status + tags = (job['status'],) + values = (job['id'], + job['name'] or os.path.basename(job['input_path']), + job['renderer'] + "-" + job['renderer_version'], + job['priority'], + display_status, + job['time_elapsed'], + job['total_frames']) + if self.tree.exists(job['id']): + update_row(self.tree, job['id'], new_values=values, tags=tags) + else: + self.tree.insert("", tk.END, iid=job['id'], values=values, tags=tags) + + def show_new_job_window(self): + new_window = tk.Toplevel(self.root) + new_window.title("New Window") + new_window.geometry("500x600+300+300") + new_window.resizable(False, height=True) + x = NewJobWindow(parent=new_window, hostname=self.server_proxy.hostname) + x.pack() + + +if __name__ == '__main__': + x = ZordonClient() + x.mainloop() diff --git a/scheduler_gui.py b/lib/client/new_job_window.py similarity index 92% rename from scheduler_gui.py rename to lib/client/new_job_window.py index 33c00ad..b2b3d77 100755 --- a/scheduler_gui.py +++ b/lib/client/new_job_window.py @@ -13,7 +13,7 @@ import psutil import requests from lib.render_workers.blender_worker import Blender -from lib.utilities.server_helper import post_job_to_server +from server_proxy import RenderServerProxy logger = logging.getLogger() @@ -61,24 +61,21 @@ class ChecklistBox(Frame): return values -class ScheduleJob(Frame): +class NewJobWindow(Frame): - def __init__(self): - super().__init__() + def __init__(self, parent=None, hostname=None): + super().__init__(parent) - self.server_hostname = None + self.server_proxy = RenderServerProxy(hostname=hostname) self.chosen_file = None self.clients = [] self.presets = {} self.renderer_info = {} self.priority = IntVar(value=2) - self.master.title("Schedule Job") + self.master.title("New 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 job_frame = LabelFrame(self, text="Job Settings") job_frame.pack(fill=X, padx=5, pady=5) @@ -156,24 +153,12 @@ class ScheduleJob(Frame): 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 {} + self.clients = self.server_proxy.request_data('clients', timeout=3) or [] + 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 clients self.client_combo['values'] = self.clients @@ -388,7 +373,7 @@ class ScheduleJob(Frame): # Submit to server job_list = job_list or [job_json] - result = post_job_to_server(input_path=input_path, job_list=job_list, hostname=client) + result = self.server_proxy.post_job_to_server(input_path=input_path, job_list=job_list) if result.ok: messagebox.showinfo("Success", "Job successfully submitted to server.") else: @@ -405,7 +390,7 @@ def main(): root.geometry("500x600+300+300") root.maxsize(width=1000, height=2000) root.minsize(width=600, height=600) - app = ScheduleJob() + app = NewJobWindow(root) root.mainloop() diff --git a/lib/client/server_proxy.py b/lib/client/server_proxy.py new file mode 100644 index 0000000..91fc3c5 --- /dev/null +++ b/lib/client/server_proxy.py @@ -0,0 +1,54 @@ +import os +import json +import requests +from lib.render_workers.base_worker import RenderStatus + +status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green', + RenderStatus.NOT_STARTED: "yellow", RenderStatus.SCHEDULED: 'purple', + RenderStatus.RUNNING: 'cyan'} + +categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED, + RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.UNDEFINED] + + +class RenderServerProxy: + + def __init__(self, hostname=None, server_port="8080"): + self.hostname = hostname + self.port = server_port + self.fetched_status_data = None + + def connect(self): + status = self.request_data('status') + return status + + def request_data(self, payload, timeout=5): + try: + req = requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout) + if req.ok: + return req.json() + except Exception as e: + pass + return None + + def get_jobs(self): + all_jobs = self.request_data('jobs') + sorted_jobs = [] + if all_jobs: + for status_category in categories: + found_jobs = [x for x in all_jobs if x['status'] == status_category.value] + if found_jobs: + sorted_jobs.extend(found_jobs) + return sorted_jobs + + def get_data(self, timeout=5): + all_data = self.request_data('full_status', timeout=timeout) + return all_data + + def post_job_to_server(self, input_path, job_list): + # Pack job data and submit to server + job_files = {'file': (os.path.basename(input_path), open(input_path, 'rb'), 'application/octet-stream'), + 'json': (None, json.dumps(job_list), 'application/json')} + + req = requests.post(f'http://{self.hostname}:{self.port}/api/add_job', files=job_files) + return req \ No newline at end of file diff --git a/lib/render_workers/base_worker.py b/lib/render_workers/base_worker.py index ef4bf9f..fd27b15 100644 --- a/lib/render_workers/base_worker.py +++ b/lib/render_workers/base_worker.py @@ -244,7 +244,7 @@ class BaseRenderWorker(Base): self.stop(is_error=True) def stop(self, is_error=False): - if self.__process: + if hasattr(self, '__process'): try: if self.status in [RenderStatus.RUNNING, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED]: if is_error: @@ -324,7 +324,8 @@ class BaseRenderWorker(Base): 'renderer_version': self.renderer_version, 'errors': getattr(self, 'errors', None), 'total_frames': self.total_frames, - 'last_output': getattr(self, 'last_output', None) + 'last_output': getattr(self, 'last_output', None), + 'log_path': self.log_path() } # convert to json and back to auto-convert dates to iso format diff --git a/lib/server/job_server.py b/lib/server/job_server.py index d2ae020..8917d00 100755 --- a/lib/server/job_server.py +++ b/lib/server/job_server.py @@ -70,17 +70,27 @@ def job_detail(job_id): @server.route('/ui/job//thumbnail') def job_thumbnail(job_id): + big_thumb = request.args.get('size', False) == "big" found_job = RenderQueue.job_with_id(job_id, none_ok=True) if found_job: os.makedirs(server.config['THUMBS_FOLDER'], exist_ok=True) thumb_video_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.mp4') thumb_image_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.jpg') + big_video_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '_big.mp4') + big_image_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '_big.jpg') if not os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS') and \ found_job.status not in [RenderStatus.CANCELLED, RenderStatus.ERROR]: generate_thumbnail_for_job(found_job, thumb_video_path, thumb_image_path, max_width=240) + if big_thumb and os.path.exists(big_video_path) and not os.path.exists(big_video_path + '_IN-PROGRESS'): + return send_file(big_video_path, mimetype="video/mp4") + elif big_thumb and os.path.exists(big_image_path): + return send_file(big_image_path, mimetype='image/jpeg') + elif not os.path.exists(big_video_path) and not os.path.exists(big_image_path + '_IN-PROGRESS') and \ + found_job.status not in [RenderStatus.CANCELLED, RenderStatus.ERROR]: + generate_thumbnail_for_job(found_job, big_video_path, big_image_path, max_width=800) if os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS'): return send_file(thumb_video_path, mimetype="video/mp4") elif os.path.exists(thumb_image_path): @@ -110,7 +120,11 @@ def get_job_file(job_id, filename): @server.get('/api/jobs') def jobs_json(): - return [x.json() for x in RenderQueue.all_jobs()] + try: + return [x.json() for x in RenderQueue.all_jobs()] + except Exception as e: + logger.exception(f"Exception fetching all_jobs: {e}") + return [], 500 @server.get('/api/jobs/')