diff --git a/lib/job_server.py b/lib/job_server.py index c6a6d4e..eb3ac60 100755 --- a/lib/job_server.py +++ b/lib/job_server.py @@ -10,32 +10,67 @@ from datetime import datetime from zipfile import ZipFile import requests -from flask import Flask, request, render_template, send_file, after_this_request, Response, redirect, url_for +from flask import Flask, request, render_template, send_file, after_this_request, Response, redirect, url_for, abort from werkzeug.utils import secure_filename from lib.render_job import RenderJob from lib.render_queue import RenderQueue -from utilities.render_worker import RenderWorkerFactory, string_to_status +from lib.server_helper import post_job_to_server +from utilities.render_worker import RenderWorkerFactory, string_to_status, RenderStatus logger = logging.getLogger() server = Flask(__name__, template_folder='../templates') +categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED, + RenderStatus.COMPLETED, RenderStatus.CANCELLED] + + +def sorted_jobs(all_jobs, sort_by_date=True): + if not sort_by_date: + sorted_job_list = [] + if all_jobs: + for status_category in categories: + found_jobs = [x for x in all_jobs if x.render_status() == status_category.value] + if found_jobs: + sorted_found_jobs = sorted(found_jobs, key=lambda d: d.date_created, reverse=True) + sorted_job_list.extend(sorted_found_jobs) + else: + sorted_job_list = sorted(all_jobs, key=lambda d: d.date_created, reverse=True) + return sorted_job_list + @server.route('/') @server.route('/index') def index(): - return render_template('index.html', all_jobs=RenderQueue.job_queue, hostname=RenderQueue.host_name) + return render_template('index.html', all_jobs=sorted_jobs(RenderQueue.job_queue), hostname=RenderQueue.host_name, + renderer_info=renderer_info()) @server.route('/ui/job//full_details') def job_detail(job_id): found_job = RenderQueue.job_with_id(job_id) if found_job: - table_html = json2html.json2html.convert(json=found_job.json()) - return render_template('details.html', detail_table=table_html) + table_html = json2html.json2html.convert(json=found_job.json(), table_attributes='class="table is-narrow is-striped"') + media_url = None + if found_job.file_list(): + media_basename = os.path.basename(found_job.file_list()[0]) + media_url = f"/api/job/{job_id}/file/{media_basename}" + return render_template('details.html', detail_table=table_html, media_url=media_url) return f'Cannot find job with ID {job_id}', 400 +# Get job file routing +@server.route('/api/job//file/', methods=['GET']) +def get_job_file(job_id, filename): + found_job = RenderQueue.job_with_id(job_id) + try: + for full_path in found_job.file_list(): + if filename in full_path: + return send_file(path_or_file=full_path) + except FileNotFoundError: + abort(404) + + @server.get('/api/jobs') def jobs_json(): return [x.json() for x in RenderQueue.job_queue] @@ -169,8 +204,22 @@ def add_job_handler(): try: """Create new job and add to server render queue""" - if not request.form.get('json', None) and not request.is_json: - return 'missing json data', 400 + if request.is_json: + jobs_list = [request.json] + elif request.form.get('json', None): + jobs_list = json.loads(request.form['json']) + else: + form_dict = dict(request.form) + args = {} + arg_keys = [k for k in form_dict.keys() if '-arg_' in k] + for key in arg_keys: + if form_dict['renderer'] in key: + cleaned_key = key.split('-arg_')[-1] + args[cleaned_key] = form_dict[key] + form_dict.pop(key) + args['raw'] = form_dict.get('raw_args', None) + form_dict['args'] = args + jobs_list = [form_dict] # handle uploaded files uploaded_file = request.files.get('file', None) @@ -185,8 +234,6 @@ def add_job_handler(): uploaded_file.save(uploaded_file_local_path) # convert job input paths for uploaded files and add jobs - json_string = request.form.get('json', None) - jobs_list = json.loads(json_string) if json_string else [request.json] results = [] for job in jobs_list: if uploaded_file_local_path: @@ -205,7 +252,11 @@ def add_job_handler(): return results, response.get('code', 500) else: return results, 400 - return results + + if request.args.get('redirect', False): + return redirect(url_for('index')) + else: + return results except Exception as e: logger.exception(f"Unknown error adding job: {e}") @@ -296,7 +347,10 @@ def cancel_job(job_id): return 'Confirmation required to cancel job', 400 if RenderQueue.cancel_job(found_job): - return redirect(url_for('index')) + if request.args.get('redirect', False): + return redirect(url_for('index')) + else: + return "Job cancelled" else: return "Unknown error", 500 @@ -328,10 +382,13 @@ def delete_job(job_id): shutil.rmtree(input_dir) RenderQueue.delete_job(found_job) - return redirect(url_for('index')) + if request.args.get('redirect', False): + return redirect(url_for('index')) + else: + return "Job deleted", 200 except Exception as e: - return "Unknown error", 500 + return f"Unknown error: {e}", 500 @server.get('/api/clear_history') @@ -362,12 +419,3 @@ def upload_file_page(): return render_template('upload.html', render_clients=RenderQueue.render_clients, supported_renderers=RenderWorkerFactory.supported_renderers()) - -#todo: move this to a helper file -def post_job_to_server(input_path, job_list, client, server_port=8080): - # 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://{client}:{server_port}/api/add_job', files=job_files) - return req diff --git a/lib/server_helper.py b/lib/server_helper.py new file mode 100644 index 0000000..869ade9 --- /dev/null +++ b/lib/server_helper.py @@ -0,0 +1,12 @@ +import requests +import os +import json + + +def post_job_to_server(input_path, job_list, client, server_port=8080): + # 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://{client}:{server_port}/api/add_job', files=job_files) + return req diff --git a/lib/static/modals.js b/lib/static/modals.js new file mode 100644 index 0000000..e314dc7 --- /dev/null +++ b/lib/static/modals.js @@ -0,0 +1,44 @@ +document.addEventListener('DOMContentLoaded', () => { + // Functions to open and close a modal + function openModal($el) { + $el.classList.add('is-active'); + } + + function closeModal($el) { + $el.classList.remove('is-active'); + } + + function closeAllModals() { + (document.querySelectorAll('.modal') || []).forEach(($modal) => { + closeModal($modal); + }); + } + + // Add a click event on buttons to open a specific modal + (document.querySelectorAll('.js-modal-trigger') || []).forEach(($trigger) => { + const modal = $trigger.dataset.target; + const $target = document.getElementById(modal); + + $trigger.addEventListener('click', () => { + openModal($target); + }); + }); + + // Add a click event on various child elements to close the parent modal + (document.querySelectorAll('.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach(($close) => { + const $target = $close.closest('.modal'); + + $close.addEventListener('click', () => { + closeModal($target); + }); + }); + + // Add a keyboard event to close all modals + document.addEventListener('keydown', (event) => { + const e = event || window.event; + + if (e.keyCode === 27) { // Escape key + closeAllModals(); + } + }); +}); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index c0052af..7626759 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,7 +9,7 @@ - +
@@ -103,7 +62,7 @@ {{job.priority}} {{job.render_status().value}} {{job.time_elapsed()}} - {{job.percent_complete()}} + {{ '{0:0.0f}'.format(job.percent_complete() * 100) }}% {{job.frame_count()}} {{job.client}} {{job.worker.last_output}} @@ -116,7 +75,7 @@ {% if job.render_status().value in ['running', 'scheduled', 'not_started']: %} - {% elif job.render_status().value == 'completed': %} @@ -125,7 +84,7 @@ {{job.file_list() | length}} {% endif %} -
@@ -136,5 +95,130 @@ + + \ No newline at end of file diff --git a/utilities/blender_worker.py b/utilities/blender_worker.py index 465f130..ea68254 100644 --- a/utilities/blender_worker.py +++ b/utilities/blender_worker.py @@ -10,8 +10,8 @@ class BlenderRenderWorker(BaseRenderWorker): render_engine = 'blender' supported_extensions = ['.blend'] install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender'] - supported_export_formats = ['TGA', 'RAWTGA', 'JPEG', 'IRIS', 'IRIZ', 'AVIRAW', 'AVIJPEG', 'PNG', 'BMP', 'HDR', 'TIFF', - 'OPEN_EXR', 'OPEN_EXR_MULTILAYER', 'MPEG', 'CINEON', 'DPX', 'DDS', 'JP2'] + supported_export_formats = ['TGA', 'RAWTGA', 'JPEG', 'IRIS', 'IRIZ', 'AVIRAW', 'AVIJPEG', 'PNG', 'BMP', 'HDR', + 'TIFF', 'OPEN_EXR', 'OPEN_EXR_MULTILAYER', 'MPEG', 'CINEON', 'DPX', 'DDS', 'JP2'] def __init__(self, input_path, output_path, args=None): super(BlenderRenderWorker, self).__init__(input_path=input_path, output_path=output_path, @@ -29,10 +29,9 @@ class BlenderRenderWorker(BaseRenderWorker): # Scene Info self.scene_info = get_scene_info(input_path) - self.total_frames = int(self.scene_info.get('frame_end', 0)) + self.total_frames = (int(self.scene_info.get('frame_end', 0)) - int(self.scene_info.get('frame_start', 0)) + 1) \ + if self.render_all_frames else 1 self.current_frame = int(self.scene_info.get('frame_start', 0)) - self.resolution = {'x': int(self.scene_info.get('resolution_x', 0)), - 'y': int(self.scene_info.get('resolution_y', 0))} @classmethod def version(cls): @@ -61,6 +60,11 @@ class BlenderRenderWorker(BaseRenderWorker): # all frames or single cmd.extend(['-a'] if self.render_all_frames else ['-f', str(self.frame_to_render)]) + # Convert raw args from string if available + raw_args = self.args.get('raw', None) + if raw_args: + cmd.extend(raw_args.split(' ')) + return cmd def _parse_stdout(self, line): diff --git a/utilities/ffmpeg_worker.py b/utilities/ffmpeg_worker.py index 6457a5f..d2b0bd9 100644 --- a/utilities/ffmpeg_worker.py +++ b/utilities/ffmpeg_worker.py @@ -43,9 +43,14 @@ class FFMPEGRenderWorker(BaseRenderWorker): cmd = [self.renderer_path(), '-y', '-stats', '-i', self.input_path] if self.args: - cmd.extend(self.args) - cmd.append(self.output_path) + cmd.extend([x for x in self.args if x != 'raw']) + # Convert raw args from string if available + raw_args = self.args.get('raw', None) + print(raw_args) + if raw_args: + cmd.extend(raw_args.split(' ')) + cmd.append(self.output_path) return cmd def percent_complete(self):