diff --git a/dashboard.py b/dashboard.py index 311230d..0b05320 100755 --- a/dashboard.py +++ b/dashboard.py @@ -202,7 +202,7 @@ class RenderServerProxy: def request_data(self, payload, timeout=5): try: - req = requests.get(f'http://{self.hostname}:{self.port}/{payload}', timeout=timeout) + req = requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout) if req.ok: return req.json() except Exception as e: diff --git a/lib/job_server.py b/lib/job_server.py index 3bf7f25..bedb010 100755 --- a/lib/job_server.py +++ b/lib/job_server.py @@ -1,62 +1,147 @@ #!/usr/bin/env python3 - import json import logging import os import pathlib import shutil +import json2html from datetime import datetime from zipfile import ZipFile import requests -from flask import Flask, request, render_template, send_file, after_this_request +import yaml +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.render_queue import RenderQueue, JobNotFoundError +from lib.server_helper import post_job_to_server, generate_thumbnail_for_job +from utilities.render_worker import RenderWorkerFactory, string_to_status, RenderStatus logger = logging.getLogger() -server = Flask(__name__) +server = Flask(__name__, template_folder='../templates') + +categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED, + RenderStatus.COMPLETED, RenderStatus.CANCELLED] -@server.get('/jobs') +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(): + + with open('utilities/presets.yaml') as f: + presets = yaml.load(f, Loader=yaml.FullLoader) + + return render_template('index.html', all_jobs=sorted_jobs(RenderQueue.job_queue), hostname=RenderQueue.host_name, + renderer_info=renderer_info(), render_clients=RenderQueue.render_clients, preset_list=presets) + + +@server.route('/ui/job//full_details') +def job_detail(job_id): + found_job = RenderQueue.job_with_id(job_id) + table_html = json2html.json2html.convert(json=found_job.json(), + table_attributes='class="table is-narrow is-striped is-fullwidth"') + media_url = None + if found_job.file_list() and found_job.render_status() == RenderStatus.COMPLETED: + 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, + hostname=RenderQueue.host_name, job_status=found_job.render_status().value.title(), + job=found_job, renderer_info=renderer_info()) + + +@server.route('/ui/job//thumbnail') +def job_thumbnail(job_id): + 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') + + if not os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS'): + generate_thumbnail_for_job(found_job, thumb_video_path, thumb_image_path, max_width=240) + + 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): + return send_file(thumb_image_path, mimetype='image/jpeg') + return send_file('static/images/spinner.gif', mimetype="image/gif") + + +# 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_safe_copy() for x in RenderQueue.job_queue] + return [x.json() for x in RenderQueue.job_queue] -@server.get('/jobs/') +@server.get('/api/jobs/') def filtered_jobs_json(status_val): state = string_to_status(status_val) - jobs = [x.json_safe_copy() for x in RenderQueue.jobs_with_status(state)] + jobs = [x.json() for x in RenderQueue.jobs_with_status(state)] if jobs: return jobs else: return f'Cannot find jobs with status {status_val}', 400 -@server.get('/job_status/') +@server.errorhandler(JobNotFoundError) +def handle_job_not_found(job_error): + return f'Cannot find job with ID {job_error.job_id}', 400 + + +@server.get('/api/job/') def get_job_status(job_id): + return RenderQueue.job_with_id(job_id).json() + + +@server.get('/api/job//logs') +def get_job_logs(job_id): found_job = RenderQueue.job_with_id(job_id) - if found_job: - return found_job.json_safe_copy() - else: - return f'Cannot find job with ID {job_id}', 400 + log_path = found_job.worker.log_path + log_data = None + if log_path and os.path.exists(log_path): + with open(log_path) as file: + log_data = file.read() + return Response(log_data, mimetype='text/plain') -@server.get('/file_list/') +@server.get('/api/job//file_list') def get_file_list(job_id): found_job = RenderQueue.job_with_id(job_id) if found_job: - job_dir = os.path.dirname(found_job.worker.output_path) - return os.listdir(job_dir) + return '\n'.join(found_job.file_list()) else: return f'Cannot find job with ID {job_id}', 400 -@server.route('/download_all/') +@server.route('/api/job//download_all') def download_all(job_id): - zip_filename = None @after_this_request @@ -66,41 +151,38 @@ def download_all(job_id): return response found_job = RenderQueue.job_with_id(job_id) - if found_job: - output_dir = os.path.dirname(found_job.worker.output_path) - if os.path.exists(output_dir): - zip_filename = pathlib.Path(found_job.worker.input_path).stem + '.zip' - with ZipFile(zip_filename, 'w') as zipObj: - for f in os.listdir(output_dir): - zipObj.write(filename=os.path.join(output_dir, f), - arcname=os.path.basename(f)) - return send_file(zip_filename, mimetype="zip", as_attachment=True, ) - else: - return f'Cannot find project files for job {job_id}', 500 + output_dir = os.path.dirname(found_job.worker.output_path) + if os.path.exists(output_dir): + zip_filename = os.path.join('/tmp', pathlib.Path(found_job.worker.input_path).stem + '.zip') + with ZipFile(zip_filename, 'w') as zipObj: + for f in os.listdir(output_dir): + zipObj.write(filename=os.path.join(output_dir, f), + arcname=os.path.basename(f)) + return send_file(zip_filename, mimetype="zip", as_attachment=True, ) else: - return f'Cannot find job with ID {job_id}', 400 + return f'Cannot find project files for job {job_id}', 500 -@server.post('/register_client') +@server.post('/api/register_client') def register_client(): client_hostname = request.values['hostname'] x = RenderQueue.register_client(client_hostname) return "Success" if x else "Fail" -@server.post('/unregister_client') +@server.post('/api/unregister_client') def unregister_client(): client_hostname = request.values['hostname'] x = RenderQueue.unregister_client(client_hostname) return "Success" if x else "Fail" -@server.get('/clients') +@server.get('/api/clients') def render_clients(): return RenderQueue.render_clients -@server.get('/full_status') +@server.get('/api/full_status') def full_status(): full_results = {'timestamp': datetime.now().isoformat(), 'servers': {}} @@ -127,46 +209,57 @@ def full_status(): return full_results -@server.get('/snapshot') +@server.get('/api/snapshot') def snapshot(): server_status = RenderQueue.status() - server_jobs = [x.json_safe_copy() for x in RenderQueue.job_queue] + server_jobs = [x.json() for x in RenderQueue.job_queue] server_data = {'status': server_status, 'jobs': server_jobs, 'timestamp': datetime.now().isoformat()} return server_data -@server.post('/add_job') +@server.post('/api/add_job') def add_job_handler(): - try: """Create new job and add to server render queue""" - json_string = request.form.get('json', None) - uploaded_file = request.files.get('file', None) - if not json_string: - 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: + # Cleanup flat form data into nested structure + form_dict = {k: v for k, v in dict(request.form).items() if v} + 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 or 'AnyRenderer' 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) uploaded_file_local_path = None job_dir = None if uploaded_file and uploaded_file.filename: logger.info(f"Receiving uploaded file {uploaded_file.filename}") - new_id = RenderJob.generate_id() - job_dir = os.path.join(server.config['UPLOAD_FOLDER'], new_id + "-" + uploaded_file.filename) - if not os.path.exists(job_dir): - os.makedirs(job_dir) + job_dir = os.path.join(server.config['UPLOAD_FOLDER'], (uploaded_file.filename + "_" + + datetime.now().strftime("%Y.%m.%d_%H.%M.%S"))) + os.makedirs(job_dir, exist_ok=True) uploaded_file_local_path = os.path.join(job_dir, secure_filename(uploaded_file.filename)) uploaded_file.save(uploaded_file_local_path) # convert job input paths for uploaded files and add jobs - jobs_list = json.loads(json_string) results = [] for job in jobs_list: if uploaded_file_local_path: job['input_path'] = uploaded_file_local_path - output_dir = os.path.join(job_dir, job.get('name', 'output')) + output_dir = os.path.join(job_dir, job.get('name', None) or 'output') os.makedirs(output_dir, exist_ok=True) job['output_path'] = os.path.join(output_dir, os.path.basename(job['output_path'])) - remove_job_dir = len(jobs_list) == 1 # remove failed job dir for single file uploads only + remove_job_dir = len(jobs_list) == 1 and uploaded_file_local_path # remove failed job dir for single file uploads only add_result = add_job(job, remove_job_dir_on_failure=remove_job_dir) results.append(add_result) @@ -177,7 +270,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}") @@ -185,7 +282,6 @@ def add_job_handler(): def add_job(job_params, remove_job_dir_on_failure=False): - def remove_job_dir(): if remove_job_dir_on_failure and job_dir and os.path.exists(job_dir): logger.debug(f"Removing job dir: {job_dir}") @@ -198,7 +294,7 @@ def add_job(job_params, remove_job_dir_on_failure=False): output_path = job_params.get("output_path", None) priority = int(job_params.get('priority', 2)) args = job_params.get('args', {}) - client = job_params.get('client', RenderQueue.host_name) + client = job_params.get('client', None) or RenderQueue.host_name force_start = job_params.get('force_start', False) custom_id = None job_dir = None @@ -216,7 +312,7 @@ def add_job(job_params, remove_job_dir_on_failure=False): render_job = RenderJob(renderer, input_path, output_path, args, priority, job_owner, client, notify=False, custom_id=custom_id, name=name) RenderQueue.add_to_render_queue(render_job, force_start=force_start) - return render_job.json_safe_copy() + return render_job.json() except Exception as e: err_msg = f"Error creating job: {str(e)}" logger.exception(err_msg) @@ -259,36 +355,75 @@ def add_job(job_params, remove_job_dir_on_failure=False): return {'error': err_msg, 'code': 400} -@server.get('/cancel_job') -def cancel_job(): - job_id = request.args.get('id', None) - confirm = request.args.get('confirm', False) - if not job_id: - return 'job id not found', 400 - elif not confirm: - return 'confirmation required', 400 +@server.get('/api/job//cancel') +def cancel_job(job_id): + if not request.args.get('confirm', False): + return 'Confirmation required to cancel job', 400 + + if RenderQueue.cancel_job(RenderQueue.job_with_id(job_id)): + if request.args.get('redirect', False): + return redirect(url_for('index')) + else: + return "Job cancelled" else: - found = [x for x in RenderQueue.job_queue if x.id == job_id] - if len(found) > 1: - return f'multiple jobs found for ID {job_id}', 400 - elif found: - success = RenderQueue.cancel_job(found[0]) - return success - return 'job not found', 400 + return "Unknown error", 500 -@server.get('/clear_history') +@server.route('/api/job//delete', methods=['POST', 'GET']) +def delete_job(job_id): + try: + if not request.args.get('confirm', False): + return 'Confirmation required to delete job', 400 + + # First, remove all render files and logs + found_job = RenderQueue.job_with_id(job_id) + files_to_delete = found_job.file_list() + files_to_delete.append(found_job.log_path()) + for d in files_to_delete: + if os.path.exists(d): + os.remove(d) + + # Check if we can remove the 'output' directory + output_dir = os.path.dirname(files_to_delete[0]) + if server.config['UPLOAD_FOLDER'] in output_dir and os.path.exists(output_dir): + shutil.rmtree(output_dir) + + # Remove any thumbnails + for filename in os.listdir(server.config['THUMBS_FOLDER']): + if job_id in filename: + os.remove(os.path.join(server.config['THUMBS_FOLDER'], filename)) + + thumb_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.mp4') + if os.path.exists(thumb_path): + os.remove(thumb_path) + + # See if we own the input file (i.e. was it uploaded) + input_dir = os.path.dirname(found_job.worker.input_path) + if server.config['UPLOAD_FOLDER'] in input_dir and os.path.exists(input_dir): + shutil.rmtree(input_dir) + + RenderQueue.delete_job(found_job) + if request.args.get('redirect', False): + return redirect(url_for('index')) + else: + return "Job deleted", 200 + + except Exception as e: + return f"Unknown error: {e}", 500 + + +@server.get('/api/clear_history') def clear_history(): RenderQueue.clear_history() return 'success' -@server.route('/status') +@server.route('/api/status') def status(): return RenderQueue.status() -@server.get('/renderer_info') +@server.get('/api/renderer_info') def renderer_info(): renderer_data = {} for r in RenderWorkerFactory.supported_renderers(): @@ -300,22 +435,7 @@ def renderer_info(): return renderer_data -@server.route('/') -def default(): - return "Server running" - - @server.route('/upload') 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}/add_job', files=job_files) - return req diff --git a/lib/render_job.py b/lib/render_job.py index 640af8e..8e3e6fb 100644 --- a/lib/render_job.py +++ b/lib/render_job.py @@ -1,3 +1,4 @@ +import glob import hashlib import json import logging @@ -22,7 +23,7 @@ class RenderJob: self.date_created = datetime.now() self.scheduled_start = None self.renderer = renderer - self.name = name or os.path.basename(input_path) + '_' + self.date_created.isoformat() + self.name = name or os.path.basename(input_path) + '_' + self.date_created.strftime("%Y.%m.%d_%H.%M.%S") self.worker = RenderWorkerFactory.create_worker(renderer, input_path, output_path, args) self.worker.log_path = os.path.join(os.path.dirname(input_path), self.name + '.log') @@ -38,14 +39,16 @@ class RenderJob: return hashlib.md5(open(self.worker.input_path, 'rb').read()).hexdigest() return None - def json_safe_copy(self): + def json(self): """Converts RenderJob into JSON-friendly dict""" - import numbers job_dict = None try: job_dict = self.__dict__.copy() job_dict['status'] = self.render_status().value - job_dict['file_hash'] = self.file_hash if isinstance(self.file_hash, str) else self.file_hash() + job_dict['time_elapsed'] = self.time_elapsed() if type(self.time_elapsed) != str else self.time_elapsed + job_dict['file_hash'] = self.file_hash if self.file_hash and isinstance(self.file_hash, str) else self.file_hash() + job_dict['percent_complete'] = self.percent_complete() + job_dict['file_list'] = self.file_list() job_dict['worker'] = self.worker.__dict__.copy() job_dict['worker']['status'] = job_dict['status'] @@ -57,13 +60,6 @@ class RenderJob: for key in keys_to_remove: job_dict['worker'].pop(key, None) - # jobs from current_session generate percent completed - # jobs after loading server pull in a saved value. Have to check if callable object or not - - percent_complete = self.worker.percent_complete if isinstance(self.worker.percent_complete, numbers.Number) \ - else self.worker.percent_complete() - job_dict['worker']['percent_complete'] = percent_complete - # convert to json and back to auto-convert dates to iso format def date_serializer(o): if isinstance(o, datetime): @@ -81,6 +77,53 @@ class RenderJob: def stop(self): self.worker.stop() + def time_elapsed(self): + + from string import Template + + class DeltaTemplate(Template): + delimiter = "%" + + def strfdelta(tdelta, fmt='%H:%M:%S'): + d = {"D": tdelta.days} + hours, rem = divmod(tdelta.seconds, 3600) + minutes, seconds = divmod(rem, 60) + d["H"] = '{:02d}'.format(hours) + d["M"] = '{:02d}'.format(minutes) + d["S"] = '{:02d}'.format(seconds) + t = DeltaTemplate(fmt) + return t.substitute(**d) + + # calculate elapsed time + elapsed_time = None + start_time = self.worker.start_time + end_time = self.worker.end_time + + if start_time: + if end_time: + elapsed_time = end_time - start_time + elif self.render_status() == RenderStatus.RUNNING: + elapsed_time = datetime.now() - start_time + + elapsed_time_string = strfdelta(elapsed_time) if elapsed_time else "Unknown" + return elapsed_time_string + + def frame_count(self): + return self.worker.total_frames + + def work_path(self): + return os.path.dirname(self.worker.output_path) + + def file_list(self): + job_dir = os.path.dirname(self.worker.output_path) + return glob.glob(os.path.join(job_dir, '*')) + + def log_path(self): + return self.worker.log_path + + def percent_complete(self): + return self.worker.percent_complete() + @classmethod def generate_id(cls): return str(uuid.uuid4()).split('-')[0] \ No newline at end of file diff --git a/lib/render_queue.py b/lib/render_queue.py index 8362adb..9fbe061 100755 --- a/lib/render_queue.py +++ b/lib/render_queue.py @@ -16,6 +16,12 @@ JSON_FILE = 'server_state.json' #todo: move history to sqlite db +class JobNotFoundError(Exception): + def __init__(self, job_id, *args): + super().__init__(args) + self.job_id = job_id + + class RenderQueue: job_queue = [] render_clients = [] @@ -39,8 +45,6 @@ class RenderQueue: cls.job_queue.append(render_job) if force_start: cls.start_job(render_job) - else: - cls.evaluate_queue() else: # todo: implement client rendering logger.warning('remote client rendering not implemented yet') @@ -63,8 +67,10 @@ class RenderQueue: return found_jobs @classmethod - def job_with_id(cls, job_id): + def job_with_id(cls, job_id, none_ok=False): found_job = next((x for x in cls.job_queue if x.id == job_id), None) + if not found_job and not none_ok: + raise JobNotFoundError(job_id) return found_job @classmethod @@ -88,35 +94,39 @@ class RenderQueue: cls.render_clients = saved_state.get('clients', {}) for job in saved_state.get('jobs', []): + try: + render_job = RenderJob(renderer=job['renderer'], input_path=job['worker']['input_path'], + output_path=job['worker']['output_path'], args=job['worker']['args'], + priority=job['priority'], client=job['client']) - render_job = RenderJob(renderer=job['renderer'], input_path=job['worker']['input_path'], - output_path=job['worker']['output_path'], args=job['worker']['args'], - priority=job['priority'], client=job['client']) + # Load Worker values + for key, val in job['worker'].items(): + if val and key in ['start_time', 'end_time']: # convert date strings back into date objects + render_job.worker.__dict__[key] = datetime.fromisoformat(val) + else: + render_job.worker.__dict__[key] = val - # Load Worker values - for key, val in job['worker'].items(): - if val and key in ['start_time', 'end_time']: # convert date strings back into date objects - render_job.worker.__dict__[key] = datetime.fromisoformat(val) - else: - render_job.worker.__dict__[key] = val + render_job.worker.status = RenderStatus[job['status'].upper()] + job.pop('worker', None) - render_job.worker.status = RenderStatus[job['status'].upper()] - job.pop('worker', None) + # Create RenderJob with re-created Renderer object + for key, val in job.items(): + if key in ['date_created']: # convert date strings back to datetime objects + render_job.__dict__[key] = datetime.fromisoformat(val) + else: + import types + if hasattr(render_job, key): + if getattr(render_job, key) and not isinstance(getattr(render_job, key), types.MethodType): + render_job.__dict__[key] = val - # Create RenderJob with re-created Renderer object - for key, val in job.items(): - if key in ['date_created']: # convert date strings back to datetime objects - render_job.__dict__[key] = datetime.fromisoformat(val) - else: - render_job.__dict__[key] = val - render_job.__delattr__('status') + # Handle older loaded jobs that were cancelled before closing + if render_job.render_status() == RenderStatus.RUNNING: + render_job.worker.status = RenderStatus.CANCELLED - # Handle older loaded jobs that were cancelled before closing - if render_job.render_status() == RenderStatus.RUNNING: - render_job.worker.status = RenderStatus.CANCELLED - - # finally add back to render queue - cls.job_queue.append(render_job) + # finally add back to render queue + cls.job_queue.append(render_job) + except Exception as e: + logger.exception(f"Unable to load job: {job['id']} - {e}") cls.last_saved_counts = cls.job_counts() @@ -126,7 +136,7 @@ class RenderQueue: try: logger.debug("Saving Render History") output = {'timestamp': datetime.now().isoformat(), - 'jobs': [j.json_safe_copy() for j in cls.job_queue], + 'jobs': [j.json() for j in cls.job_queue], 'clients': cls.render_clients} output_path = json_path or JSON_FILE with open(output_path, 'w') as f: @@ -167,9 +177,16 @@ class RenderQueue: @classmethod def cancel_job(cls, job): - logger.info('Cancelling job ID: {}'.format(job.id)) + logger.info(f'Cancelling job ID: {job.id}') job.stop() - return job.render_status == RenderStatus.CANCELLED + return job.render_status() == RenderStatus.CANCELLED + + @classmethod + def delete_job(cls, job): + logger.info(f"Deleting job ID: {job.id}") + job.stop() + cls.job_queue.remove(job) + return True @classmethod def renderer_instances(cls): @@ -213,7 +230,7 @@ class RenderQueue: err_msg = f"Client '{hostname}' already registered" else: try: - response = requests.get(f"http://{hostname}:8080/status", timeout=3) + response = requests.get(f"http://{hostname}:8080/api/status", timeout=3) if response.ok: cls.render_clients.append(hostname) logger.info(f"Client '{hostname}' successfully registered") @@ -241,7 +258,7 @@ class RenderQueue: @staticmethod def is_client_available(client_hostname, timeout=3): try: - response = requests.get(f"http://{client_hostname}:8080/status", timeout=timeout) + response = requests.get(f"http://{client_hostname}:8080/api/status", timeout=timeout) if response.ok: return True except requests.ConnectionError as e: diff --git a/lib/server_helper.py b/lib/server_helper.py new file mode 100644 index 0000000..82b9cc5 --- /dev/null +++ b/lib/server_helper.py @@ -0,0 +1,43 @@ +import subprocess +import requests +import os +import json +import threading +from utilities.render_worker import RenderStatus +from utilities.ffmpeg_presets import generate_thumbnail, save_first_frame + + +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 + + +def generate_thumbnail_for_job(job, thumb_video_path, thumb_image_path, max_width=320): + + # Simple thread to generate thumbs in background + def generate_thumb_thread(source): + in_progress_path = thumb_video_path + '_IN-PROGRESS' + subprocess.run(['touch', in_progress_path]) + generate_thumbnail(source_path=source, dest_path=thumb_video_path, max_width=max_width) + os.remove(in_progress_path) + + # Determine best source file to use for thumbs + if job.render_status() == RenderStatus.COMPLETED: # use finished file for thumb + source_path = job.file_list() + elif len(job.file_list()) > 1: # if image sequence, use second to last file (last may be in use) + source_path = [job.file_list()[-2]] + else: + source_path = [job.worker.input_path] # use source if nothing else + + if source_path: + # Todo: convert image sequence to animated movie + valid_formats = ['.mp4', '.mov', '.avi', '.mpg', '.mpeg', '.jpg', '.png', '.exr', '.mxf'] + is_valid_file_type = any(ele in source_path[0] for ele in valid_formats) + if is_valid_file_type and not os.path.exists(thumb_video_path): + save_first_frame(source_path=source_path[0], dest_path=thumb_image_path, max_width=max_width) + x = threading.Thread(target=generate_thumb_thread, args=(source_path[0],)) + x.start() diff --git a/lib/static/images/cancelled.png b/lib/static/images/cancelled.png new file mode 100644 index 0000000..5f3b6a1 Binary files /dev/null and b/lib/static/images/cancelled.png differ diff --git a/lib/static/images/desktop.png b/lib/static/images/desktop.png new file mode 100644 index 0000000..b04158c Binary files /dev/null and b/lib/static/images/desktop.png differ diff --git a/lib/static/images/error.png b/lib/static/images/error.png new file mode 100644 index 0000000..1461b9f Binary files /dev/null and b/lib/static/images/error.png differ diff --git a/lib/static/images/gears.png b/lib/static/images/gears.png new file mode 100644 index 0000000..da1f1ed Binary files /dev/null and b/lib/static/images/gears.png differ diff --git a/lib/static/images/logo.png b/lib/static/images/logo.png new file mode 100644 index 0000000..9ffcebd Binary files /dev/null and b/lib/static/images/logo.png differ diff --git a/lib/static/images/not_started.png b/lib/static/images/not_started.png new file mode 100644 index 0000000..56462eb Binary files /dev/null and b/lib/static/images/not_started.png differ diff --git a/lib/static/images/scheduled.png b/lib/static/images/scheduled.png new file mode 100644 index 0000000..b5a91d9 Binary files /dev/null and b/lib/static/images/scheduled.png differ diff --git a/lib/static/images/spinner.gif b/lib/static/images/spinner.gif new file mode 100644 index 0000000..0fa0d3b Binary files /dev/null and b/lib/static/images/spinner.gif differ diff --git a/lib/static/js/job_table.js b/lib/static/js/job_table.js new file mode 100644 index 0000000..2538dc6 --- /dev/null +++ b/lib/static/js/job_table.js @@ -0,0 +1,71 @@ +const grid = new gridjs.Grid({ +columns: [ + { data: (row) => row.id, + name: 'Thumbnail', + formatter: (cell) => gridjs.html(``), + sort: {enabled: false} + }, + { id: 'name', + name: 'Name', + data: (row) => row.name, + formatter: (name, row) => gridjs.html(`${name}`) + }, + { id: 'renderer', data: (row) => `${row.renderer}-${row.worker.renderer_version}`, name: 'Renderer' }, + { id: 'priority', name: 'Priority' }, + { id: 'status', + name: 'Status', + data: (row) => row, + formatter: (cell, row) => gridjs.html(` + ${cell.status} + ${cell.status} + `)}, + { id: 'time_elapsed', name: 'Time Elapsed' }, + { data: (row) => row.worker.total_frames, name: 'Frame Count' }, + { id: 'client', name: 'Client'}, + { data: (row) => row.worker.last_output, + name: 'Last Output', + formatter: (output, row) => gridjs.html(`${output}`) + }, + { data: (row) => row, + name: 'Commands', + formatter: (cell, row) => gridjs.html(` +
+ + + + + +
+ `), + sort: false + } +], +style: { + table: { + 'white-space': 'nowrap' + }, + th: { + 'vertical-align': 'middle', + }, + td: { + 'vertical-align': 'middle', + } +}, +server: { + url: '/api/jobs', + then: results => results, +}, +sort: true, +}).render(document.getElementById('table')); \ No newline at end of file diff --git a/lib/static/js/modals.js b/lib/static/js/modals.js new file mode 100644 index 0000000..e314dc7 --- /dev/null +++ b/lib/static/js/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/requirements.txt b/requirements.txt index 5b370eb..6862f32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ rich==12.6.0 ffmpeg-python Werkzeug~=2.2.2 tkinterdnd2~=0.3.0 -future~=0.18.2 \ No newline at end of file +future~=0.18.2 +json2html~=1.3.0 \ No newline at end of file diff --git a/scheduler_gui.py b/scheduler_gui.py index b6f00e9..080f302 100755 --- a/scheduler_gui.py +++ b/scheduler_gui.py @@ -24,7 +24,7 @@ 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) + req = requests.get(f'http://{server_ip}:{server_port}/api/{payload}', timeout=timeout) if req.ok: return req.json() except Exception as e: @@ -334,11 +334,12 @@ class ScheduleJob(Frame): # 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) + if selected_cameras: + 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] diff --git a/start_server.py b/start_server.py index 9dadec6..16aff08 100755 --- a/start_server.py +++ b/start_server.py @@ -22,6 +22,7 @@ def start_server(background_thread=False): level=config.get('server_log_level', 'INFO').upper()) server.config['UPLOAD_FOLDER'] = os.path.expanduser(config['upload_folder']) + server.config['THUMBS_FOLDER'] = os.path.join(os.path.expanduser(config['upload_folder']), 'thumbs') server.config['MAX_CONTENT_PATH'] = config['max_content_path'] # Get hostname and render clients @@ -49,7 +50,7 @@ def start_server(background_thread=False): server_thread.join() else: server.run(host='0.0.0.0', port=RenderQueue.port, debug=config.get('flask_debug_enable', False), - use_reloader=False) + use_reloader=True, threaded=True) if __name__ == '__main__': diff --git a/templates/details.html b/templates/details.html new file mode 100644 index 0000000..76b200b --- /dev/null +++ b/templates/details.html @@ -0,0 +1,48 @@ +{% extends 'layout.html' %} + +{% block body %} +
+
+ {% if media_url: %} + + {% elif job_status == 'Running': %} +
+ + + Rendering + Rendering in Progress - {{(job.percent_complete() * 100) | int}}% +
Time Elapsed: {{job.time_elapsed()}} +
+ +
+ {% else %} +
+ + + {{job_status}} + +
+ {% endif %} +
+ {{detail_table|safe}} +
+{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f0de9ec --- /dev/null +++ b/templates/index.html @@ -0,0 +1,8 @@ +{% extends 'layout.html' %} + +{% block body %} +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..71ac978 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,236 @@ + + + + + + Zordon Dashboard + + + + + + + + + + + + +{% block body %} +{% endblock %} + + + + + \ 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/compressor.py b/utilities/compressor.py deleted file mode 100644 index f2747df..0000000 --- a/utilities/compressor.py +++ /dev/null @@ -1,121 +0,0 @@ -#! /usr/bin/python -from render_worker import * -import glob -import logging -import subprocess - -# Documentation -# https://help.apple.com/compressor/mac/4.0/en/compressor/usermanual/Compressor%204%20User%20Manual%20(en).pdf - -def compressor_path(): - return '/Applications/Compressor.app/Contents/MacOS/Compressor' - - -class CompressorRenderWorker(RenderWorker): - - renderer = 'Compressor' - - # Usage: Compressor [Cluster Info] [Batch Specific Info] [Optional Info] [Other Options] - # - # -computergroup -- name of the Computer Group to use. - # --Batch Specific Info:-- - # -batchname -- name to be given to the batch. - # -priority -- priority to be given to the batch. Possible values are: low, medium or high - # Job Info: Used when submitting individual source files. Following parameters are repeated to enter multiple job targets in a batch - # -jobpath -- url to source file. - # -- In case of Image Sequence, URL should be a file URL pointing to directory with image sequence. - # -- Additional URL query style parameters may be specified to set frameRate (file:///myImageSequenceDir?frameRate=29.97) and audio file (e.g. file:///myImageSequenceDir?audio=/usr/me/myaudiofile.mov). - # -settingpath -- path to settings file. - # -locationpath -- path to location file. - # -info -- xml for job info. - # -jobaction -- xml for job action. - # -scc -- url to scc file for source - # -startoffset -- time offset from beginning - # -in -- in time - # -out -- out time - # -annotations -- path to file to import annotations from; a plist file or a Quicktime movie - # -chapters -- path to file to import chapters from - # --Optional Info:-- - # -help -- Displays, on stdout, this help information. - # -checkstream -- url to source file to analyze - # -findletterbox -- url to source file to analyze - # - # --Batch Monitoring Info:-- - # Actions on Job: - # -monitor -- monitor the job or batch specified by jobid or batchid. - # -kill -- kill the job or batch specified by jobid or batchid. - # -pause -- pause the job or batch specified by jobid or batchid. - # -resume -- resume previously paused job or batch specified by jobid or batchid. - # Optional Info: - # -jobid -- unique id of the job usually obtained when job was submitted. - # -batchid -- unique id of the batch usually obtained when job was submitted. - # -query -- The value in seconds, specifies how often to query the cluster for job status. - # -timeout -- the timeOut value, in seconds, specifies when to quit the process. - # -once -- show job status only once and quit the process. - # - # --Sharing Related Options:-- - # -resetBackgroundProcessing [cancelJobs] -- Restart all processes used in background processing, and optionally cancel all queued jobs. - # - # -repairCompressor -- Repair Compressor config files and restart all processes used in background processing. - # - # -sharing -- Turn sharing of this computer on or off. - # - # -requiresPassword [password] -- Sharing of this computer requires specified password. Computer must not be busy processing jobs when you set the password. - # - # -noPassword -- Turn off the password requirement for sharing this computer. - # - # -instances -- Enables additional Compressor instances. - # - # -networkInterface -- Specify which network interface to use. If "all" is specified for , all available network interfaces are used. - # - # -portRange -- Defines what port range use, using start number specifying how many ports to use. - # - # --File Modification Options (all other parameters ignored):-- - # -relabelaudiotracks -- url to source file. - Must be a QuickTime Movie file - # --Optional Info:-- - # -renametrackswithlayouts (Optional, rename the tracks with the new channel layouts) - # -locationpath -- path to location file. Modified movie will be saved here. If unspecified, changes will be saved in place, overwriting the original file. - - def __init__(self, project, settings_path, output): - super(CompressorRenderWorker, self).__init__(project=project, output=output) - self.settings_path = settings_path - - self.batch_name = os.path.basename(project) - self.cluster_name = 'This Computer' - - self.timeout = 5 - - # /Applications/Compressor.app/Contents/MacOS/Compressor -clusterid "tcp://192.168.1.148:62995" -batchname "My First Batch" -jobpath ~/Movies/MySource.mov -settingpath ~/Library/Application\ Support/Compressor/Settings/MPEG-4.setting -destinationpath ~/Movies/MyOutput.mp4 -timeout 5 - - def _generate_subprocess(self): - x = [compressor_path(), '-batchname', datetime.now().isoformat(), '-jobpath', self.input, '-settingpath', self.settings_path, '-locationpath', self.output] - print(' '.join(x)) - return x - - def _parse_stdout(self, line): - print(line) - - -if __name__ == '__main__': - logging.basicConfig(format='%(asctime)s - %(message)s', datefmt='%d-%b-%y %H:%M:%S', level=logging.DEBUG) - r = CompressorRenderWorker('/Users/brett/Desktop/drone_raw.mp4', '/Applications/Compressor.app/Contents/Resources/Settings/Website Sharing/HD720WebShareName.compressorsetting', '/Users/brett/Desktop/test_drone_output.mp4') - r.start() - while r.is_running(): - time.sleep(1) \ No newline at end of file diff --git a/utilities/ffmpeg_presets.py b/utilities/ffmpeg_presets.py index 9e8f36a..afbdf84 100644 --- a/utilities/ffmpeg_presets.py +++ b/utilities/ffmpeg_presets.py @@ -9,9 +9,27 @@ def file_info(path): return None +def save_first_frame(source_path, dest_path, max_width=1280, run_async=False): + stream = ffmpeg.input(source_path) + stream = ffmpeg.output(stream, dest_path, **{'vf': f'format=yuv420p,scale={max_width}:trunc(ow/a/2)*2', + 'vframes': '1'}) + return _run_output(stream, run_async) + + def generate_fast_preview(source_path, dest_path, max_width=1280, run_async=False): stream = ffmpeg.input(source_path) - stream = ffmpeg.output(stream, dest_path, **{'vf': 'format=yuv420p,scale={width}:-2'.format(width=max_width), 'preset': 'ultrafast'}) + stream = ffmpeg.output(stream, dest_path, **{'vf': 'format=yuv420p,scale={width}:-2'.format(width=max_width), + 'preset': 'ultrafast'}) + return _run_output(stream, run_async) + + +def generate_thumbnail(source_path, dest_path, max_width=240, run_async=False): + stream = ffmpeg.input(source_path).video + stream = ffmpeg.output(stream, dest_path, **{'vf': f'scale={max_width}:trunc(ow/a/2)*2', + 'preset': 'veryfast', + 'r': '15', + 'c:v': 'libx265', + 'tag:v': 'hvc1'}) return _run_output(stream, run_async) @@ -24,9 +42,10 @@ def generate_prores_trim(source_path, dest_path, start_frame, end_frame, handles def _run_output(stream, run_async): - return ffmpeg.run_async(stream) if run_async else ffmpeg.run(stream) + return ffmpeg.run_async(stream, quiet=True, overwrite_output=True) if run_async else \ + ffmpeg.run(stream, quiet=True, overwrite_output=True) if __name__ == '__main__': - x = file_info("/Users/brettwilliams/Desktop/dark_knight_rises.mp4") + x = generate_thumbnail("/Users/brett/Desktop/pexels.mp4", "/Users/brett/Desktop/test-output.mp4", max_width=320) print(x) diff --git a/utilities/ffmpeg_worker.py b/utilities/ffmpeg_worker.py index 6457a5f..3a2b8e2 100644 --- a/utilities/ffmpeg_worker.py +++ b/utilities/ffmpeg_worker.py @@ -42,10 +42,18 @@ class FFMPEGRenderWorker(BaseRenderWorker): def _generate_subprocess(self): cmd = [self.renderer_path(), '-y', '-stats', '-i', self.input_path] - if self.args: - cmd.extend(self.args) - cmd.append(self.output_path) + # Resize frame + if self.args.get('x_resolution', None) and self.args.get('y_resolution', None): + cmd.extend(['-vf', f"scale={self.args['x_resolution']}:{self.args['y_resolution']}"]) + + # Convert raw args from string if available + raw_args = self.args.get('raw', None) + if raw_args: + cmd.extend(raw_args.split(' ')) + + # Close with output path + cmd.append(self.output_path) return cmd def percent_complete(self): diff --git a/utilities/presets.yaml b/utilities/presets.yaml new file mode 100644 index 0000000..e4d12ef --- /dev/null +++ b/utilities/presets.yaml @@ -0,0 +1,8 @@ +fast_preview_ffmpeg: + name: "Fast Preview - Full Res" + renderer: "ffmpeg" + args: "-vf format=yuv420p -preset ultrafast" +fast_preview_ffmpeg_720: + name: "Fast Preview - 720p" + renderer: "ffmpeg" + args: "-vf format=yuv420p,scale=720:-2 -preset ultrafast" \ No newline at end of file diff --git a/utilities/render_worker.py b/utilities/render_worker.py index c4714e1..42fca3c 100644 --- a/utilities/render_worker.py +++ b/utilities/render_worker.py @@ -32,6 +32,7 @@ class BaseRenderWorker(object): renderer = 'BaseRenderWorker' render_engine = None + render_engine_version = None supported_extensions = [] install_paths = [] supported_export_formats = []