Merge pull request #1 from blw1138/webui

Merge Initial Web UI to master
This commit is contained in:
2022-12-14 19:20:59 -08:00
committed by GitHub
27 changed files with 823 additions and 271 deletions

View File

@@ -202,7 +202,7 @@ class RenderServerProxy:
def request_data(self, payload, timeout=5): def request_data(self, payload, timeout=5):
try: 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: if req.ok:
return req.json() return req.json()
except Exception as e: except Exception as e:

View File

@@ -1,62 +1,147 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import logging import logging
import os import os
import pathlib import pathlib
import shutil import shutil
import json2html
from datetime import datetime from datetime import datetime
from zipfile import ZipFile from zipfile import ZipFile
import requests 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 werkzeug.utils import secure_filename
from lib.render_job import RenderJob from lib.render_job import RenderJob
from lib.render_queue import RenderQueue from lib.render_queue import RenderQueue, JobNotFoundError
from utilities.render_worker import RenderWorkerFactory, string_to_status 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() 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(): 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): def filtered_jobs_json(status_val):
state = string_to_status(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: if jobs:
return jobs return jobs
else: else:
return f'Cannot find jobs with status {status_val}', 400 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): 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) found_job = RenderQueue.job_with_id(job_id)
if found_job: log_path = found_job.worker.log_path
return found_job.json_safe_copy() log_data = None
else: if log_path and os.path.exists(log_path):
return f'Cannot find job with ID {job_id}', 400 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): def get_file_list(job_id):
found_job = RenderQueue.job_with_id(job_id) found_job = RenderQueue.job_with_id(job_id)
if found_job: if found_job:
job_dir = os.path.dirname(found_job.worker.output_path) return '\n'.join(found_job.file_list())
return os.listdir(job_dir)
else: else:
return f'Cannot find job with ID {job_id}', 400 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): def download_all(job_id):
zip_filename = None zip_filename = None
@after_this_request @after_this_request
@@ -66,10 +151,9 @@ def download_all(job_id):
return response return response
found_job = RenderQueue.job_with_id(job_id) found_job = RenderQueue.job_with_id(job_id)
if found_job:
output_dir = os.path.dirname(found_job.worker.output_path) output_dir = os.path.dirname(found_job.worker.output_path)
if os.path.exists(output_dir): if os.path.exists(output_dir):
zip_filename = pathlib.Path(found_job.worker.input_path).stem + '.zip' zip_filename = os.path.join('/tmp', pathlib.Path(found_job.worker.input_path).stem + '.zip')
with ZipFile(zip_filename, 'w') as zipObj: with ZipFile(zip_filename, 'w') as zipObj:
for f in os.listdir(output_dir): for f in os.listdir(output_dir):
zipObj.write(filename=os.path.join(output_dir, f), zipObj.write(filename=os.path.join(output_dir, f),
@@ -77,30 +161,28 @@ def download_all(job_id):
return send_file(zip_filename, mimetype="zip", as_attachment=True, ) return send_file(zip_filename, mimetype="zip", as_attachment=True, )
else: else:
return f'Cannot find project files for job {job_id}', 500 return f'Cannot find project files for job {job_id}', 500
else:
return f'Cannot find job with ID {job_id}', 400
@server.post('/register_client') @server.post('/api/register_client')
def register_client(): def register_client():
client_hostname = request.values['hostname'] client_hostname = request.values['hostname']
x = RenderQueue.register_client(client_hostname) x = RenderQueue.register_client(client_hostname)
return "Success" if x else "Fail" return "Success" if x else "Fail"
@server.post('/unregister_client') @server.post('/api/unregister_client')
def unregister_client(): def unregister_client():
client_hostname = request.values['hostname'] client_hostname = request.values['hostname']
x = RenderQueue.unregister_client(client_hostname) x = RenderQueue.unregister_client(client_hostname)
return "Success" if x else "Fail" return "Success" if x else "Fail"
@server.get('/clients') @server.get('/api/clients')
def render_clients(): def render_clients():
return RenderQueue.render_clients return RenderQueue.render_clients
@server.get('/full_status') @server.get('/api/full_status')
def full_status(): def full_status():
full_results = {'timestamp': datetime.now().isoformat(), 'servers': {}} full_results = {'timestamp': datetime.now().isoformat(), 'servers': {}}
@@ -127,46 +209,57 @@ def full_status():
return full_results return full_results
@server.get('/snapshot') @server.get('/api/snapshot')
def snapshot(): def snapshot():
server_status = RenderQueue.status() 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()} server_data = {'status': server_status, 'jobs': server_jobs, 'timestamp': datetime.now().isoformat()}
return server_data return server_data
@server.post('/add_job') @server.post('/api/add_job')
def add_job_handler(): def add_job_handler():
try: try:
"""Create new job and add to server render queue""" """Create new job and add to server render queue"""
json_string = request.form.get('json', None) if request.is_json:
uploaded_file = request.files.get('file', None) jobs_list = [request.json]
if not json_string: elif request.form.get('json', None):
return 'missing json data', 400 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 # handle uploaded files
uploaded_file = request.files.get('file', None)
uploaded_file_local_path = None uploaded_file_local_path = None
job_dir = None job_dir = None
if uploaded_file and uploaded_file.filename: if uploaded_file and uploaded_file.filename:
logger.info(f"Receiving uploaded file {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'], (uploaded_file.filename + "_" +
job_dir = os.path.join(server.config['UPLOAD_FOLDER'], new_id + "-" + uploaded_file.filename) datetime.now().strftime("%Y.%m.%d_%H.%M.%S")))
if not os.path.exists(job_dir): os.makedirs(job_dir, exist_ok=True)
os.makedirs(job_dir)
uploaded_file_local_path = os.path.join(job_dir, secure_filename(uploaded_file.filename)) uploaded_file_local_path = os.path.join(job_dir, secure_filename(uploaded_file.filename))
uploaded_file.save(uploaded_file_local_path) uploaded_file.save(uploaded_file_local_path)
# convert job input paths for uploaded files and add jobs # convert job input paths for uploaded files and add jobs
jobs_list = json.loads(json_string)
results = [] results = []
for job in jobs_list: for job in jobs_list:
if uploaded_file_local_path: if uploaded_file_local_path:
job['input_path'] = 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) os.makedirs(output_dir, exist_ok=True)
job['output_path'] = os.path.join(output_dir, os.path.basename(job['output_path'])) 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) add_result = add_job(job, remove_job_dir_on_failure=remove_job_dir)
results.append(add_result) results.append(add_result)
@@ -177,6 +270,10 @@ def add_job_handler():
return results, response.get('code', 500) return results, response.get('code', 500)
else: else:
return results, 400 return results, 400
if request.args.get('redirect', False):
return redirect(url_for('index'))
else:
return results return results
except Exception as e: except Exception as e:
@@ -185,7 +282,6 @@ def add_job_handler():
def add_job(job_params, remove_job_dir_on_failure=False): def add_job(job_params, remove_job_dir_on_failure=False):
def remove_job_dir(): def remove_job_dir():
if remove_job_dir_on_failure and job_dir and os.path.exists(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}") 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) output_path = job_params.get("output_path", None)
priority = int(job_params.get('priority', 2)) priority = int(job_params.get('priority', 2))
args = job_params.get('args', {}) 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) force_start = job_params.get('force_start', False)
custom_id = None custom_id = None
job_dir = 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, render_job = RenderJob(renderer, input_path, output_path, args, priority, job_owner, client,
notify=False, custom_id=custom_id, name=name) notify=False, custom_id=custom_id, name=name)
RenderQueue.add_to_render_queue(render_job, force_start=force_start) 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: except Exception as e:
err_msg = f"Error creating job: {str(e)}" err_msg = f"Error creating job: {str(e)}"
logger.exception(err_msg) 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} return {'error': err_msg, 'code': 400}
@server.get('/cancel_job') @server.get('/api/job/<job_id>/cancel')
def cancel_job(): def cancel_job(job_id):
job_id = request.args.get('id', None) if not request.args.get('confirm', False):
confirm = request.args.get('confirm', False) return 'Confirmation required to cancel job', 400
if not job_id:
return 'job id not found', 400 if RenderQueue.cancel_job(RenderQueue.job_with_id(job_id)):
elif not confirm: if request.args.get('redirect', False):
return 'confirmation required', 400 return redirect(url_for('index'))
else: else:
found = [x for x in RenderQueue.job_queue if x.id == job_id] return "Job cancelled"
if len(found) > 1: else:
return f'multiple jobs found for ID {job_id}', 400 return "Unknown error", 500
elif found:
success = RenderQueue.cancel_job(found[0])
return success
return 'job not found', 400
@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(): def clear_history():
RenderQueue.clear_history() RenderQueue.clear_history()
return 'success' return 'success'
@server.route('/status') @server.route('/api/status')
def status(): def status():
return RenderQueue.status() return RenderQueue.status()
@server.get('/renderer_info') @server.get('/api/renderer_info')
def renderer_info(): def renderer_info():
renderer_data = {} renderer_data = {}
for r in RenderWorkerFactory.supported_renderers(): for r in RenderWorkerFactory.supported_renderers():
@@ -300,22 +435,7 @@ def renderer_info():
return renderer_data return renderer_data
@server.route('/')
def default():
return "Server running"
@server.route('/upload') @server.route('/upload')
def upload_file_page(): def upload_file_page():
return render_template('upload.html', render_clients=RenderQueue.render_clients, return render_template('upload.html', render_clients=RenderQueue.render_clients,
supported_renderers=RenderWorkerFactory.supported_renderers()) 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

View File

@@ -1,3 +1,4 @@
import glob
import hashlib import hashlib
import json import json
import logging import logging
@@ -22,7 +23,7 @@ class RenderJob:
self.date_created = datetime.now() self.date_created = datetime.now()
self.scheduled_start = None self.scheduled_start = None
self.renderer = renderer 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 = RenderWorkerFactory.create_worker(renderer, input_path, output_path, args)
self.worker.log_path = os.path.join(os.path.dirname(input_path), self.name + '.log') 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 hashlib.md5(open(self.worker.input_path, 'rb').read()).hexdigest()
return None return None
def json_safe_copy(self): def json(self):
"""Converts RenderJob into JSON-friendly dict""" """Converts RenderJob into JSON-friendly dict"""
import numbers
job_dict = None job_dict = None
try: try:
job_dict = self.__dict__.copy() job_dict = self.__dict__.copy()
job_dict['status'] = self.render_status().value 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'] = self.worker.__dict__.copy()
job_dict['worker']['status'] = job_dict['status'] job_dict['worker']['status'] = job_dict['status']
@@ -57,13 +60,6 @@ class RenderJob:
for key in keys_to_remove: for key in keys_to_remove:
job_dict['worker'].pop(key, None) 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 # convert to json and back to auto-convert dates to iso format
def date_serializer(o): def date_serializer(o):
if isinstance(o, datetime): if isinstance(o, datetime):
@@ -81,6 +77,53 @@ class RenderJob:
def stop(self): def stop(self):
self.worker.stop() 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 @classmethod
def generate_id(cls): def generate_id(cls):
return str(uuid.uuid4()).split('-')[0] return str(uuid.uuid4()).split('-')[0]

View File

@@ -16,6 +16,12 @@ JSON_FILE = 'server_state.json'
#todo: move history to sqlite db #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: class RenderQueue:
job_queue = [] job_queue = []
render_clients = [] render_clients = []
@@ -39,8 +45,6 @@ class RenderQueue:
cls.job_queue.append(render_job) cls.job_queue.append(render_job)
if force_start: if force_start:
cls.start_job(render_job) cls.start_job(render_job)
else:
cls.evaluate_queue()
else: else:
# todo: implement client rendering # todo: implement client rendering
logger.warning('remote client rendering not implemented yet') logger.warning('remote client rendering not implemented yet')
@@ -63,8 +67,10 @@ class RenderQueue:
return found_jobs return found_jobs
@classmethod @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) 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 return found_job
@classmethod @classmethod
@@ -88,7 +94,7 @@ class RenderQueue:
cls.render_clients = saved_state.get('clients', {}) cls.render_clients = saved_state.get('clients', {})
for job in saved_state.get('jobs', []): for job in saved_state.get('jobs', []):
try:
render_job = RenderJob(renderer=job['renderer'], input_path=job['worker']['input_path'], render_job = RenderJob(renderer=job['renderer'], input_path=job['worker']['input_path'],
output_path=job['worker']['output_path'], args=job['worker']['args'], output_path=job['worker']['output_path'], args=job['worker']['args'],
priority=job['priority'], client=job['client']) priority=job['priority'], client=job['client'])
@@ -108,8 +114,10 @@ class RenderQueue:
if key in ['date_created']: # convert date strings back to datetime objects if key in ['date_created']: # convert date strings back to datetime objects
render_job.__dict__[key] = datetime.fromisoformat(val) render_job.__dict__[key] = datetime.fromisoformat(val)
else: 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 render_job.__dict__[key] = val
render_job.__delattr__('status')
# Handle older loaded jobs that were cancelled before closing # Handle older loaded jobs that were cancelled before closing
if render_job.render_status() == RenderStatus.RUNNING: if render_job.render_status() == RenderStatus.RUNNING:
@@ -117,6 +125,8 @@ class RenderQueue:
# finally add back to render queue # finally add back to render queue
cls.job_queue.append(render_job) 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() cls.last_saved_counts = cls.job_counts()
@@ -126,7 +136,7 @@ class RenderQueue:
try: try:
logger.debug("Saving Render History") logger.debug("Saving Render History")
output = {'timestamp': datetime.now().isoformat(), 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} 'clients': cls.render_clients}
output_path = json_path or JSON_FILE output_path = json_path or JSON_FILE
with open(output_path, 'w') as f: with open(output_path, 'w') as f:
@@ -167,9 +177,16 @@ class RenderQueue:
@classmethod @classmethod
def cancel_job(cls, job): def cancel_job(cls, job):
logger.info('Cancelling job ID: {}'.format(job.id)) logger.info(f'Cancelling job ID: {job.id}')
job.stop() 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 @classmethod
def renderer_instances(cls): def renderer_instances(cls):
@@ -213,7 +230,7 @@ class RenderQueue:
err_msg = f"Client '{hostname}' already registered" err_msg = f"Client '{hostname}' already registered"
else: else:
try: 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: if response.ok:
cls.render_clients.append(hostname) cls.render_clients.append(hostname)
logger.info(f"Client '{hostname}' successfully registered") logger.info(f"Client '{hostname}' successfully registered")
@@ -241,7 +258,7 @@ class RenderQueue:
@staticmethod @staticmethod
def is_client_available(client_hostname, timeout=3): def is_client_available(client_hostname, timeout=3):
try: 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: if response.ok:
return True return True
except requests.ConnectionError as e: except requests.ConnectionError as e:

43
lib/server_helper.py Normal file
View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
lib/static/images/error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

BIN
lib/static/images/gears.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
lib/static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View 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
View 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();
}
});
});

View File

@@ -8,3 +8,4 @@ ffmpeg-python
Werkzeug~=2.2.2 Werkzeug~=2.2.2
tkinterdnd2~=0.3.0 tkinterdnd2~=0.3.0
future~=0.18.2 future~=0.18.2
json2html~=1.3.0

View File

@@ -24,7 +24,7 @@ server_setup_timeout = 5
def request_data(server_ip, payload, server_port=8080, timeout=2): def request_data(server_ip, payload, server_port=8080, timeout=2):
try: 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: if req.ok:
return req.json() return req.json()
except Exception as e: except Exception as e:
@@ -334,6 +334,7 @@ class ScheduleJob(Frame):
# multiple camera rendering # multiple camera rendering
selected_cameras = self.blender_cameras_list.getCheckedItems() if self.blender_cameras_list else None selected_cameras = self.blender_cameras_list.getCheckedItems() if self.blender_cameras_list else None
if selected_cameras:
for cam in selected_cameras: for cam in selected_cameras:
job_copy = copy.deepcopy(job_json) job_copy = copy.deepcopy(job_json)
job_copy['args']['camera'] = cam.split('-')[0].strip() job_copy['args']['camera'] = cam.split('-')[0].strip()

View File

@@ -22,6 +22,7 @@ def start_server(background_thread=False):
level=config.get('server_log_level', 'INFO').upper()) level=config.get('server_log_level', 'INFO').upper())
server.config['UPLOAD_FOLDER'] = os.path.expanduser(config['upload_folder']) 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'] server.config['MAX_CONTENT_PATH'] = config['max_content_path']
# Get hostname and render clients # Get hostname and render clients
@@ -49,7 +50,7 @@ def start_server(background_thread=False):
server_thread.join() server_thread.join()
else: else:
server.run(host='0.0.0.0', port=RenderQueue.port, debug=config.get('flask_debug_enable', False), 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__': if __name__ == '__main__':

48
templates/details.html Normal file
View 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
View 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
View 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>

View File

@@ -10,8 +10,8 @@ class BlenderRenderWorker(BaseRenderWorker):
render_engine = 'blender' render_engine = 'blender'
supported_extensions = ['.blend'] supported_extensions = ['.blend']
install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender'] install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender']
supported_export_formats = ['TGA', 'RAWTGA', 'JPEG', 'IRIS', 'IRIZ', 'AVIRAW', 'AVIJPEG', 'PNG', 'BMP', 'HDR', 'TIFF', supported_export_formats = ['TGA', 'RAWTGA', 'JPEG', 'IRIS', 'IRIZ', 'AVIRAW', 'AVIJPEG', 'PNG', 'BMP', 'HDR',
'OPEN_EXR', 'OPEN_EXR_MULTILAYER', 'MPEG', 'CINEON', 'DPX', 'DDS', 'JP2'] 'TIFF', 'OPEN_EXR', 'OPEN_EXR_MULTILAYER', 'MPEG', 'CINEON', 'DPX', 'DDS', 'JP2']
def __init__(self, input_path, output_path, args=None): def __init__(self, input_path, output_path, args=None):
super(BlenderRenderWorker, self).__init__(input_path=input_path, output_path=output_path, super(BlenderRenderWorker, self).__init__(input_path=input_path, output_path=output_path,
@@ -29,10 +29,9 @@ class BlenderRenderWorker(BaseRenderWorker):
# Scene Info # Scene Info
self.scene_info = get_scene_info(input_path) 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.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 @classmethod
def version(cls): def version(cls):
@@ -61,6 +60,11 @@ class BlenderRenderWorker(BaseRenderWorker):
# all frames or single # all frames or single
cmd.extend(['-a'] if self.render_all_frames else ['-f', str(self.frame_to_render)]) 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 return cmd
def _parse_stdout(self, line): def _parse_stdout(self, line):

View File

@@ -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)

View File

@@ -9,9 +9,27 @@ def file_info(path):
return None 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): def generate_fast_preview(source_path, dest_path, max_width=1280, run_async=False):
stream = ffmpeg.input(source_path) 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) 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): 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__': 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) print(x)

View File

@@ -42,10 +42,18 @@ class FFMPEGRenderWorker(BaseRenderWorker):
def _generate_subprocess(self): def _generate_subprocess(self):
cmd = [self.renderer_path(), '-y', '-stats', '-i', self.input_path] 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 return cmd
def percent_complete(self): def percent_complete(self):

8
utilities/presets.yaml Normal file
View 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"

View File

@@ -32,6 +32,7 @@ class BaseRenderWorker(object):
renderer = 'BaseRenderWorker' renderer = 'BaseRenderWorker'
render_engine = None render_engine = None
render_engine_version = None
supported_extensions = [] supported_extensions = []
install_paths = [] install_paths = []
supported_export_formats = [] supported_export_formats = []