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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

@@ -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
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'
render_engine = None
render_engine_version = None
supported_extensions = []
install_paths = []
supported_export_formats = []