Merge pull request #1 from blw1138/webui
Merge Initial Web UI to master
@@ -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:
|
||||
|
||||
@@ -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/<job_id>/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/<job_id>/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/<job_id>/file/<filename>', 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/<status_val>')
|
||||
@server.get('/api/jobs/<status_val>')
|
||||
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/<job_id>')
|
||||
@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/<job_id>')
|
||||
def get_job_status(job_id):
|
||||
return RenderQueue.job_with_id(job_id).json()
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/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/<job_id>')
|
||||
@server.get('/api/job/<job_id>/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/<job_id>')
|
||||
@server.route('/api/job/<job_id>/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/<job_id>/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/<job_id>/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
|
||||
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
43
lib/server_helper.py
Normal file
@@ -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()
|
||||
BIN
lib/static/images/cancelled.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
lib/static/images/desktop.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
lib/static/images/error.png
Normal file
|
After Width: | Height: | Size: 995 B |
BIN
lib/static/images/gears.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
lib/static/images/logo.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
lib/static/images/not_started.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
lib/static/images/scheduled.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
lib/static/images/spinner.gif
Normal file
|
After Width: | Height: | Size: 66 KiB |
71
lib/static/js/job_table.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const grid = new gridjs.Grid({
|
||||
columns: [
|
||||
{ data: (row) => row.id,
|
||||
name: 'Thumbnail',
|
||||
formatter: (cell) => gridjs.html(`<img src="/ui/job/${cell}/thumbnail" style='width: 320px; min-width: 240px;'>`),
|
||||
sort: {enabled: false}
|
||||
},
|
||||
{ id: 'name',
|
||||
name: 'Name',
|
||||
data: (row) => row.name,
|
||||
formatter: (name, row) => gridjs.html(`<a href="/ui/job/${row.cells[0].data}/full_details">${name}</a>`)
|
||||
},
|
||||
{ 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(`
|
||||
<span class="tag is-primary is-light ${(cell.status == 'running') ? 'is-hidden': ''}">${cell.status}</span>
|
||||
<progress class="progress is-primary ${(cell.status != 'running') ? 'is-hidden': ''}"
|
||||
value="${(parseFloat(cell.percent_complete) * 100.0)}" max="100">${cell.status}</progress>
|
||||
`)},
|
||||
{ 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(`<a href="/api/job/${row.cells[0].data}/logs">${output}</a>`)
|
||||
},
|
||||
{ data: (row) => row,
|
||||
name: 'Commands',
|
||||
formatter: (cell, row) => gridjs.html(`
|
||||
<div class="field has-addons" style='white-space: nowrap; display: inline-block;'>
|
||||
<button class="button is-info" onclick="window.location.href='/ui/job/${row.cells[0].data}/full_details';">
|
||||
<span class="icon"><i class="fa-solid fa-info"></i></span>
|
||||
</button>
|
||||
<button class="button is-link" onclick="window.location.href='/api/job/${row.cells[0].data}/logs';">
|
||||
<span class="icon"><i class="fa-regular fa-file-lines"></i></span>
|
||||
</button>
|
||||
<button class="button is-warning is-active ${(cell.status != 'running') ? 'is-hidden': ''}" onclick="window.location.href='/api/job/${row.cells[0].data}/cancel?confirm=True&redirect=True';">
|
||||
<span class="icon"><i class="fa-solid fa-x"></i></span>
|
||||
</button>
|
||||
<button class="button is-success ${(cell.status != 'completed') ? 'is-hidden': ''}" onclick="window.location.href='/api/job/${row.cells[0].data}/download_all';">
|
||||
<span class="icon"><i class="fa-solid fa-download"></i></span>
|
||||
<span>${cell.file_list.length}</span>
|
||||
</button>
|
||||
<button class="button is-danger" onclick="window.location.href='/api/job/${row.cells[0].data}/delete?confirm=True&redirect=True'">
|
||||
<span class="icon"><i class="fa-regular fa-trash-can"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
`),
|
||||
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'));
|
||||
44
lib/static/js/modals.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,4 +7,5 @@ rich==12.6.0
|
||||
ffmpeg-python
|
||||
Werkzeug~=2.2.2
|
||||
tkinterdnd2~=0.3.0
|
||||
future~=0.18.2
|
||||
future~=0.18.2
|
||||
json2html~=1.3.0
|
||||
@@ -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]
|
||||
|
||||
@@ -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__':
|
||||
|
||||
48
templates/details.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container" style="text-align:center; width: 100%">
|
||||
<br>
|
||||
{% if media_url: %}
|
||||
<video width="1280" height="720" controls>
|
||||
<source src="{{media_url}}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{% elif job_status == 'Running': %}
|
||||
<div style="width: 100%; height: 720px; position: relative; background: black; text-align: center; color: white;">
|
||||
<img src="/static/images/gears.png" style="vertical-align: middle; width: auto; height: auto; position:absolute; margin: auto; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<span style="height: auto; position:absolute; margin: auto; top: 58%; left: 0; right: 0; color: white; width: 60%">
|
||||
<progress class="progress is-primary" value="{{job.percent_complete() * 100}}" max="100" style="margin-top: 6px;" id="progress-bar">Rendering</progress>
|
||||
Rendering in Progress - <span id="percent-complete">{{(job.percent_complete() * 100) | int}}%</span>
|
||||
<br>Time Elapsed: <span id="time-elapsed">{{job.time_elapsed()}}</span>
|
||||
</span>
|
||||
<script>
|
||||
var startingStatus = '{{job.render_status().value}}';
|
||||
function update_job() {
|
||||
$.getJSON('/api/job/{{job.id}}', function(data) {
|
||||
document.getElementById('progress-bar').value = (data.percent_complete * 100);
|
||||
document.getElementById('percent-complete').innerHTML = (data.percent_complete * 100).toFixed(0) + '%';
|
||||
document.getElementById('time-elapsed').innerHTML = data.time_elapsed;
|
||||
if (data.status != startingStatus){
|
||||
clearInterval(renderingTimer);
|
||||
window.location.reload(true);
|
||||
};
|
||||
});
|
||||
}
|
||||
if (startingStatus == 'running'){
|
||||
var renderingTimer = setInterval(update_job, 2000);
|
||||
};
|
||||
</script>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="width: 100%; height: 720px; position: relative; background: black;">
|
||||
<img src="/static/images/{{job_status}}.png" style="vertical-align: middle; width: auto; height: auto; position:absolute; margin: auto; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<span style="height: auto; position:absolute; margin: auto; top: 58%; left: 0; right: 0; color: white;">
|
||||
{{job_status}}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<br>
|
||||
{{detail_table|safe}}
|
||||
</div>
|
||||
{% endblock %}
|
||||
8
templates/index.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container is-fluid" style="padding-top: 20px;">
|
||||
<div id="table" class="table"></div>
|
||||
</div>
|
||||
<script src="/static/js/job_table.js"></script>
|
||||
{% endblock %}
|
||||
236
templates/layout.html
Normal file
@@ -0,0 +1,236 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zordon Dashboard</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gridjs/dist/gridjs.umd.js"></script>
|
||||
<link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
|
||||
<script src="https://kit.fontawesome.com/698705d14d.js" crossorigin="anonymous"></script>
|
||||
<script type="text/javascript" src="/static/js/modals.js"></script>
|
||||
</head>
|
||||
<body onload="rendererChanged(document.getElementById('renderer'))">
|
||||
|
||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img src="/static/images/logo.png">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="navbarBasicExample" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="/">
|
||||
Home
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<button class="button is-primary js-modal-trigger" data-target="add-job-modal">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
</span>
|
||||
<span>Submit Job</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% block body %}
|
||||
{% endblock %}
|
||||
|
||||
<div id="add-job-modal" class="modal">
|
||||
<!-- Start Add Form -->
|
||||
<form id="submit_job" action="/api/add_job?redirect=True" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Submit New Job</p>
|
||||
<button class="delete" aria-label="close" type="button"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<!-- File Uploader -->
|
||||
|
||||
<label class="label">Upload File</label>
|
||||
<div id="file-uploader" class="file has-name is-fullwidth">
|
||||
<label class="file-label">
|
||||
<input class="file-input is-small" type="file" name="file">
|
||||
<span class="file-cta">
|
||||
<span class="file-icon">
|
||||
<i class="fas fa-upload"></i>
|
||||
</span>
|
||||
<span class="file-label">
|
||||
Choose a file…
|
||||
</span>
|
||||
</span>
|
||||
<span class="file-name">
|
||||
No File Uploaded
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<br>
|
||||
<script>
|
||||
const fileInput = document.querySelector('#file-uploader input[type=file]');
|
||||
fileInput.onchange = () => {
|
||||
if (fileInput.files.length > 0) {
|
||||
const fileName = document.querySelector('#file-uploader .file-name');
|
||||
fileName.textContent = fileInput.files[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
const presets = {
|
||||
{% for preset in preset_list: %}
|
||||
{{preset}}: {
|
||||
name: '{{preset_list[preset]['name']}}',
|
||||
renderer: '{{preset_list[preset]['renderer']}}',
|
||||
args: '{{preset_list[preset]['args']}}',
|
||||
},
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
function rendererChanged(ddl1) {
|
||||
|
||||
var renderers = {
|
||||
{% for renderer in renderer_info: %}
|
||||
{% if renderer_info[renderer]['supported_export_formats']: %}
|
||||
{{renderer}}: [
|
||||
{% for format in renderer_info[renderer]['supported_export_formats']: %}
|
||||
'{{format}}',
|
||||
{% endfor %}
|
||||
],
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
var selectedRenderer = ddl1.value;
|
||||
|
||||
var ddl3 = document.getElementById('preset_list');
|
||||
ddl3.options.length = 0;
|
||||
createOption(ddl3, '-Presets-', '');
|
||||
for (var preset_name in presets) {
|
||||
if (presets[preset_name]['renderer'] == selectedRenderer) {
|
||||
createOption(ddl3, presets[preset_name]['name'], preset_name);
|
||||
};
|
||||
};
|
||||
document.getElementById('raw_args').value = "";
|
||||
|
||||
var ddl2 = document.getElementById('export_format');
|
||||
ddl2.options.length = 0;
|
||||
var options = renderers[selectedRenderer];
|
||||
for (i = 0; i < options.length; i++) {
|
||||
createOption(ddl2, options[i], options[i]);
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(ddl, text, value) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.text = text;
|
||||
ddl.options.add(opt);
|
||||
}
|
||||
|
||||
function addPresetTextToInput(presetfield, textfield) {
|
||||
var p = presets[presetfield.value];
|
||||
textfield.value = p['args'];
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Renderer & Priority -->
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<label class="label">Renderer</label>
|
||||
<span class="select">
|
||||
<select id="renderer" name="renderer" onchange="rendererChanged(this)">
|
||||
{% for renderer in renderer_info: %}
|
||||
<option name="renderer" value="{{renderer}}">{{renderer}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control">
|
||||
<label class="label">Client</label>
|
||||
<span class="select">
|
||||
<select name="client">
|
||||
<option name="client" value="">First Available</option>
|
||||
{% for client in render_clients: %}
|
||||
<option name="client" value="{{client}}">{{client}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control">
|
||||
<label class="label">Priority</label>
|
||||
<span class="select">
|
||||
<select name="priority">
|
||||
<option name="priority" value="1">1</option>
|
||||
<option name="priority" value="2" selected="selected">2</option>
|
||||
<option name="priority" value="3">3</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Output Path -->
|
||||
<label class="label">Output</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input class="input is-small" type="text" placeholder="Output Name" name="output_path" value="output.mp4">
|
||||
</div>
|
||||
<p class="control">
|
||||
<span class="select is-small">
|
||||
<select id="export_format" name="export_format">
|
||||
<option value="ar">option</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Resolution -->
|
||||
<!-- <label class="label">Resolution</label>-->
|
||||
<!-- <div class="field is-grouped">-->
|
||||
<!-- <p class="control">-->
|
||||
<!-- <input class="input" type="text" placeholder="auto" maxlength="5" size="8" name="AnyRenderer-arg_x_resolution">-->
|
||||
<!-- </p>-->
|
||||
<!-- <label class="label"> x </label>-->
|
||||
<!-- <p class="control">-->
|
||||
<!-- <input class="input" type="text" placeholder="auto" maxlength="5" size="8" name="AnyRenderer-arg_y_resolution">-->
|
||||
<!-- </p>-->
|
||||
<!-- <label class="label"> @ </label>-->
|
||||
<!-- <p class="control">-->
|
||||
<!-- <input class="input" type="text" placeholder="auto" maxlength="3" size="5" name="AnyRenderer-arg_frame_rate">-->
|
||||
<!-- </p>-->
|
||||
<!-- <label class="label"> fps </label>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<label class="label">Command Line Arguments</label>
|
||||
<div class="field has-addons">
|
||||
<p class="control">
|
||||
<span class="select is-small">
|
||||
<select id="preset_list" onchange="addPresetTextToInput(this, document.getElementById('raw_args'))">
|
||||
<option value="preset-placeholder">presets</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control is-expanded">
|
||||
<input class="input is-small" type="text" placeholder="Args" id="raw_args" name="raw_args">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- End Add Form -->
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<input class="button is-link" type="submit"/>
|
||||
<button class="button" type="button">Cancel</button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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):
|
||||
|
||||
@@ -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> -- name of the Computer Group to use.
|
||||
# --Batch Specific Info:--
|
||||
# -batchname <name> -- name to be given to the batch.
|
||||
# -priority <value> -- 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> -- 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> -- path to settings file.
|
||||
# -locationpath <path> -- path to location file.
|
||||
# -info <xml> -- xml for job info.
|
||||
# -jobaction <xml> -- xml for job action.
|
||||
# -scc <url> -- url to scc file for source
|
||||
# -startoffset <hh:mm:ss;ff> -- time offset from beginning
|
||||
# -in <hh:mm:ss;ff> -- in time
|
||||
# -out <hh:mm:ss;ff> -- out time
|
||||
# -annotations <path> -- path to file to import annotations from; a plist file or a Quicktime movie
|
||||
# -chapters <path> -- path to file to import chapters from
|
||||
# --Optional Info:--
|
||||
# -help -- Displays, on stdout, this help information.
|
||||
# -checkstream <url> -- url to source file to analyze
|
||||
# -findletterbox <url> -- 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 <id> -- unique id of the job usually obtained when job was submitted.
|
||||
# -batchid <id> -- unique id of the batch usually obtained when job was submitted.
|
||||
# -query <seconds> -- The value in seconds, specifies how often to query the cluster for job status.
|
||||
# -timeout <seconds> -- 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 <on/off> -- 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 <number> -- Enables additional Compressor instances.
|
||||
#
|
||||
# -networkInterface <bsdname> -- Specify which network interface to use. If "all" is specified for <bsdname>, all available network interfaces are used.
|
||||
#
|
||||
# -portRange <startNumber> <count> -- Defines what port range use, using start number specifying how many ports to use.
|
||||
#
|
||||
# --File Modification Options (all other parameters ignored):--
|
||||
# -relabelaudiotracks <layout[1] layout[2]... layout[N]
|
||||
# Supported values:
|
||||
# Ls : Left Surround
|
||||
# R : Right
|
||||
# C : Center
|
||||
# Rs : Right Surround
|
||||
# Lt : Left Total
|
||||
# L : Left
|
||||
# Rt : Right Total
|
||||
# LFE : LFE Screen
|
||||
# Lc : Left Center
|
||||
# Rls : Rear Surround Left
|
||||
# mono : Mono
|
||||
# LtRt : Matrix Stereo (Lt Rt)
|
||||
# Rc : Right Center
|
||||
# stereo : Stereo (L R)
|
||||
# Rrs : Rear Surround Right
|
||||
# -jobpath <url> -- url to source file. - Must be a QuickTime Movie file
|
||||
# --Optional Info:--
|
||||
# -renametrackswithlayouts (Optional, rename the tracks with the new channel layouts)
|
||||
# -locationpath <path> -- 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
8
utilities/presets.yaml
Normal file
@@ -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"
|
||||
@@ -32,6 +32,7 @@ class BaseRenderWorker(object):
|
||||
|
||||
renderer = 'BaseRenderWorker'
|
||||
render_engine = None
|
||||
render_engine_version = None
|
||||
supported_extensions = []
|
||||
install_paths = []
|
||||
supported_export_formats = []
|
||||
|
||||