Compare commits
18 Commits
#24_genera
...
loopback_f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e50d80bc | ||
| c0d0ec64a8 | |||
| 32afcf945d | |||
| e9f9521924 | |||
|
|
0e0eba7b22 | ||
|
|
86c5d4cc15 | ||
| da61bf72f8 | |||
| 0271abf705 | |||
| c3b446be8e | |||
| 06a613fcc4 | |||
| d3b84c6212 | |||
|
|
014489e3bf | ||
| 65c256b641 | |||
| bc8e88ea59 | |||
| 6ce69c8d35 | |||
| dcc0504d3c | |||
|
|
22aaa82da7 | ||
|
|
951bebb3a8 |
@@ -2,6 +2,8 @@ upload_folder: "~/zordon-uploads/"
|
||||
update_engines_on_launch: true
|
||||
max_content_path: 100000000
|
||||
server_log_level: info
|
||||
log_buffer_length: 250
|
||||
subjob_connection_timeout: 120
|
||||
flask_log_level: error
|
||||
flask_debug_enable: false
|
||||
queue_eval_seconds: 1
|
||||
|
||||
7
dashboard.py
Executable file → Normal file
@@ -6,7 +6,6 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import requests
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.layout import Layout
|
||||
@@ -17,8 +16,8 @@ from rich.table import Table
|
||||
from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
|
||||
from src.workers.base_worker import RenderStatus, string_to_status
|
||||
from src.server_proxy import RenderServerProxy
|
||||
from src.engines.core.base_worker import RenderStatus, string_to_status
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.utilities.misc_helper import get_time_elapsed
|
||||
from start_server import start_server
|
||||
|
||||
@@ -202,7 +201,7 @@ if __name__ == '__main__':
|
||||
start_server_input = input("Local server not running. Start server? (y/n) ")
|
||||
if start_server_input and start_server_input[0].lower() == "y":
|
||||
# Startup the local server
|
||||
start_server(background_thread=True)
|
||||
start_server()
|
||||
test = server_proxy.connect()
|
||||
print(f"connected? {test}")
|
||||
else:
|
||||
|
||||
6
main.py
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from src import init
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
sys.exit(init.run())
|
||||
@@ -1,17 +1,15 @@
|
||||
requests==2.31.0
|
||||
requests_toolbelt==1.0.0
|
||||
psutil==5.9.6
|
||||
PyYAML==6.0.1
|
||||
Flask==3.0.0
|
||||
rich==13.6.0
|
||||
Werkzeug==3.0.0
|
||||
future==0.18.3
|
||||
Werkzeug~=3.0.1
|
||||
json2html~=1.3.0
|
||||
SQLAlchemy~=2.0.15
|
||||
Pillow==10.1.0
|
||||
zeroconf==0.119.0
|
||||
Pypubsub~=4.0.3
|
||||
tqdm==4.66.1
|
||||
dmglib==0.9.4
|
||||
plyer==2.1.0
|
||||
pyobjus==1.2.3
|
||||
PyQt6~=6.6.0
|
||||
PySide6~=6.6.0
|
||||
BIN
resources/Rectangle.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
resources/icons/AddProduct.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
resources/icons/Adobe After Effects.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
resources/icons/Blender.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
resources/icons/Console.png
Normal file
|
After Width: | Height: | Size: 921 B |
BIN
resources/icons/Document.png
Normal file
|
After Width: | Height: | Size: 476 B |
BIN
resources/icons/Download.png
Normal file
|
After Width: | Height: | Size: 979 B |
BIN
resources/icons/FFmpeg.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
resources/icons/Gear.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
resources/icons/GreenCircle.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
resources/icons/Monitor.png
Normal file
|
After Width: | Height: | Size: 450 B |
BIN
resources/icons/RedSquare.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
resources/icons/SearchFolder.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
resources/icons/Server.png
Normal file
|
After Width: | Height: | Size: 694 B |
BIN
resources/icons/SoftwareInstaller.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
resources/icons/StopSign.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
resources/icons/Synchronize.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
resources/icons/Trash.png
Normal file
|
After Width: | Height: | Size: 816 B |
BIN
resources/icons/linux.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
resources/icons/macos.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
resources/icons/windows.png
Normal file
|
After Width: | Height: | Size: 806 B |
@@ -11,7 +11,7 @@ from tqdm import tqdm
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.distributed_job_manager import DistributedJobManager
|
||||
from src.engines.core.worker_factory import RenderWorkerFactory
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.render_queue import RenderQueue
|
||||
|
||||
logger = logging.getLogger()
|
||||
@@ -88,13 +88,10 @@ def download_project_from_url(project_url):
|
||||
progress_bar.update(len(chunk))
|
||||
# Close the progress bar
|
||||
progress_bar.close()
|
||||
else:
|
||||
return None, None
|
||||
return referred_name, downloaded_file_url
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading file: {e}")
|
||||
return None, None
|
||||
|
||||
return referred_name, downloaded_file_url
|
||||
return None, None
|
||||
|
||||
|
||||
def process_zipped_project(zip_path):
|
||||
@@ -127,15 +124,11 @@ def process_zipped_project(zip_path):
|
||||
return extracted_project_path
|
||||
|
||||
|
||||
def create_render_jobs(jobs_list, loaded_project_local_path, job_dir, enable_split_jobs=False):
|
||||
def create_render_jobs(jobs_list, loaded_project_local_path, job_dir):
|
||||
results = []
|
||||
|
||||
for job_data in jobs_list:
|
||||
try:
|
||||
# prepare output paths
|
||||
output_dir = os.path.join(job_dir, job_data.get('name') if len(jobs_list) > 1 else 'output')
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# get new output path in output_dir
|
||||
output_path = job_data.get('output_path')
|
||||
if not output_path:
|
||||
@@ -144,16 +137,18 @@ def create_render_jobs(jobs_list, loaded_project_local_path, job_dir, enable_spl
|
||||
else:
|
||||
output_filename = os.path.basename(output_path)
|
||||
|
||||
output_path = os.path.join(os.path.dirname(os.path.dirname(loaded_project_local_path)), 'output',
|
||||
output_filename)
|
||||
# Prepare output path
|
||||
output_dir = os.path.join(os.path.dirname(os.path.dirname(loaded_project_local_path)), 'output')
|
||||
output_path = os.path.join(output_dir, output_filename)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
logger.debug(f"New job output path: {output_path}")
|
||||
|
||||
# create & configure jobs
|
||||
worker = RenderWorkerFactory.create_worker(renderer=job_data['renderer'],
|
||||
input_path=loaded_project_local_path,
|
||||
output_path=output_path,
|
||||
engine_version=job_data.get('engine_version'),
|
||||
args=job_data.get('args', {}))
|
||||
worker = EngineManager.create_worker(renderer=job_data['renderer'],
|
||||
input_path=loaded_project_local_path,
|
||||
output_path=output_path,
|
||||
engine_version=job_data.get('engine_version'),
|
||||
args=job_data.get('args', {}))
|
||||
worker.status = job_data.get("initial_status", worker.status)
|
||||
worker.parent = job_data.get("parent", worker.parent)
|
||||
worker.name = job_data.get("name", worker.name)
|
||||
@@ -162,12 +157,12 @@ def create_render_jobs(jobs_list, loaded_project_local_path, job_dir, enable_spl
|
||||
worker.end_frame = int(job_data.get("end_frame", worker.end_frame))
|
||||
|
||||
# determine if we can / should split the job
|
||||
if enable_split_jobs and (worker.total_frames > 1) and not worker.parent:
|
||||
if job_data.get("enable_split_jobs", False) and (worker.total_frames > 1) and not worker.parent:
|
||||
DistributedJobManager.split_into_subjobs(worker, job_data, loaded_project_local_path)
|
||||
else:
|
||||
logger.debug("Not splitting into subjobs")
|
||||
RenderQueue.add_to_render_queue(worker, force_start=job_data.get('force_start', False))
|
||||
|
||||
RenderQueue.add_to_render_queue(worker, force_start=job_data.get('force_start', False))
|
||||
if not worker.parent:
|
||||
from src.api.api_server import make_job_ready
|
||||
make_job_ready(worker.id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
@@ -17,14 +18,15 @@ import psutil
|
||||
import yaml
|
||||
from flask import Flask, request, render_template, send_file, after_this_request, Response, redirect, url_for, abort
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.api.add_job_helpers import handle_uploaded_project_files, process_zipped_project, create_render_jobs
|
||||
from src.api.serverproxy_manager import ServerProxyManager
|
||||
from src.distributed_job_manager import DistributedJobManager
|
||||
from src.engines.core.base_worker import string_to_status, RenderStatus
|
||||
from src.engines.core.worker_factory import RenderWorkerFactory
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.render_queue import RenderQueue, JobNotFoundError
|
||||
from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu, current_system_os_version
|
||||
from src.utilities.config import Config
|
||||
from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu, \
|
||||
current_system_os_version, config_dir
|
||||
from src.utilities.server_helper import generate_thumbnail_for_job
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
|
||||
@@ -53,7 +55,7 @@ def sorted_jobs(all_jobs, sort_by_date=True):
|
||||
@server.route('/')
|
||||
@server.route('/index')
|
||||
def index():
|
||||
with open(system_safe_path('config/presets.yaml')) as f:
|
||||
with open(system_safe_path(os.path.join(config_dir(), 'presets.yaml'))) as f:
|
||||
render_presets = yaml.load(f, Loader=yaml.FullLoader)
|
||||
|
||||
return render_template('index.html', all_jobs=sorted_jobs(RenderQueue.all_jobs()),
|
||||
@@ -168,7 +170,7 @@ def subjob_status_change(job_id):
|
||||
try:
|
||||
subjob_details = request.json
|
||||
logger.info(f"Subjob to job id: {job_id} is now {subjob_details['status']}")
|
||||
DistributedJobManager.handle_subjob_status_change(job_id, subjob_data=subjob_details)
|
||||
DistributedJobManager.handle_subjob_status_change(RenderQueue.job_with_id(job_id), subjob_data=subjob_details)
|
||||
return Response(status=200)
|
||||
except JobNotFoundError:
|
||||
return "Job not found", 404
|
||||
@@ -209,7 +211,7 @@ def make_job_ready(job_id):
|
||||
for child_key in found_job.children.keys():
|
||||
child_id = child_key.split('@')[0]
|
||||
hostname = child_key.split('@')[-1]
|
||||
RenderServerProxy(hostname).request_data(f'job/{child_id}/make_ready')
|
||||
ServerProxyManager.get_proxy_for_hostname(hostname).request_data(f'job/{child_id}/make_ready')
|
||||
found_job.status = RenderStatus.NOT_STARTED
|
||||
RenderQueue.save_state()
|
||||
return found_job.json(), 200
|
||||
@@ -276,7 +278,7 @@ def snapshot():
|
||||
@server.get('/api/_detected_clients')
|
||||
def detected_clients():
|
||||
# todo: dev/debug only. Should not ship this - probably.
|
||||
return ZeroconfServer.found_clients()
|
||||
return ZeroconfServer.found_hostnames()
|
||||
|
||||
|
||||
# New version
|
||||
@@ -286,10 +288,8 @@ def add_job_handler():
|
||||
try:
|
||||
if request.is_json:
|
||||
jobs_list = [request.json] if not isinstance(request.json, list) else request.json
|
||||
logger.debug(f"Received add_job JSON: {jobs_list}")
|
||||
elif request.form.get('json', None):
|
||||
jobs_list = json.loads(request.form['json'])
|
||||
logger.debug(f"Received add_job form: {jobs_list}")
|
||||
else:
|
||||
# Cleanup flat form data into nested structure
|
||||
form_dict = {k: v for k, v in dict(request.form).items() if v}
|
||||
@@ -303,7 +303,6 @@ def add_job_handler():
|
||||
args['raw'] = form_dict.get('raw_args', None)
|
||||
form_dict['args'] = args
|
||||
jobs_list = [form_dict]
|
||||
logger.debug(f"Received add_job data: {jobs_list}")
|
||||
except Exception as e:
|
||||
err_msg = f"Error processing job data: {e}"
|
||||
logger.error(err_msg)
|
||||
@@ -315,8 +314,7 @@ def add_job_handler():
|
||||
if loaded_project_local_path.lower().endswith('.zip'):
|
||||
loaded_project_local_path = process_zipped_project(loaded_project_local_path)
|
||||
|
||||
results = create_render_jobs(jobs_list, loaded_project_local_path, referred_name,
|
||||
server.config['enable_split_jobs'])
|
||||
results = create_render_jobs(jobs_list, loaded_project_local_path, referred_name)
|
||||
for response in results:
|
||||
if response.get('error', None):
|
||||
return results, 400
|
||||
@@ -393,7 +391,7 @@ def clear_history():
|
||||
@server.route('/api/status')
|
||||
def status():
|
||||
renderer_data = {}
|
||||
for render_class in RenderWorkerFactory.supported_classes():
|
||||
for render_class in EngineManager.supported_engines():
|
||||
if EngineManager.all_versions_for_engine(render_class.name): # only return renderers installed on host
|
||||
renderer_data[render_class.engine.name()] = \
|
||||
{'versions': EngineManager.all_versions_for_engine(render_class.engine.name()),
|
||||
@@ -421,17 +419,17 @@ def status():
|
||||
@server.get('/api/renderer_info')
|
||||
def renderer_info():
|
||||
renderer_data = {}
|
||||
for engine_name in RenderWorkerFactory.supported_renderers():
|
||||
engine = RenderWorkerFactory.class_for_name(engine_name).engine
|
||||
|
||||
for engine in EngineManager.supported_engines():
|
||||
# Get all installed versions of engine
|
||||
installed_versions = EngineManager.all_versions_for_engine(engine_name)
|
||||
installed_versions = EngineManager.all_versions_for_engine(engine.name())
|
||||
if installed_versions:
|
||||
install_path = installed_versions[0]['path']
|
||||
renderer_data[engine_name] = {'is_available': RenderQueue.is_available_for_job(engine.name()),
|
||||
'versions': installed_versions,
|
||||
'supported_extensions': engine.supported_extensions,
|
||||
'supported_export_formats': engine(install_path).get_output_formats()}
|
||||
# fixme: using system versions only because downloaded versions may have permissions issues
|
||||
system_installed_versions = [x for x in installed_versions if x['type'] == 'system']
|
||||
install_path = system_installed_versions[0]['path'] if system_installed_versions else installed_versions[0]['path']
|
||||
renderer_data[engine.name()] = {'is_available': RenderQueue.is_available_for_job(engine.name()),
|
||||
'versions': installed_versions,
|
||||
'supported_extensions': engine.supported_extensions(),
|
||||
'supported_export_formats': engine(install_path).get_output_formats()}
|
||||
return renderer_data
|
||||
|
||||
|
||||
@@ -474,54 +472,59 @@ def download_engine():
|
||||
|
||||
@server.post('/api/delete_engine')
|
||||
def delete_engine_download():
|
||||
delete_result = EngineManager.delete_engine_download(request.args.get('engine'),
|
||||
request.args.get('version'),
|
||||
request.args.get('system_os'),
|
||||
request.args.get('cpu'))
|
||||
json_data = request.json
|
||||
delete_result = EngineManager.delete_engine_download(json_data.get('engine'),
|
||||
json_data.get('version'),
|
||||
json_data.get('system_os'),
|
||||
json_data.get('cpu'))
|
||||
return "Success" if delete_result else \
|
||||
(f"Error deleting {request.args.get('engine')} {request.args.get('version')}", 500)
|
||||
(f"Error deleting {json_data.get('engine')} {json_data.get('version')}", 500)
|
||||
|
||||
|
||||
@server.get('/api/renderer/<renderer>/args')
|
||||
def get_renderer_args(renderer):
|
||||
try:
|
||||
renderer_engine_class = RenderWorkerFactory.class_for_name(renderer).engine()
|
||||
return renderer_engine_class.get_arguments()
|
||||
renderer_engine_class = EngineManager.engine_with_name(renderer)
|
||||
return renderer_engine_class().get_arguments()
|
||||
except LookupError:
|
||||
return f"Cannot find renderer '{renderer}'", 400
|
||||
|
||||
|
||||
@server.get('/api/renderer/<renderer>/help')
|
||||
def get_renderer_help(renderer):
|
||||
try:
|
||||
renderer_engine_class = EngineManager.engine_with_name(renderer)
|
||||
return renderer_engine_class().get_help()
|
||||
except LookupError:
|
||||
return f"Cannot find renderer '{renderer}'", 400
|
||||
|
||||
|
||||
@server.route('/upload')
|
||||
def upload_file_page():
|
||||
return render_template('upload.html', supported_renderers=RenderWorkerFactory.supported_renderers())
|
||||
return render_template('upload.html', supported_renderers=EngineManager.supported_engines())
|
||||
|
||||
|
||||
def start_server(background_thread=False):
|
||||
def start_server():
|
||||
def eval_loop(delay_sec=1):
|
||||
while True:
|
||||
RenderQueue.evaluate_queue()
|
||||
time.sleep(delay_sec)
|
||||
|
||||
with open(system_safe_path('config/config.yaml')) as f:
|
||||
config = yaml.load(f, Loader=yaml.FullLoader)
|
||||
|
||||
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
|
||||
level=config.get('server_log_level', 'INFO').upper())
|
||||
|
||||
# get hostname
|
||||
local_hostname = socket.gethostname()
|
||||
local_hostname = local_hostname + (".local" if not local_hostname.endswith(".local") else "")
|
||||
|
||||
# load flask settings
|
||||
server.config['HOSTNAME'] = local_hostname
|
||||
server.config['PORT'] = int(config.get('port_number', 8080))
|
||||
server.config['UPLOAD_FOLDER'] = system_safe_path(os.path.expanduser(config['upload_folder']))
|
||||
server.config['THUMBS_FOLDER'] = system_safe_path(os.path.join(os.path.expanduser(config['upload_folder']), 'thumbs'))
|
||||
server.config['MAX_CONTENT_PATH'] = config['max_content_path']
|
||||
server.config['enable_split_jobs'] = config.get('enable_split_jobs', False)
|
||||
server.config['PORT'] = int(Config.port_number)
|
||||
server.config['UPLOAD_FOLDER'] = system_safe_path(os.path.expanduser(Config.upload_folder))
|
||||
server.config['THUMBS_FOLDER'] = system_safe_path(os.path.join(os.path.expanduser(Config.upload_folder), 'thumbs'))
|
||||
server.config['MAX_CONTENT_PATH'] = Config.max_content_path
|
||||
server.config['enable_split_jobs'] = Config.enable_split_jobs
|
||||
|
||||
# Setup directory for saving engines to
|
||||
EngineManager.engines_path = system_safe_path(os.path.join(os.path.join(os.path.expanduser(config['upload_folder']), 'engines')))
|
||||
EngineManager.engines_path = system_safe_path(os.path.join(os.path.join(os.path.expanduser(Config.upload_folder),
|
||||
'engines')))
|
||||
os.makedirs(EngineManager.engines_path, exist_ok=True)
|
||||
|
||||
# Debug info
|
||||
@@ -531,32 +534,30 @@ def start_server(background_thread=False):
|
||||
|
||||
# disable most Flask logging
|
||||
flask_log = logging.getLogger('werkzeug')
|
||||
flask_log.setLevel(config.get('flask_log_level', 'ERROR').upper())
|
||||
flask_log.setLevel(Config.flask_log_level.upper())
|
||||
|
||||
# check for updates for render engines if config'd or on first launch
|
||||
if config.get('update_engines_on_launch', False) or not EngineManager.all_engines():
|
||||
if Config.update_engines_on_launch or not EngineManager.all_engines():
|
||||
EngineManager.update_all_engines()
|
||||
|
||||
# Set up the RenderQueue object
|
||||
RenderQueue.start_queue()
|
||||
DistributedJobManager.start()
|
||||
RenderQueue.load_state(database_directory=server.config['UPLOAD_FOLDER'])
|
||||
ServerProxyManager.subscribe_to_listener()
|
||||
DistributedJobManager.subscribe_to_listener()
|
||||
|
||||
thread = threading.Thread(target=eval_loop, kwargs={'delay_sec': config.get('queue_eval_seconds', 1)}, daemon=True)
|
||||
thread = threading.Thread(target=eval_loop, kwargs={'delay_sec': Config.queue_eval_seconds}, daemon=True)
|
||||
thread.start()
|
||||
|
||||
logger.info(f"Starting Zordon Render Server - Hostname: '{server.config['HOSTNAME']}:'")
|
||||
ZeroconfServer.configure("_zordon._tcp.local.", server.config['HOSTNAME'], server.config['PORT'])
|
||||
ZeroconfServer.properties = {'system_cpu': current_system_cpu(), 'system_cpu_cores': multiprocessing.cpu_count(),
|
||||
'system_os': current_system_os(),
|
||||
'system_os_version': current_system_os_version()}
|
||||
ZeroconfServer.start()
|
||||
|
||||
try:
|
||||
if background_thread:
|
||||
server_thread = threading.Thread(
|
||||
target=lambda: server.run(host='0.0.0.0', port=server.config['PORT'], debug=False, use_reloader=False))
|
||||
server_thread.start()
|
||||
server_thread.join()
|
||||
else:
|
||||
server.run(host='0.0.0.0', port=server.config['PORT'], debug=config.get('flask_debug_enable', False),
|
||||
use_reloader=False, threaded=True)
|
||||
server.run(host='0.0.0.0', port=server.config['PORT'], debug=Config.flask_debug_enable,
|
||||
use_reloader=False, threaded=True)
|
||||
finally:
|
||||
RenderQueue.save_state()
|
||||
ZeroconfServer.stop()
|
||||
|
||||
@@ -4,11 +4,11 @@ import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor
|
||||
|
||||
from src.utilities.misc_helper import is_localhost
|
||||
from src.utilities.status_utils import RenderStatus
|
||||
|
||||
status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green',
|
||||
@@ -20,6 +20,7 @@ categories = [RenderStatus.RUNNING, RenderStatus.WAITING_FOR_SUBJOBS, RenderStat
|
||||
|
||||
logger = logging.getLogger()
|
||||
OFFLINE_MAX = 2
|
||||
LOOPBACK = '127.0.0.1'
|
||||
|
||||
|
||||
class RenderServerProxy:
|
||||
@@ -34,13 +35,16 @@ class RenderServerProxy:
|
||||
self.__background_thread = None
|
||||
self.__offline_flags = 0
|
||||
self.update_cadence = 5
|
||||
self.last_contact = datetime.now()
|
||||
# to prevent errors, the last contact datetime is set to when the class is initialized - you must keep an
|
||||
# instance of this class alive to accurately know the delay
|
||||
self.is_localhost = is_localhost(hostname)
|
||||
|
||||
# Cache some basic server info
|
||||
self.system_cpu = None
|
||||
self.system_cpu_count = None
|
||||
self.system_os = None
|
||||
self.system_os_version = None
|
||||
|
||||
def connect(self):
|
||||
status = self.request_data('status')
|
||||
return status
|
||||
return self.status()
|
||||
|
||||
def is_online(self):
|
||||
if self.__update_in_background:
|
||||
@@ -52,14 +56,13 @@ class RenderServerProxy:
|
||||
if not self.is_online():
|
||||
return "Offline"
|
||||
running_jobs = [x for x in self.__jobs_cache if x['status'] == 'running'] if self.__jobs_cache else []
|
||||
return f"{len(running_jobs)} running" if running_jobs else "Available"
|
||||
return f"{len(running_jobs)} running" if running_jobs else "Ready"
|
||||
|
||||
def request_data(self, payload, timeout=5):
|
||||
try:
|
||||
req = self.request(payload, timeout)
|
||||
if req.ok and req.status_code == 200:
|
||||
self.__offline_flags = 0
|
||||
self.last_contact = datetime.now()
|
||||
return req.json()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f"JSON decode error: {e}")
|
||||
@@ -74,9 +77,11 @@ class RenderServerProxy:
|
||||
return None
|
||||
|
||||
def request(self, payload, timeout=5):
|
||||
return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout)
|
||||
return requests.get(f'http://{self.optimized_hostname()}:{self.port}/api/{payload}', timeout=timeout)
|
||||
|
||||
def start_background_update(self):
|
||||
if self.__update_in_background:
|
||||
return
|
||||
self.__update_in_background = True
|
||||
|
||||
def thread_worker():
|
||||
@@ -118,22 +123,34 @@ class RenderServerProxy:
|
||||
def cancel_job(self, job_id, confirm=False):
|
||||
return self.request_data(f'job/{job_id}/cancel?confirm={confirm}')
|
||||
|
||||
def delete_job(self, job_id, confirm=False):
|
||||
return self.request_data(f'job/{job_id}/delete?confirm={confirm}')
|
||||
|
||||
def get_status(self):
|
||||
return self.request_data('status')
|
||||
status = self.request_data('status')
|
||||
if not self.system_cpu:
|
||||
self.system_cpu = status['system_cpu']
|
||||
self.system_cpu_count = status['cpu_count']
|
||||
self.system_os = status['system_os']
|
||||
self.system_os_version = status['system_os_version']
|
||||
return status
|
||||
|
||||
def is_engine_available(self, engine_name):
|
||||
return self.request_data(f'{engine_name}/is_available')
|
||||
|
||||
def get_all_engines(self):
|
||||
return self.request_data('all_engines')
|
||||
|
||||
def notify_parent_of_status_change(self, parent_id, subjob):
|
||||
return requests.post(f'http://{self.hostname}:{self.port}/api/job/{parent_id}/notify_parent_of_status_change',
|
||||
return requests.post(f'http://{self.optimized_hostname()}:{self.port}/api/job/{parent_id}/notify_parent_of_status_change',
|
||||
json=subjob.json())
|
||||
|
||||
def post_job_to_server(self, file_path, job_list, callback=None):
|
||||
|
||||
# bypass uploading file if posting to localhost
|
||||
if self.hostname == socket.gethostname():
|
||||
if self.is_localhost:
|
||||
jobs_with_path = [{**item, "local_path": file_path} for item in job_list]
|
||||
return requests.post(f'http://{self.hostname}:{self.port}/api/add_job', data=json.dumps(jobs_with_path),
|
||||
return requests.post(f'http://{LOOPBACK}:{self.port}/api/add_job', data=json.dumps(jobs_with_path),
|
||||
headers={'Content-Type': 'application/json'})
|
||||
|
||||
# Prepare the form data
|
||||
@@ -153,7 +170,7 @@ class RenderServerProxy:
|
||||
return requests.post(f'http://{self.hostname}:{self.port}/api/add_job', data=monitor, headers=headers)
|
||||
|
||||
def get_job_files(self, job_id, save_path):
|
||||
url = f"http://{self.hostname}:{self.port}/api/job/{job_id}/download_all"
|
||||
url = f"http://{self.optimized_hostname()}:{self.port}/api/job/{job_id}/download_all"
|
||||
return self.download_file(url, filename=save_path)
|
||||
|
||||
@staticmethod
|
||||
@@ -165,3 +182,15 @@ class RenderServerProxy:
|
||||
f.write(chunk)
|
||||
return filename
|
||||
|
||||
# --- Renderer --- #
|
||||
|
||||
def get_renderer_info(self, timeout=5):
|
||||
all_data = self.request_data(f'renderer_info', timeout=timeout)
|
||||
return all_data
|
||||
|
||||
def delete_engine(self, engine, version, system_cpu=None):
|
||||
form_data = {'engine': engine, 'version': version, 'system_cpu': system_cpu}
|
||||
return requests.post(f'http://{self.optimized_hostname()}:{self.port}/api/delete_engine', json=form_data)
|
||||
|
||||
def optimized_hostname(self):
|
||||
return LOOPBACK if self.is_localhost else self.hostname
|
||||
35
src/api/serverproxy_manager.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pubsub import pub
|
||||
from zeroconf import ServiceStateChange
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
|
||||
|
||||
class ServerProxyManager:
|
||||
|
||||
server_proxys = {}
|
||||
|
||||
@classmethod
|
||||
def subscribe_to_listener(cls):
|
||||
"""
|
||||
Subscribes the private class method '__local_job_status_changed' to the 'status_change' pubsub message.
|
||||
This should be called once, typically during the initialization phase.
|
||||
"""
|
||||
pub.subscribe(cls.__zeroconf_state_change, 'zeroconf_state_change')
|
||||
|
||||
@classmethod
|
||||
def __zeroconf_state_change(cls, hostname, state_change, info):
|
||||
if state_change == ServiceStateChange.Added or state_change == ServiceStateChange.Updated:
|
||||
cls.get_proxy_for_hostname(hostname)
|
||||
else:
|
||||
cls.server_proxys.pop(hostname)
|
||||
|
||||
@classmethod
|
||||
def get_proxy_for_hostname(cls, hostname):
|
||||
found_proxy = cls.server_proxys.get(hostname)
|
||||
if not found_proxy:
|
||||
new_proxy = RenderServerProxy(hostname)
|
||||
new_proxy.start_background_update()
|
||||
cls.server_proxys[hostname] = new_proxy
|
||||
found_proxy = new_proxy
|
||||
return found_proxy
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, simpledialog
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from src.client.new_job_window import NewJobWindow
|
||||
# from src.client.server_details import create_server_popup
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.utilities.misc_helper import launch_url, file_exists_in_mounts, get_time_elapsed
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
from src.engines.core.base_worker import RenderStatus
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def sort_column(tree, col, reverse=False):
|
||||
data = [(tree.set(child, col), child) for child in tree.get_children('')]
|
||||
data.sort(reverse=reverse)
|
||||
for index, (_, child) in enumerate(data):
|
||||
tree.move(child, '', index)
|
||||
|
||||
|
||||
def make_sortable(tree):
|
||||
for col in tree["columns"]:
|
||||
tree.heading(col, text=col, command=lambda c=col: sort_column(tree, c))
|
||||
|
||||
|
||||
class DashboardWindow:
|
||||
|
||||
lib_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
image_path = os.path.join(lib_path, 'web', 'static', 'images')
|
||||
default_image = Image.open(os.path.join(image_path, 'desktop.png'))
|
||||
|
||||
def __init__(self):
|
||||
|
||||
# Create a Treeview widget
|
||||
self.root = tk.Tk()
|
||||
self.root.title("Zordon Dashboard")
|
||||
self.current_hostname = None
|
||||
self.server_proxies = {}
|
||||
self.added_hostnames = []
|
||||
|
||||
# Setup zeroconf
|
||||
ZeroconfServer.configure("_zordon._tcp.local.", socket.gethostname(), 8080)
|
||||
ZeroconfServer.start(listen_only=True)
|
||||
|
||||
# Setup photo preview
|
||||
photo_pad = tk.Frame(self.root, background="gray")
|
||||
photo_pad.pack(fill=tk.BOTH, pady=5, padx=5)
|
||||
self.photo_label = tk.Label(photo_pad, height=500)
|
||||
self.photo_label.pack(fill=tk.BOTH, expand=True)
|
||||
self.set_image(self.default_image)
|
||||
|
||||
server_frame = tk.LabelFrame(self.root, text="Server")
|
||||
server_frame.pack(fill=tk.BOTH, pady=5, padx=5, expand=True)
|
||||
|
||||
# Create server tree
|
||||
left_frame = tk.Frame(server_frame)
|
||||
left_frame.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)
|
||||
self.server_tree = ttk.Treeview(left_frame, show="headings")
|
||||
self.server_tree.pack(expand=True, fill=tk.BOTH)
|
||||
self.server_tree["columns"] = ("Server", "Status")
|
||||
self.server_tree.bind("<<TreeviewSelect>>", self.server_picked)
|
||||
self.server_tree.column("Server", width=200)
|
||||
self.server_tree.column("Status", width=80)
|
||||
|
||||
left_button_frame = tk.Frame(left_frame)
|
||||
left_button_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=5, expand=False)
|
||||
|
||||
# Create buttons
|
||||
self.remove_server_button = tk.Button(left_button_frame, text="-", command=self.remove_server_button)
|
||||
self.remove_server_button.pack(side=tk.RIGHT)
|
||||
self.remove_server_button.config(state='disabled')
|
||||
add_server_button = tk.Button(left_button_frame, text="+", command=self.add_server_button)
|
||||
add_server_button.pack(side=tk.RIGHT)
|
||||
|
||||
# Create separator
|
||||
separator = ttk.Separator(server_frame, orient=tk.VERTICAL)
|
||||
separator.pack(side=tk.LEFT, padx=5, pady=5, fill=tk.Y)
|
||||
|
||||
# Setup the Tree
|
||||
self.job_tree = ttk.Treeview(server_frame, show="headings")
|
||||
self.job_tree.tag_configure(RenderStatus.RUNNING.value, background='lawn green', font=('', 0, 'bold'))
|
||||
self.job_tree.bind("<<TreeviewSelect>>", self.job_picked)
|
||||
self.job_tree["columns"] = ("id", "Name", "Renderer", "Priority", "Status", "Time Elapsed", "Frames",
|
||||
"Date Added", "Parent", "")
|
||||
|
||||
# Format the columns
|
||||
self.job_tree.column("id", width=0, stretch=False)
|
||||
self.job_tree.column("Name", width=300)
|
||||
self.job_tree.column("Renderer", width=100, stretch=False)
|
||||
self.job_tree.column("Priority", width=50, stretch=False)
|
||||
self.job_tree.column("Status", width=100, stretch=False)
|
||||
self.job_tree.column("Time Elapsed", width=100, stretch=False)
|
||||
self.job_tree.column("Frames", width=50, stretch=False)
|
||||
self.job_tree.column("Date Added", width=150, stretch=True)
|
||||
self.job_tree.column("Parent", width=250, stretch=True)
|
||||
|
||||
# Create the column headings
|
||||
for name in self.job_tree['columns']:
|
||||
self.job_tree.heading(name, text=name)
|
||||
|
||||
# Pack the Treeview widget
|
||||
self.job_tree.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
button_frame = tk.Frame(server_frame)
|
||||
button_frame.pack(pady=5, fill=tk.X, expand=False)
|
||||
|
||||
# Create buttons
|
||||
self.logs_button = tk.Button(button_frame, text="Logs", command=self.open_logs)
|
||||
self.show_files_button = tk.Button(button_frame, text="Show Files", command=self.show_files)
|
||||
self.stop_button = tk.Button(button_frame, text="Stop", command=self.stop_job)
|
||||
self.delete_button = tk.Button(button_frame, text="Delete", command=self.delete_job)
|
||||
add_job_button = tk.Button(button_frame, text="Add Job", command=self.show_new_job_window)
|
||||
|
||||
# Pack the buttons in the frame
|
||||
self.stop_button.pack(side=tk.LEFT)
|
||||
self.stop_button.config(state='disabled')
|
||||
self.delete_button.pack(side=tk.LEFT)
|
||||
self.delete_button.config(state='disabled')
|
||||
self.show_files_button.pack(side=tk.LEFT)
|
||||
self.show_files_button.config(state='disabled')
|
||||
self.logs_button.pack(side=tk.LEFT)
|
||||
self.logs_button.config(state='disabled')
|
||||
add_job_button.pack(side=tk.RIGHT)
|
||||
|
||||
# Start the Tkinter event loop
|
||||
self.root.geometry("500x600+300+300")
|
||||
self.root.maxsize(width=2000, height=1200)
|
||||
self.root.minsize(width=900, height=800)
|
||||
make_sortable(self.job_tree)
|
||||
make_sortable(self.server_tree)
|
||||
|
||||
# update servers
|
||||
self.update_servers()
|
||||
try:
|
||||
selected_server = self.server_tree.get_children()[0]
|
||||
self.server_tree.selection_set(selected_server)
|
||||
self.server_picked()
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
# update jobs
|
||||
self.update_jobs()
|
||||
try:
|
||||
selected_job = self.job_tree.get_children()[0]
|
||||
self.job_tree.selection_set(selected_job)
|
||||
self.job_picked()
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
# start background update
|
||||
x = threading.Thread(target=self.__background_update)
|
||||
x.daemon = True
|
||||
x.start()
|
||||
|
||||
@property
|
||||
def current_server_proxy(self):
|
||||
return self.server_proxies.get(self.current_hostname, None)
|
||||
|
||||
def remove_server_button(self):
|
||||
new_hostname = self.server_tree.selection()[0]
|
||||
if new_hostname in self.added_hostnames:
|
||||
self.added_hostnames.remove(new_hostname)
|
||||
self.update_servers()
|
||||
if self.server_tree.get_children():
|
||||
self.server_tree.selection_set(self.server_tree.get_children()[0])
|
||||
self.server_picked(event=None)
|
||||
|
||||
def add_server_button(self):
|
||||
hostname = simpledialog.askstring("Server Hostname", "Enter the server hostname to add:")
|
||||
if hostname:
|
||||
hostname = hostname.strip()
|
||||
if hostname not in self.added_hostnames:
|
||||
if RenderServerProxy(hostname=hostname).connect():
|
||||
self.added_hostnames.append(hostname)
|
||||
self.update_servers()
|
||||
else:
|
||||
messagebox.showerror("Cannot Connect", f"Cannot connect to server at hostname: '{hostname}'")
|
||||
|
||||
def server_picked(self, event=None):
|
||||
try:
|
||||
new_hostname = self.server_tree.selection()[0]
|
||||
self.remove_server_button.config(state="normal" if new_hostname in self.added_hostnames else "disabled")
|
||||
if self.current_hostname == new_hostname:
|
||||
return
|
||||
self.current_hostname = new_hostname
|
||||
self.update_jobs(clear_table=True)
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
def selected_job_ids(self):
|
||||
selected_items = self.job_tree.selection() # Get the selected item
|
||||
row_data = [self.job_tree.item(item) for item in selected_items] # Get the text of the selected item
|
||||
job_ids = [row['values'][0] for row in row_data]
|
||||
return job_ids
|
||||
|
||||
def stop_job(self):
|
||||
job_ids = self.selected_job_ids()
|
||||
for job_id in job_ids:
|
||||
self.current_server_proxy.cancel_job(job_id, confirm=True)
|
||||
self.update_jobs(clear_table=True)
|
||||
|
||||
def delete_job(self):
|
||||
job_ids = self.selected_job_ids()
|
||||
if len(job_ids) == 1:
|
||||
job = next((d for d in self.current_server_proxy.get_all_jobs() if d.get('id') == job_ids[0]), None)
|
||||
display_name = job['name'] or os.path.basename(job['input_path'])
|
||||
message = f"Are you sure you want to delete the job:\n{display_name}?"
|
||||
else:
|
||||
message = f"Are you sure you want to delete these {len(job_ids)} jobs?"
|
||||
|
||||
result = messagebox.askyesno("Confirmation", message)
|
||||
if result:
|
||||
for job_id in job_ids:
|
||||
self.current_server_proxy.request_data(f'job/{job_id}/delete?confirm=true')
|
||||
self.update_jobs(clear_table=True)
|
||||
|
||||
def set_image(self, image):
|
||||
thumb_image = ImageTk.PhotoImage(image)
|
||||
if thumb_image:
|
||||
self.photo_label.configure(image=thumb_image)
|
||||
self.photo_label.image = thumb_image
|
||||
|
||||
def job_picked(self, event=None):
|
||||
job_id = self.selected_job_ids()[0] if self.selected_job_ids() else None
|
||||
if job_id:
|
||||
# update thumb
|
||||
def fetch_preview():
|
||||
try:
|
||||
before_fetch_hostname = self.current_server_proxy.hostname
|
||||
response = self.current_server_proxy.request(f'job/{job_id}/thumbnail?size=big')
|
||||
if response.ok:
|
||||
import io
|
||||
image_data = response.content
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
if self.current_server_proxy.hostname == before_fetch_hostname and job_id == self.selected_job_ids()[0]:
|
||||
self.set_image(image)
|
||||
except ConnectionError as e:
|
||||
logger.error(f"Connection error fetching image: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching image: {e}")
|
||||
|
||||
fetch_thread = threading.Thread(target=fetch_preview)
|
||||
fetch_thread.daemon = True
|
||||
fetch_thread.start()
|
||||
else:
|
||||
self.set_image(self.default_image)
|
||||
|
||||
# update button status
|
||||
current_jobs = self.current_server_proxy.get_all_jobs() or []
|
||||
job = next((d for d in current_jobs if d.get('id') == job_id), None)
|
||||
stop_button_state = 'normal' if job and job['status'] == RenderStatus.RUNNING.value else 'disabled'
|
||||
self.stop_button.config(state=stop_button_state)
|
||||
|
||||
generic_button_state = 'normal' if job else 'disabled'
|
||||
self.show_files_button.config(state=generic_button_state)
|
||||
self.delete_button.config(state=generic_button_state)
|
||||
self.logs_button.config(state=generic_button_state)
|
||||
|
||||
def show_files(self):
|
||||
if not self.selected_job_ids():
|
||||
return
|
||||
|
||||
job = next((d for d in self.current_server_proxy.get_all_jobs() if d.get('id') == self.selected_job_ids()[0]), None)
|
||||
output_path = os.path.dirname(job['output_path']) # check local filesystem
|
||||
if not os.path.exists(output_path):
|
||||
output_path = file_exists_in_mounts(output_path) # check any attached network shares
|
||||
if not output_path:
|
||||
return messagebox.showerror("File Not Found", "The file could not be found. Check your network mounts.")
|
||||
launch_url(output_path)
|
||||
|
||||
def open_logs(self):
|
||||
if self.selected_job_ids():
|
||||
url = f'http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}/api/job/{self.selected_job_ids()[0]}/logs'
|
||||
launch_url(url)
|
||||
|
||||
def mainloop(self):
|
||||
self.root.mainloop()
|
||||
|
||||
def __background_update(self):
|
||||
while True:
|
||||
self.update_servers()
|
||||
self.update_jobs()
|
||||
time.sleep(1)
|
||||
|
||||
def update_servers(self):
|
||||
|
||||
def update_row(tree, id, new_values, tags=None):
|
||||
for item in tree.get_children():
|
||||
values = tree.item(item, "values")
|
||||
if values[0] == id:
|
||||
if tags:
|
||||
tree.item(item, values=new_values, tags=tags)
|
||||
else:
|
||||
tree.item(item, values=new_values)
|
||||
break
|
||||
|
||||
current_servers = list(set(ZeroconfServer.found_clients() + self.added_hostnames))
|
||||
for hostname in current_servers:
|
||||
if not self.server_proxies.get(hostname, None):
|
||||
new_proxy = RenderServerProxy(hostname=hostname)
|
||||
new_proxy.start_background_update()
|
||||
self.server_proxies[hostname] = new_proxy
|
||||
|
||||
try:
|
||||
for hostname, proxy in self.server_proxies.items():
|
||||
if hostname not in self.server_tree.get_children():
|
||||
self.server_tree.insert("", tk.END, iid=hostname, values=(hostname, proxy.status(), ))
|
||||
else:
|
||||
update_row(self.server_tree, hostname, new_values=(hostname, proxy.status()))
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# remove any servers that don't belong
|
||||
for row in self.server_tree.get_children():
|
||||
if row not in current_servers:
|
||||
self.server_tree.delete(row)
|
||||
proxy = self.server_proxies.get(row, None)
|
||||
if proxy:
|
||||
proxy.stop_background_update()
|
||||
self.server_proxies.pop(row)
|
||||
|
||||
def update_jobs(self, clear_table=False):
|
||||
|
||||
if not self.current_server_proxy:
|
||||
return
|
||||
|
||||
def update_row(tree, id, new_values, tags=None):
|
||||
for item in tree.get_children():
|
||||
values = tree.item(item, "values")
|
||||
if values[0] == id:
|
||||
tree.item(item, values=new_values, tags=tags)
|
||||
break
|
||||
|
||||
if clear_table:
|
||||
self.job_tree.delete(*self.job_tree.get_children())
|
||||
|
||||
job_fetch = self.current_server_proxy.get_all_jobs(ignore_token=clear_table)
|
||||
if job_fetch:
|
||||
for job in job_fetch:
|
||||
display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \
|
||||
('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status
|
||||
tags = (job['status'],)
|
||||
start_time = datetime.datetime.fromisoformat(job['start_time']) if job['start_time'] else None
|
||||
end_time = datetime.datetime.fromisoformat(job['end_time']) if job['end_time'] else None
|
||||
|
||||
time_elapsed = "" if (job['status'] != RenderStatus.RUNNING.value and not end_time) else \
|
||||
get_time_elapsed(start_time, end_time)
|
||||
|
||||
values = (job['id'],
|
||||
job['name'] or os.path.basename(job['input_path']),
|
||||
job['renderer'] + "-" + job['renderer_version'],
|
||||
job['priority'],
|
||||
display_status,
|
||||
time_elapsed,
|
||||
job['total_frames'],
|
||||
job['date_created'],
|
||||
job['parent'])
|
||||
try:
|
||||
if self.job_tree.exists(job['id']):
|
||||
update_row(self.job_tree, job['id'], new_values=values, tags=tags)
|
||||
else:
|
||||
self.job_tree.insert("", tk.END, iid=job['id'], values=values, tags=tags)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
# remove any jobs that don't belong
|
||||
all_job_ids = [job['id'] for job in job_fetch]
|
||||
for row in self.job_tree.get_children():
|
||||
if row not in all_job_ids:
|
||||
self.job_tree.delete(row)
|
||||
|
||||
def show_new_job_window(self):
|
||||
new_window = tk.Toplevel(self.root)
|
||||
new_window.title("New Window")
|
||||
new_window.geometry("500x600+300+300")
|
||||
new_window.resizable(False, height=True)
|
||||
x = NewJobWindow(parent=new_window, clients=list(self.server_tree.get_children()))
|
||||
x.pack()
|
||||
|
||||
|
||||
def start_client():
|
||||
|
||||
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
|
||||
level='INFO'.upper())
|
||||
|
||||
x = DashboardWindow()
|
||||
x.mainloop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
start_client()
|
||||
@@ -1,452 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import copy
|
||||
import logging
|
||||
import os.path
|
||||
import pathlib
|
||||
import socket
|
||||
import threading
|
||||
from tkinter import *
|
||||
from tkinter import filedialog, messagebox
|
||||
from tkinter.ttk import Frame, Label, Entry, Combobox, Progressbar
|
||||
|
||||
import psutil
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.engines.blender.blender_worker import Blender
|
||||
from src.engines.ffmpeg.ffmpeg_worker import FFMPEG
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
label_width = 9
|
||||
header_padding = 6
|
||||
|
||||
|
||||
# CheckListBox source - https://stackoverflow.com/questions/50398649/python-tkinter-tk-support-checklist-box
|
||||
class ChecklistBox(Frame):
|
||||
def __init__(self, parent, choices, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
|
||||
self.vars = []
|
||||
for choice in choices:
|
||||
var = StringVar(value="")
|
||||
self.vars.append(var)
|
||||
cb = Checkbutton(self, text=choice, onvalue=choice, offvalue="", anchor="w", width=20,
|
||||
relief="flat", highlightthickness=0, variable=var)
|
||||
cb.pack(side="top", fill="x", anchor="w")
|
||||
|
||||
def getCheckedItems(self):
|
||||
values = []
|
||||
for var in self.vars:
|
||||
value = var.get()
|
||||
if value:
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
def resetCheckedItems(self):
|
||||
values = []
|
||||
for var in self.vars:
|
||||
var.set(value='')
|
||||
return values
|
||||
|
||||
|
||||
class NewJobWindow(Frame):
|
||||
|
||||
def __init__(self, parent=None, clients=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.root = parent
|
||||
self.clients = clients or []
|
||||
self.server_proxy = RenderServerProxy(hostname=clients[0] if clients else None)
|
||||
self.chosen_file = None
|
||||
self.project_info = {}
|
||||
self.presets = {}
|
||||
self.renderer_info = {}
|
||||
self.priority = IntVar(value=2)
|
||||
|
||||
self.master.title("New Job")
|
||||
self.pack(fill=BOTH, expand=True)
|
||||
|
||||
# project frame
|
||||
job_frame = LabelFrame(self, text="Job Settings")
|
||||
job_frame.pack(fill=X, padx=5, pady=5)
|
||||
|
||||
# project frame
|
||||
project_frame = Frame(job_frame)
|
||||
project_frame.pack(fill=X)
|
||||
|
||||
project_label = Label(project_frame, text="Project", width=label_width)
|
||||
project_label.pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
self.project_button = Button(project_frame, text="no file selected", width=6, command=self.choose_file_button)
|
||||
self.project_button.pack(fill=X, padx=5, expand=True)
|
||||
|
||||
# client frame
|
||||
client_frame = Frame(job_frame)
|
||||
client_frame.pack(fill=X)
|
||||
|
||||
Label(client_frame, text="Client", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
self.client_combo = Combobox(client_frame, state="readonly")
|
||||
self.client_combo.pack(fill=X, padx=5, expand=True)
|
||||
self.client_combo.bind('<<ComboboxSelected>>', self.client_picked)
|
||||
self.client_combo['values'] = self.clients
|
||||
if self.clients:
|
||||
self.client_combo.current(0)
|
||||
|
||||
# renderer frame
|
||||
renderer_frame = Frame(job_frame)
|
||||
renderer_frame.pack(fill=X)
|
||||
|
||||
Label(renderer_frame, text="Renderer", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
self.renderer_combo = Combobox(renderer_frame, state="readonly")
|
||||
self.renderer_combo.pack(fill=X, padx=5, expand=True)
|
||||
self.renderer_combo.bind('<<ComboboxSelected>>', self.refresh_renderer_settings)
|
||||
|
||||
# priority frame
|
||||
priority_frame = Frame(job_frame)
|
||||
priority_frame.pack(fill=X)
|
||||
|
||||
Label(priority_frame, text="Priority", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
Radiobutton(priority_frame, text="1", value=1, variable=self.priority).pack(anchor=W, side=LEFT, padx=5)
|
||||
Radiobutton(priority_frame, text="2", value=2, variable=self.priority).pack(anchor=W, side=LEFT, padx=5)
|
||||
Radiobutton(priority_frame, text="3", value=3, variable=self.priority).pack(anchor=W, side=LEFT, padx=5)
|
||||
|
||||
# presets
|
||||
presets_frame = Frame(job_frame)
|
||||
presets_frame.pack(fill=X)
|
||||
|
||||
Label(presets_frame, text="Presets", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
self.presets_combo = Combobox(presets_frame, state="readonly")
|
||||
self.presets_combo.pack(side=LEFT, padx=5, pady=5, expand=True, fill=X)
|
||||
self.presets_combo.bind('<<ComboboxSelected>>', self.chose_preset)
|
||||
|
||||
# output frame
|
||||
output_frame = Frame(job_frame)
|
||||
output_frame.pack(fill=X)
|
||||
|
||||
Label(output_frame, text="Output", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
self.output_entry = Entry(output_frame)
|
||||
self.output_entry.pack(side=LEFT, padx=5, expand=True, fill=X)
|
||||
|
||||
self.output_format = Combobox(output_frame, state="readonly", values=['JPG', 'MOV', 'PNG'], width=9)
|
||||
self.output_format.pack(side=LEFT, padx=5, pady=5)
|
||||
self.output_format['state'] = DISABLED
|
||||
|
||||
# frame_range frame
|
||||
frame_range_frame = Frame(job_frame)
|
||||
frame_range_frame.pack(fill=X)
|
||||
|
||||
Label(frame_range_frame, text="Frames", width=label_width).pack(side=LEFT, padx=5, pady=5, expand=False)
|
||||
|
||||
self.start_frame_spinbox = Spinbox(frame_range_frame, from_=0, to=50000, width=5)
|
||||
self.start_frame_spinbox.pack(side=LEFT, expand=False, padx=5, pady=5)
|
||||
|
||||
Label(frame_range_frame, text="to").pack(side=LEFT, pady=5, expand=False)
|
||||
self.end_frame_spinbox = Spinbox(frame_range_frame, from_=0, to=50000, width=5)
|
||||
self.end_frame_spinbox.pack(side=LEFT, expand=False, padx=5, pady=5)
|
||||
|
||||
# Blender
|
||||
self.blender_frame = None
|
||||
self.blender_cameras_frame = None
|
||||
self.blender_engine = StringVar(value='CYCLES')
|
||||
self.blender_pack_textures = BooleanVar(value=False)
|
||||
self.blender_multiple_cameras = BooleanVar(value=False)
|
||||
self.blender_cameras_list = None
|
||||
|
||||
# Custom Args / Submit Button
|
||||
self.custom_args_frame = None
|
||||
self.custom_args_entry = None
|
||||
self.submit_frame = None
|
||||
|
||||
self.progress_frame = None
|
||||
self.progress_label = None
|
||||
self.progress_bar = None
|
||||
self.upload_status = None
|
||||
|
||||
self.fetch_server_data()
|
||||
|
||||
def client_picked(self, event=None):
|
||||
self.server_proxy.hostname = self.client_combo.get()
|
||||
self.fetch_server_data()
|
||||
|
||||
def fetch_server_data(self):
|
||||
self.renderer_info = self.server_proxy.request_data('renderer_info', timeout=3) or {}
|
||||
self.presets = self.server_proxy.request_data('presets', timeout=3) or {}
|
||||
|
||||
# update available renders
|
||||
self.renderer_combo['values'] = list(self.renderer_info.keys())
|
||||
if self.renderer_info.keys():
|
||||
self.renderer_combo.current(0)
|
||||
|
||||
self.refresh_renderer_settings()
|
||||
|
||||
def choose_file_button(self):
|
||||
self.chosen_file = filedialog.askopenfilename()
|
||||
button_text = os.path.basename(self.chosen_file) if self.chosen_file else "no file selected"
|
||||
self.project_button.configure(text=button_text)
|
||||
|
||||
# Update the output label
|
||||
self.output_entry.delete(0, END)
|
||||
if self.chosen_file:
|
||||
# Generate a default output name
|
||||
output_name = os.path.splitext(os.path.basename(self.chosen_file))[-1].strip('.')
|
||||
self.output_entry.insert(0, os.path.basename(output_name))
|
||||
|
||||
# Try to determine file type
|
||||
extension = os.path.splitext(self.chosen_file)[-1].strip('.') # not the best way to do this
|
||||
for renderer, renderer_info in self.renderer_info.items():
|
||||
supported = [x.lower().strip('.') for x in renderer_info.get('supported_extensions', [])]
|
||||
if extension.lower().strip('.') in supported:
|
||||
if renderer in self.renderer_combo['values']:
|
||||
self.renderer_combo.set(renderer)
|
||||
|
||||
self.refresh_renderer_settings()
|
||||
|
||||
def chose_preset(self, event=None):
|
||||
preset_name = self.presets_combo.get()
|
||||
renderer = self.renderer_combo.get()
|
||||
|
||||
presets_to_show = {key: value for key, value in self.presets.items() if value.get("renderer") == renderer}
|
||||
matching_dict = next((value for value in presets_to_show.values() if value.get("name") == preset_name), None)
|
||||
if matching_dict:
|
||||
self.custom_args_entry.delete(0, END)
|
||||
self.custom_args_entry.insert(0, matching_dict['args'])
|
||||
|
||||
def refresh_renderer_settings(self, event=None):
|
||||
renderer = self.renderer_combo.get()
|
||||
|
||||
# clear old settings
|
||||
if self.blender_frame:
|
||||
self.blender_frame.pack_forget()
|
||||
|
||||
if not self.chosen_file:
|
||||
return
|
||||
|
||||
if renderer == 'blender':
|
||||
self.project_info = Blender().get_scene_info(self.chosen_file)
|
||||
self.draw_blender_settings()
|
||||
elif renderer == 'ffmpeg':
|
||||
f = FFMPEG.get_frame_count(self.chosen_file)
|
||||
self.project_info['frame_end'] = f
|
||||
|
||||
# set frame start / end numbers fetched from fils
|
||||
if self.project_info.get('frame_start'):
|
||||
self.start_frame_spinbox.delete(0, 'end')
|
||||
self.start_frame_spinbox.insert(0, self.project_info['frame_start'])
|
||||
if self.project_info.get('frame_end'):
|
||||
self.end_frame_spinbox.delete(0, 'end')
|
||||
self.end_frame_spinbox.insert(0, self.project_info['frame_end'])
|
||||
|
||||
# redraw lower ui
|
||||
self.draw_custom_args()
|
||||
self.draw_submit_button()
|
||||
|
||||
# check supported export formats
|
||||
if self.renderer_info.get(renderer, {}).get('supported_export_formats', None):
|
||||
formats = self.renderer_info[renderer]['supported_export_formats']
|
||||
if formats and isinstance(formats[0], dict):
|
||||
formats = [x.get('name', str(x)) for x in formats]
|
||||
formats.sort()
|
||||
self.output_format['values'] = formats
|
||||
self.output_format['state'] = NORMAL
|
||||
self.output_format.current(0)
|
||||
else:
|
||||
self.output_format['values'] = []
|
||||
self.output_format['state'] = DISABLED
|
||||
|
||||
# update presets
|
||||
presets_to_show = {key: value for key, value in self.presets.items() if value.get("renderer") == renderer}
|
||||
self.presets_combo['values'] = [value['name'] for value in presets_to_show.values()]
|
||||
|
||||
def draw_custom_args(self):
|
||||
if hasattr(self, 'custom_args_frame') and self.custom_args_frame:
|
||||
self.custom_args_frame.forget()
|
||||
self.custom_args_frame = LabelFrame(self, text="Advanced")
|
||||
self.custom_args_frame.pack(side=TOP, fill=X, expand=False, padx=5, pady=5)
|
||||
Label(self.custom_args_frame, text="Custom Args", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
self.custom_args_entry = Entry(self.custom_args_frame)
|
||||
self.custom_args_entry.pack(side=TOP, padx=5, expand=True, fill=X)
|
||||
|
||||
def draw_submit_button(self):
|
||||
if hasattr(self, 'submit_frame') and self.submit_frame:
|
||||
self.submit_frame.forget()
|
||||
self.submit_frame = Frame(self)
|
||||
self.submit_frame.pack(fill=BOTH, expand=True)
|
||||
# Label(self.submit_frame, text="").pack(fill=BOTH, expand=True)
|
||||
submit_button = Button(self.submit_frame, text="Submit", command=self.submit_job)
|
||||
submit_button.pack(fill=Y, anchor="s", pady=header_padding)
|
||||
|
||||
def draw_progress_frame(self):
|
||||
if hasattr(self, 'progress_frame') and self.progress_frame:
|
||||
self.progress_frame.forget()
|
||||
self.progress_frame = LabelFrame(self, text="Job Submission")
|
||||
self.progress_frame.pack(side=TOP, fill=X, expand=False, padx=5, pady=5)
|
||||
self.progress_bar = Progressbar(self.progress_frame, length=300, mode="determinate")
|
||||
self.progress_bar.pack()
|
||||
self.progress_label = Label(self.progress_frame, text="Starting Up")
|
||||
self.progress_label.pack(pady=5, padx=5)
|
||||
|
||||
def draw_blender_settings(self):
|
||||
|
||||
# blender settings
|
||||
self.blender_frame = LabelFrame(self, text="Blender Settings")
|
||||
self.blender_frame.pack(fill=X, padx=5)
|
||||
|
||||
blender_engine_frame = Frame(self.blender_frame)
|
||||
blender_engine_frame.pack(fill=X)
|
||||
|
||||
Label(blender_engine_frame, text="Engine", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
Radiobutton(blender_engine_frame, text="Cycles", value="CYCLES", variable=self.blender_engine).pack(
|
||||
anchor=W, side=LEFT, padx=5)
|
||||
Radiobutton(blender_engine_frame, text="Eevee", value="BLENDER_EEVEE", variable=self.blender_engine).pack(
|
||||
anchor=W, side=LEFT, padx=5)
|
||||
|
||||
# options
|
||||
pack_frame = Frame(self.blender_frame)
|
||||
pack_frame.pack(fill=X)
|
||||
|
||||
Label(pack_frame, text="Options", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
Checkbutton(pack_frame, text="Pack Textures", variable=self.blender_pack_textures, onvalue=True, offvalue=False
|
||||
).pack(anchor=W, side=LEFT, padx=5)
|
||||
|
||||
# multi cams
|
||||
def draw_scene_cams(event=None):
|
||||
if self.project_info:
|
||||
show_cams_checkbutton['state'] = NORMAL
|
||||
if self.blender_multiple_cameras.get():
|
||||
self.blender_cameras_frame = Frame(self.blender_frame)
|
||||
self.blender_cameras_frame.pack(fill=X)
|
||||
|
||||
Label(self.blender_cameras_frame, text="Cameras", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
choices = [f"{x['name']} - {int(float(x['lens']))}mm" for x in self.project_info['cameras']]
|
||||
choices.sort()
|
||||
self.blender_cameras_list = ChecklistBox(self.blender_cameras_frame, choices, relief="sunken")
|
||||
self.blender_cameras_list.pack(padx=5, fill=X)
|
||||
elif self.blender_cameras_frame:
|
||||
self.blender_cameras_frame.pack_forget()
|
||||
else:
|
||||
show_cams_checkbutton['state'] = DISABLED
|
||||
if self.blender_cameras_frame:
|
||||
self.blender_cameras_frame.pack_forget()
|
||||
|
||||
# multiple cameras checkbox
|
||||
camera_count = len(self.project_info.get('cameras', [])) if self.project_info else 0
|
||||
show_cams_checkbutton = Checkbutton(pack_frame, text=f'Multiple Cameras ({camera_count})', offvalue=False,
|
||||
onvalue=True,
|
||||
variable=self.blender_multiple_cameras, command=draw_scene_cams)
|
||||
show_cams_checkbutton.pack(side=LEFT, padx=5)
|
||||
show_cams_checkbutton['state'] = NORMAL if camera_count > 1 else DISABLED
|
||||
|
||||
def submit_job(self):
|
||||
|
||||
def submit_job_worker():
|
||||
|
||||
self.draw_progress_frame()
|
||||
self.progress_bar['value'] = 0
|
||||
self.progress_bar.configure(mode='determinate')
|
||||
self.progress_bar.start()
|
||||
self.progress_label.configure(text="Preparing files...")
|
||||
|
||||
# start the progress UI
|
||||
client = self.client_combo.get()
|
||||
|
||||
renderer = self.renderer_combo.get()
|
||||
job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(),
|
||||
'renderer': renderer,
|
||||
'client': client,
|
||||
'output_path': os.path.join(os.path.dirname(self.chosen_file), self.output_entry.get()),
|
||||
'args': {'raw': self.custom_args_entry.get()},
|
||||
'start_frame': self.start_frame_spinbox.get(),
|
||||
'end_frame': self.end_frame_spinbox.get(),
|
||||
'name': None}
|
||||
job_list = []
|
||||
|
||||
input_path = self.chosen_file
|
||||
|
||||
temp_files = []
|
||||
if renderer == 'blender':
|
||||
if self.blender_pack_textures.get():
|
||||
self.progress_label.configure(text="Packing Blender file...")
|
||||
new_path = Blender().pack_project_file(project_path=input_path, timeout=300)
|
||||
if new_path:
|
||||
logger.info(f"New Path is now {new_path}")
|
||||
input_path = new_path
|
||||
temp_files.append(new_path)
|
||||
else:
|
||||
err_msg = f'Failed to pack Blender file: {input_path}'
|
||||
messagebox.showinfo("Error", err_msg)
|
||||
return
|
||||
# add all Blender args
|
||||
job_json['args']['engine'] = self.blender_engine.get()
|
||||
job_json['args']['export_format'] = self.output_format.get()
|
||||
|
||||
# multiple camera rendering
|
||||
if self.blender_cameras_list and self.blender_multiple_cameras.get():
|
||||
selected_cameras = self.blender_cameras_list.getCheckedItems()
|
||||
for cam in selected_cameras:
|
||||
job_copy = copy.deepcopy(job_json)
|
||||
job_copy['args']['camera'] = cam.rsplit('-', 1)[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]
|
||||
self.progress_label.configure(text="Posting to server...")
|
||||
self.progress_bar.stop()
|
||||
self.progress_bar.configure(mode='determinate')
|
||||
self.progress_bar.start()
|
||||
|
||||
def create_callback(encoder):
|
||||
encoder_len = encoder.len
|
||||
|
||||
def callback(monitor):
|
||||
percent = f"{monitor.bytes_read / encoder_len * 100:.0f}"
|
||||
self.progress_label.configure(text=f"Transferring to {client} - {percent}%")
|
||||
self.progress_bar['value'] = int(percent)
|
||||
return callback
|
||||
|
||||
result = self.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list,
|
||||
callback=create_callback)
|
||||
|
||||
self.progress_bar.stop()
|
||||
# clean up
|
||||
for temp in temp_files:
|
||||
os.remove(temp)
|
||||
|
||||
def finish_on_main():
|
||||
if result.ok:
|
||||
message = "Job successfully submitted to server."
|
||||
self.progress_label.configure(text=message)
|
||||
messagebox.showinfo("Success", message)
|
||||
logger.info(message)
|
||||
else:
|
||||
message = result.text or "Unknown error"
|
||||
self.progress_label.configure(text=message)
|
||||
logger.warning(message)
|
||||
messagebox.showinfo("Error", message)
|
||||
self.progress_label.configure(text="")
|
||||
self.progress_frame.forget()
|
||||
|
||||
self.root.after(0, finish_on_main)
|
||||
|
||||
# Start the job submit task as a bg thread
|
||||
bg_thread = threading.Thread(target=submit_job_worker)
|
||||
bg_thread.start()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
root = Tk()
|
||||
root.geometry("500x600+300+300")
|
||||
root.maxsize(width=1000, height=2000)
|
||||
root.minsize(width=600, height=600)
|
||||
app = NewJobWindow(root)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,4 +1,3 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
@@ -23,7 +22,7 @@ class DistributedJobManager:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def start(cls):
|
||||
def subscribe_to_listener(cls):
|
||||
"""
|
||||
Subscribes the private class method '__local_job_status_changed' to the 'status_change' pubsub message.
|
||||
This should be called once, typically during the initialization phase.
|
||||
@@ -86,60 +85,38 @@ class DistributedJobManager:
|
||||
logger.debug(f"Unable to show UI notification: {e}")
|
||||
|
||||
@classmethod
|
||||
def handle_subjob_status_change(cls, parent_job_id, subjob_data):
|
||||
def handle_subjob_status_change(cls, local_job, subjob_data):
|
||||
"""
|
||||
Responds to a status change from a remote subjob and triggers the creation or modification of subjobs as needed.
|
||||
|
||||
Parameters:
|
||||
local_job_id (str): ID for local parent job worker.
|
||||
subjob_data (dict): Subjob data sent from the remote server.
|
||||
local_job (BaseRenderWorker): The local parent job worker.
|
||||
subjob_data (dict): subjob data sent from remote server.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
parent_job = RenderQueue.job_with_id(parent_job_id)
|
||||
subjob_id = subjob_data['id']
|
||||
subjob_hostname = next((hostname.split('@')[1] for hostname in parent_job.children if
|
||||
hostname.split('@')[0] == subjob_id), None)
|
||||
subjob_key = f'{subjob_id}@{subjob_hostname}'
|
||||
|
||||
# Update the local job's subjob data
|
||||
parent_job.children = dict(parent_job.children) # copy as dict to work around sqlalchemy update issue
|
||||
parent_job.children[subjob_key] = subjob_data
|
||||
|
||||
logname = f"{parent_job_id}:{subjob_key}"
|
||||
subjob_status = string_to_status(subjob_data['status'])
|
||||
subjob_id = subjob_data['id']
|
||||
subjob_hostname = next((hostname.split('@')[1] for hostname in local_job.children if
|
||||
hostname.split('@')[0] == subjob_id), None)
|
||||
local_job.children[f'{subjob_id}@{subjob_hostname}'] = subjob_data
|
||||
|
||||
logname = f"{local_job.id}:{subjob_id}@{subjob_hostname}"
|
||||
logger.debug(f"Subjob status changed: {logname} -> {subjob_status.value}")
|
||||
|
||||
# Handle downloading for completed, cancelled, or error'd subjobs
|
||||
if (subjob_status in [RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.ERROR]
|
||||
and subjob_data['file_count']):
|
||||
if not cls.download_from_subjob(parent_job, subjob_id, subjob_hostname):
|
||||
# Download complete or partial render jobs
|
||||
if subjob_status in [RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.ERROR] and \
|
||||
subjob_data['file_count']:
|
||||
download_result = cls.download_from_subjob(local_job, subjob_id, subjob_hostname)
|
||||
if not download_result:
|
||||
# todo: handle error
|
||||
logger.error(f"Unable to download subjob files from {logname} with status {subjob_status.value}")
|
||||
|
||||
# Handle cancelled or errored subjobs by determining missing frames and scheduling a new job
|
||||
if subjob_status == RenderStatus.CANCELLED or subjob_status == RenderStatus.ERROR:
|
||||
logger.info("Creating a new subjob")
|
||||
cls.new_create_subjob(parent_job.id, socket.gethostname(),
|
||||
parent_job.children[subjob_key]['start_frame'],
|
||||
parent_job.children[subjob_key]['end_frame'])
|
||||
# todo: determine why we don't wait for the new subjobs we create when replacing an error'd job
|
||||
|
||||
@staticmethod
|
||||
def determine_missing_frames(parent_job_id):
|
||||
"""
|
||||
Determine missing frames in the subjob.
|
||||
|
||||
Parameters:
|
||||
subjob_data (dict): Subjob data.
|
||||
|
||||
Returns:
|
||||
list: List of missing frame numbers.
|
||||
"""
|
||||
# todo: Implement the logic to determine missing frames based on subjob_data
|
||||
missing_frames = []
|
||||
return missing_frames
|
||||
|
||||
# todo: determine missing frames and schedule new job
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def download_from_subjob(local_job, subjob_id, subjob_hostname):
|
||||
@@ -161,11 +138,13 @@ class DistributedJobManager:
|
||||
|
||||
# download zip file from server
|
||||
try:
|
||||
local_job.children[child_key]['download_status'] = 'working'
|
||||
logger.info(f"Downloading completed subjob files from {subjob_hostname} to localhost")
|
||||
RenderServerProxy(subjob_hostname).get_job_files(subjob_id, zip_file_path)
|
||||
logger.info(f"File transfer complete for {logname} - Transferred {get_file_size_human(zip_file_path)}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception downloading files from remote server: {e}")
|
||||
local_job.children[child_key]['download_status'] = 'failed'
|
||||
return False
|
||||
|
||||
# extract zip
|
||||
@@ -176,173 +155,129 @@ class DistributedJobManager:
|
||||
zip_ref.extractall(extract_path)
|
||||
logger.info(f"Successfully extracted zip to: {extract_path}")
|
||||
os.remove(zip_file_path)
|
||||
return True
|
||||
local_job.children[child_key]['download_status'] = 'complete'
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception extracting zip file: {e}")
|
||||
return False
|
||||
local_job.children[child_key]['download_status'] = 'failed'
|
||||
|
||||
return local_job.children[child_key].get('download_status', None) == 'complete'
|
||||
|
||||
@classmethod
|
||||
def wait_for_subjobs(cls, parent_job):
|
||||
"""
|
||||
Wait for subjobs to complete and update the parent job's status.
|
||||
def wait_for_subjobs(cls, local_job):
|
||||
logger.debug(f"Waiting for subjobs for job {local_job}")
|
||||
local_job.status = RenderStatus.WAITING_FOR_SUBJOBS
|
||||
statuses_to_download = [RenderStatus.CANCELLED, RenderStatus.ERROR, RenderStatus.COMPLETED]
|
||||
|
||||
This method continuously checks the status of subjobs until all of them are either completed, canceled, or in error
|
||||
status. It updates the parent job's children with the latest subjob information.
|
||||
def subjobs_not_downloaded():
|
||||
return {k: v for k, v in local_job.children.items() if 'download_status' not in v or
|
||||
v['download_status'] == 'working' or v['download_status'] is None}
|
||||
|
||||
Parameters:
|
||||
parent_job (BaseRenderWorker): The parent job worker.
|
||||
logger.info(f'Waiting on {len(subjobs_not_downloaded())} subjobs for {local_job.id}')
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
logger.debug(f"Waiting for subjobs for job {parent_job}")
|
||||
parent_job.status = RenderStatus.WAITING_FOR_SUBJOBS
|
||||
server_proxys = {}
|
||||
while len(subjobs_not_downloaded()):
|
||||
for child_key, subjob_cached_data in subjobs_not_downloaded().items():
|
||||
|
||||
def fetch_subjob_info(child_key):
|
||||
"""
|
||||
Fetch subjob information from the remote server using a RenderServerProxy.
|
||||
|
||||
Parameters:
|
||||
child_key (str): The key representing the subjob.
|
||||
|
||||
Returns:
|
||||
dict: Subjob information.
|
||||
"""
|
||||
subjob_id, subjob_hostname = child_key.split('@')
|
||||
if subjob_hostname not in server_proxys:
|
||||
server_proxys[subjob_hostname] = RenderServerProxy(subjob_hostname)
|
||||
return server_proxys[subjob_hostname].get_job_info(subjob_id)
|
||||
|
||||
while True:
|
||||
incomplete_jobs = {}
|
||||
|
||||
for child_key in list(
|
||||
parent_job.children.keys()): # Create a list to avoid dictionary modification during iteration
|
||||
subjob_data = fetch_subjob_info(child_key)
|
||||
subjob_id = child_key.split('@')[0]
|
||||
subjob_hostname = child_key.split('@')[-1]
|
||||
|
||||
# Fetch info from server and handle failing case
|
||||
subjob_data = RenderServerProxy(subjob_hostname).get_job_info(subjob_id)
|
||||
if not subjob_data:
|
||||
subjob_id, subjob_hostname = child_key.split('@')
|
||||
last_connection = datetime.datetime.now() - server_proxys[subjob_hostname].last_contact
|
||||
logger.warning(f"No response from: {subjob_hostname} - Last connection: {last_connection}")
|
||||
|
||||
last_connection_max_time = 12
|
||||
if last_connection.seconds > last_connection_max_time:
|
||||
logger.error(
|
||||
f"{subjob_hostname} has been offline for over {last_connection_max_time} seconds - Assuming render failed")
|
||||
logger.warning(f"Spinning up a new subjob to replace the offlined server")
|
||||
parent_job.children[child_key]['errors'] = ['Renderer went offline']
|
||||
parent_job.children[child_key]['status'] = RenderStatus.ERROR
|
||||
|
||||
cls.handle_subjob_status_change(parent_job_id=parent_job.id,
|
||||
subjob_data=parent_job.children[child_key])
|
||||
logger.warning(f"No response from: {subjob_hostname}")
|
||||
# todo: handle timeout / missing server situations
|
||||
continue
|
||||
|
||||
parent_job.children[child_key] = subjob_data
|
||||
# Update parent job cache but keep the download status
|
||||
download_status = local_job.children[child_key].get('download_status', None)
|
||||
local_job.children[child_key] = subjob_data
|
||||
local_job.children[child_key]['download_status'] = download_status
|
||||
|
||||
status = string_to_status(subjob_data.get('status', ''))
|
||||
status_msg = f"Subjob {child_key} | {status} | {float(subjob_data.get('percent_complete', 0)) * 100.0}%"
|
||||
status_msg = f"Subjob {child_key} | {status} | " \
|
||||
f"{float(subjob_data.get('percent_complete')) * 100.0}%"
|
||||
logger.debug(status_msg)
|
||||
|
||||
if status not in [RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.ERROR]:
|
||||
incomplete_jobs[child_key] = subjob_data
|
||||
# Still working in another thread - keep waiting
|
||||
if download_status == 'working':
|
||||
continue
|
||||
|
||||
if incomplete_jobs:
|
||||
logger.debug(f"Waiting on {len(incomplete_jobs)} subjobs on {', '.join(list(incomplete_jobs.keys()))}")
|
||||
else:
|
||||
logger.debug("No more incomplete subjobs")
|
||||
if not cls.completion_hold_enabled:
|
||||
break
|
||||
time.sleep(5)
|
||||
# Check if job is finished, but has not had files copied yet over yet
|
||||
if download_status is None and subjob_data['file_count'] and status in statuses_to_download:
|
||||
download_result = cls.download_from_subjob(local_job, subjob_id, subjob_hostname)
|
||||
if not download_result:
|
||||
logger.error("Failed to download from subjob")
|
||||
# todo: error handling here
|
||||
|
||||
# Any finished jobs not successfully downloaded at this point are skipped
|
||||
if local_job.children[child_key].get('download_status', None) is None and \
|
||||
status in statuses_to_download:
|
||||
logger.warning(f"Skipping waiting on downloading from subjob: {child_key}")
|
||||
local_job.children[child_key]['download_status'] = 'skipped'
|
||||
|
||||
if subjobs_not_downloaded():
|
||||
logger.debug(f"Waiting on {len(subjobs_not_downloaded())} subjobs on "
|
||||
f"{', '.join(list(subjobs_not_downloaded().keys()))}")
|
||||
time.sleep(5)
|
||||
|
||||
@classmethod
|
||||
def split_into_subjobs(cls, parent_worker, job_data, project_path):
|
||||
def split_into_subjobs(cls, worker, job_data, project_path, system_os=None):
|
||||
|
||||
# Check availability
|
||||
available_servers = cls.find_available_servers(parent_worker.renderer)
|
||||
available_servers = cls.find_available_servers(worker.renderer, system_os)
|
||||
logger.debug(f"Splitting into subjobs - Available servers: {available_servers}")
|
||||
subjob_frame_ranges = cls.distribute_server_work(parent_worker.start_frame, parent_worker.end_frame, available_servers)
|
||||
subjob_servers = cls.distribute_server_work(worker.start_frame, worker.end_frame, available_servers)
|
||||
local_hostname = socket.gethostname()
|
||||
|
||||
# Prep and submit these sub-jobs
|
||||
simple_ranges = [f"{x['hostname']}:[{x['frame_range'][0]}-{x['frame_range'][1]}]" for x in subjob_frame_ranges]
|
||||
logger.info(f"Job {parent_worker.id} split plan: {','.join(simple_ranges)}")
|
||||
logger.info(f"Job {worker.id} split plan: {subjob_servers}")
|
||||
try:
|
||||
|
||||
# setup parent render job first - truncate frames
|
||||
local_range = [x for x in subjob_frame_ranges if x['hostname'] == local_hostname][0]
|
||||
parent_worker.start_frame = max(local_range['frame_range'][0], parent_worker.start_frame)
|
||||
parent_worker.end_frame = min(local_range['frame_range'][-1], parent_worker.end_frame)
|
||||
logger.info(f"Local job now rendering from {parent_worker.start_frame} to {parent_worker.end_frame}")
|
||||
RenderQueue.add_to_render_queue(parent_worker) # add range-adjusted parent to render queue
|
||||
|
||||
# setup remote subjobs
|
||||
submission_results = {}
|
||||
for subjob_server_data in subjob_frame_ranges:
|
||||
server_hostname = subjob_server_data['hostname']
|
||||
for server_data in subjob_servers:
|
||||
server_hostname = server_data['hostname']
|
||||
if server_hostname != local_hostname:
|
||||
post_results = cls.new_create_subjob(parent_worker.id, server_hostname,
|
||||
subjob_server_data['frame_range'][0],
|
||||
subjob_server_data['frame_range'][-1])
|
||||
|
||||
post_results = cls.__create_subjob(job_data, local_hostname, project_path, server_data,
|
||||
server_hostname, worker)
|
||||
if post_results.ok:
|
||||
subjob_server_data['submission_results'] = post_results.json()[0]
|
||||
server_data['submission_results'] = post_results.json()[0]
|
||||
else:
|
||||
logger.error(f"Failed to create subjob on {server_hostname}")
|
||||
break
|
||||
else:
|
||||
subjob_server_data['submission_results'] = [True]
|
||||
# truncate parent render_job
|
||||
worker.start_frame = max(server_data['frame_range'][0], worker.start_frame)
|
||||
worker.end_frame = min(server_data['frame_range'][-1], worker.end_frame)
|
||||
logger.info(f"Local job now rendering from {worker.start_frame} to {worker.end_frame}")
|
||||
server_data['submission_results'] = worker.json()
|
||||
|
||||
# check that job posts were all successful.
|
||||
# if not all(d.get('submission_results') is not None for d in subjob_frame_ranges):
|
||||
# # todo: rewrite this code - should not have to have all submissions go through
|
||||
# raise ValueError("Failed to create all subjobs") # look into recalculating job #s and use exising jobs
|
||||
if not all(d.get('submission_results') is not None for d in subjob_servers):
|
||||
raise ValueError("Failed to create all subjobs") # look into recalculating job #s and use exising jobs
|
||||
|
||||
# start subjobs
|
||||
logger.debug(f"Starting {len(subjob_frame_ranges) - 1} attempted subjobs")
|
||||
for subjob_server_data in subjob_frame_ranges:
|
||||
if subjob_server_data['hostname'] != local_hostname:
|
||||
child_key = f"{subjob_server_data['submission_results']['id']}@{subjob_server_data['hostname']}"
|
||||
parent_worker.children[child_key] = subjob_server_data['submission_results']
|
||||
parent_worker.name = f"{parent_worker.name}[{parent_worker.start_frame}-{parent_worker.end_frame}]"
|
||||
logger.debug(f"Starting {len(subjob_servers) - 1} attempted subjobs")
|
||||
for server_data in subjob_servers:
|
||||
if server_data['hostname'] != local_hostname:
|
||||
child_key = f"{server_data['submission_results']['id']}@{server_data['hostname']}"
|
||||
worker.children[child_key] = server_data['submission_results']
|
||||
worker.name = f"{worker.name}[{worker.start_frame}-{worker.end_frame}]"
|
||||
|
||||
except Exception as e:
|
||||
# cancel all the subjobs
|
||||
logger.exception(f"Failed to split job into subjobs: {e}")
|
||||
logger.debug(f"Cancelling {len(subjob_frame_ranges) - 1} attempted subjobs")
|
||||
logger.error(f"Failed to split job into subjobs: {e}")
|
||||
logger.debug(f"Cancelling {len(subjob_servers) - 1} attempted subjobs")
|
||||
# [RenderServerProxy(hostname).cancel_job(results['id'], confirm=True) for hostname, results in
|
||||
# submission_results.items()] # todo: fix this
|
||||
|
||||
@staticmethod
|
||||
def new_create_subjob(parent_job_id, remote_hostname, start_frame, end_frame):
|
||||
"""
|
||||
Create and post a subjob to a remote render server.
|
||||
|
||||
Parameters:
|
||||
- parent_job_id (str): ID of the parent job.
|
||||
- remote_hostname (str): Remote server's hostname/address.
|
||||
- start_frame (int): Starting frame of the subjob.
|
||||
- end_frame (int): Ending frame of the subjob.
|
||||
|
||||
Example:
|
||||
new_create_subjob('parent_job_123', 'remote-server.example.com', 1, 100)
|
||||
"""
|
||||
logger.info(f"parentID: {parent_job_id}")
|
||||
local_hostname = socket.gethostname()
|
||||
parent_job = RenderQueue.job_with_id(parent_job_id)
|
||||
subjob_data = {'renderer': parent_job.engine.name(), 'input_path': parent_job.input_path,
|
||||
'args': parent_job.args, 'output_path': parent_job.output_path,
|
||||
'engine_version': parent_job.renderer_version, 'start_frame': start_frame,
|
||||
'end_frame': end_frame, 'parent': f"{parent_job_id}@{local_hostname}"}
|
||||
|
||||
logger.info(f"Creating subjob {os.path.basename(parent_job.input_path)} [{start_frame}-{end_frame}] "
|
||||
f"for {remote_hostname}")
|
||||
post_results = RenderServerProxy(remote_hostname).post_job_to_server(
|
||||
file_path=parent_job.input_path, job_list=[subjob_data])
|
||||
post_results_json = post_results.json()[0]
|
||||
|
||||
parent_job.children[f"{post_results_json['id']}@{remote_hostname}"] = post_results_json
|
||||
def __create_subjob(job_data, local_hostname, project_path, server_data, server_hostname, worker):
|
||||
subjob = job_data.copy()
|
||||
subjob['name'] = f"{worker.name}[{server_data['frame_range'][0]}-{server_data['frame_range'][-1]}]"
|
||||
subjob['parent'] = f"{worker.id}@{local_hostname}"
|
||||
subjob['start_frame'] = server_data['frame_range'][0]
|
||||
subjob['end_frame'] = server_data['frame_range'][-1]
|
||||
logger.debug(f"Posting subjob with frames {subjob['start_frame']}-"
|
||||
f"{subjob['end_frame']} to {server_hostname}")
|
||||
post_results = RenderServerProxy(server_hostname).post_job_to_server(
|
||||
file_path=project_path, job_list=[subjob])
|
||||
return post_results
|
||||
|
||||
@staticmethod
|
||||
@@ -418,17 +353,20 @@ class DistributedJobManager:
|
||||
return server_breakdown
|
||||
|
||||
@staticmethod
|
||||
def find_available_servers(engine_name):
|
||||
def find_available_servers(engine_name, system_os=None):
|
||||
"""
|
||||
Scan the Zeroconf network for currently available render servers supporting a specific engine.
|
||||
|
||||
:param engine_name: str, The engine type to search for
|
||||
:param system_os: str, Restrict results to servers running a specific OS
|
||||
:return: A list of dictionaries with each dict containing hostname and cpu_count of available servers
|
||||
"""
|
||||
available_servers = []
|
||||
for hostname in ZeroconfServer.found_clients():
|
||||
response = RenderServerProxy(hostname).is_engine_available(engine_name)
|
||||
if response and response.get('available', False):
|
||||
available_servers.append(response)
|
||||
for hostname in ZeroconfServer.found_hostnames():
|
||||
host_properties = ZeroconfServer.get_hostname_properties(hostname)
|
||||
if not system_os or (system_os and system_os == host_properties.get('system_os')):
|
||||
response = RenderServerProxy(hostname).is_engine_available(engine_name)
|
||||
if response and response.get('available', False):
|
||||
available_servers.append(response)
|
||||
|
||||
return available_servers
|
||||
|
||||
@@ -3,7 +3,8 @@ import re
|
||||
|
||||
import requests
|
||||
|
||||
from src.engines.core.downloader_core import download_and_extract_app
|
||||
from src.engines.blender.blender_engine import Blender
|
||||
from src.engines.core.base_downloader import EngineDownloader
|
||||
from src.utilities.misc_helper import current_system_os, current_system_cpu
|
||||
|
||||
# url = "https://download.blender.org/release/"
|
||||
@@ -13,10 +14,12 @@ logger = logging.getLogger()
|
||||
supported_formats = ['.zip', '.tar.xz', '.dmg']
|
||||
|
||||
|
||||
class BlenderDownloader:
|
||||
class BlenderDownloader(EngineDownloader):
|
||||
|
||||
engine = Blender
|
||||
|
||||
@staticmethod
|
||||
def get_major_versions():
|
||||
def __get_major_versions():
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
@@ -30,10 +33,10 @@ class BlenderDownloader:
|
||||
return major_versions
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error: {e}")
|
||||
return None
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_minor_versions(major_version, system_os=None, cpu=None):
|
||||
def __get_minor_versions(major_version, system_os=None, cpu=None):
|
||||
|
||||
try:
|
||||
base_url = url + 'Blender' + major_version
|
||||
@@ -63,17 +66,8 @@ class BlenderDownloader:
|
||||
logger.exception(e)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def version_is_available_to_download(cls, version, system_os=None, cpu=None):
|
||||
requested_major_version = '.'.join(version.split('.')[:2])
|
||||
minor_versions = cls.get_minor_versions(requested_major_version, system_os, cpu)
|
||||
for minor in minor_versions:
|
||||
if minor['version'] == version:
|
||||
return minor
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_LTS_versions():
|
||||
def __find_LTS_versions():
|
||||
response = requests.get('https://www.blender.org/download/lts/')
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -87,11 +81,21 @@ class BlenderDownloader:
|
||||
@classmethod
|
||||
def find_most_recent_version(cls, system_os=None, cpu=None, lts_only=False):
|
||||
try:
|
||||
major_version = cls.find_LTS_versions()[0] if lts_only else cls.get_major_versions()[0]
|
||||
most_recent = cls.get_minor_versions(major_version=major_version, system_os=system_os, cpu=cpu)
|
||||
major_version = cls.__find_LTS_versions()[0] if lts_only else cls.__get_major_versions()[0]
|
||||
most_recent = cls.__get_minor_versions(major_version=major_version, system_os=system_os, cpu=cpu)
|
||||
return most_recent[0]
|
||||
except IndexError:
|
||||
logger.error("Cannot find a most recent version")
|
||||
except (IndexError, requests.exceptions.RequestException):
|
||||
logger.error(f"Cannot get most recent version of blender")
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def version_is_available_to_download(cls, version, system_os=None, cpu=None):
|
||||
requested_major_version = '.'.join(version.split('.')[:2])
|
||||
minor_versions = cls.__get_minor_versions(requested_major_version, system_os, cpu)
|
||||
for minor in minor_versions:
|
||||
if minor['version'] == version:
|
||||
return minor
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120):
|
||||
@@ -101,11 +105,11 @@ class BlenderDownloader:
|
||||
try:
|
||||
logger.info(f"Requesting download of blender-{version}-{system_os}-{cpu}")
|
||||
major_version = '.'.join(version.split('.')[:2])
|
||||
minor_versions = [x for x in cls.get_minor_versions(major_version, system_os, cpu) if x['version'] == version]
|
||||
minor_versions = [x for x in cls.__get_minor_versions(major_version, system_os, cpu) if x['version'] == version]
|
||||
# we get the URL instead of calculating it ourselves. May change this
|
||||
|
||||
download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location,
|
||||
timeout=timeout)
|
||||
cls.download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location,
|
||||
timeout=timeout)
|
||||
except IndexError:
|
||||
logger.error("Cannot find requested engine")
|
||||
|
||||
@@ -113,5 +117,5 @@ class BlenderDownloader:
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
print(BlenderDownloader.get_major_versions())
|
||||
print(BlenderDownloader.__get_major_versions())
|
||||
|
||||
|
||||
@@ -10,9 +10,26 @@ logger = logging.getLogger()
|
||||
class Blender(BaseRenderEngine):
|
||||
|
||||
install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender']
|
||||
supported_extensions = ['.blend']
|
||||
binary_names = {'linux': 'blender', 'windows': 'blender.exe', 'macos': 'Blender'}
|
||||
|
||||
@staticmethod
|
||||
def downloader():
|
||||
from src.engines.blender.blender_downloader import BlenderDownloader
|
||||
return BlenderDownloader
|
||||
|
||||
@staticmethod
|
||||
def worker_class():
|
||||
from src.engines.blender.blender_worker import BlenderRenderWorker
|
||||
return BlenderRenderWorker
|
||||
|
||||
def ui_options(self):
|
||||
from src.engines.blender.blender_ui import BlenderUI
|
||||
return BlenderUI.get_options(self)
|
||||
|
||||
@staticmethod
|
||||
def supported_extensions():
|
||||
return ['blend']
|
||||
|
||||
def version(self):
|
||||
version = None
|
||||
try:
|
||||
@@ -53,7 +70,7 @@ class Blender(BaseRenderEngine):
|
||||
raise FileNotFoundError(f'Python script not found: {script_path}')
|
||||
raise Exception("Uncaught exception")
|
||||
|
||||
def get_scene_info(self, project_path, timeout=10):
|
||||
def get_project_info(self, project_path, timeout=10):
|
||||
scene_info = {}
|
||||
try:
|
||||
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_file_info.py')
|
||||
@@ -128,6 +145,7 @@ class Blender(BaseRenderEngine):
|
||||
return options
|
||||
|
||||
def get_detected_gpus(self):
|
||||
# no longer works on 4.0
|
||||
engine_output = subprocess.run([self.renderer_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT,
|
||||
capture_output=True).stdout.decode('utf-8')
|
||||
gpu_names = re.findall(r"DETECTED GPU: (.+)", engine_output)
|
||||
@@ -139,6 +157,10 @@ class Blender(BaseRenderEngine):
|
||||
render_engines = [x.strip() for x in engine_output.split('Blender Engine Listing:')[-1].strip().splitlines()]
|
||||
return render_engines
|
||||
|
||||
def perform_presubmission_tasks(self, project_path):
|
||||
packed_path = self.pack_project_file(project_path, timeout=30)
|
||||
return packed_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
x = Blender.get_detected_gpus()
|
||||
|
||||
8
src/engines/blender/blender_ui.py
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
class BlenderUI:
|
||||
@staticmethod
|
||||
def get_options(instance):
|
||||
options = [
|
||||
{'name': 'engine', 'options': instance.supported_render_engines()},
|
||||
]
|
||||
return options
|
||||
@@ -24,10 +24,11 @@ class BlenderRenderWorker(BaseRenderWorker):
|
||||
self.__frame_percent_complete = 0.0
|
||||
|
||||
# Scene Info
|
||||
self.scene_info = Blender(engine_path).get_scene_info(input_path)
|
||||
self.scene_info = Blender(engine_path).get_project_info(input_path)
|
||||
self.start_frame = int(self.scene_info.get('start_frame', 1))
|
||||
self.end_frame = int(self.scene_info.get('end_frame', self.start_frame))
|
||||
self.project_length = (self.end_frame - self.start_frame) + 1
|
||||
self.current_frame = -1
|
||||
|
||||
def generate_worker_subprocess(self):
|
||||
|
||||
@@ -139,7 +140,7 @@ class BlenderRenderWorker(BaseRenderWorker):
|
||||
if __name__ == '__main__':
|
||||
|
||||
import pprint
|
||||
x = Blender.get_scene_info('/Users/brett/Desktop/TC Previz/nallie_farm.blend')
|
||||
x = Blender.get_project_info('/Users/brett/Desktop/TC Previz/nallie_farm.blend')
|
||||
pprint.pprint(x)
|
||||
|
||||
# logging.basicConfig(format='%(asctime)s - %(message)s', datefmt='%d-%b-%y %H:%M:%S', level=logging.DEBUG)
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import bpy
|
||||
|
||||
# Get all cameras
|
||||
scene = bpy.data.scenes[0]
|
||||
cameras = []
|
||||
for cam_obj in bpy.data.cameras:
|
||||
user_map = bpy.data.user_map(subset={cam_obj}, value_types={'OBJECT'})
|
||||
@@ -12,10 +13,10 @@ for cam_obj in bpy.data.cameras:
|
||||
'lens': cam_obj.lens,
|
||||
'lens_unit': cam_obj.lens_unit,
|
||||
'sensor_height': cam_obj.sensor_height,
|
||||
'sensor_width': cam_obj.sensor_width}
|
||||
'sensor_width': cam_obj.sensor_width,
|
||||
'is_active': scene.camera.name_full == cam_obj.name_full}
|
||||
cameras.append(cam)
|
||||
|
||||
scene = bpy.data.scenes[0]
|
||||
data = {'cameras': cameras,
|
||||
'engine': scene.render.engine,
|
||||
'frame_start': scene.frame_start,
|
||||
|
||||
160
src/engines/core/base_downloader.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class EngineDownloader:
|
||||
|
||||
supported_formats = ['.zip', '.tar.xz', '.dmg']
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def find_most_recent_version(cls, system_os=None, cpu=None, lts_only=False):
|
||||
raise NotImplementedError # implement this method in your engine subclass
|
||||
|
||||
@classmethod
|
||||
def version_is_available_to_download(cls, version, system_os=None, cpu=None):
|
||||
raise NotImplementedError # implement this method in your engine subclass
|
||||
|
||||
@classmethod
|
||||
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120):
|
||||
raise NotImplementedError # implement this method in your engine subclass
|
||||
|
||||
@classmethod
|
||||
def download_and_extract_app(cls, remote_url, download_location, timeout=120):
|
||||
|
||||
# Create a temp download directory
|
||||
temp_download_dir = tempfile.mkdtemp()
|
||||
temp_downloaded_file_path = os.path.join(temp_download_dir, os.path.basename(remote_url))
|
||||
|
||||
try:
|
||||
output_dir_name = os.path.basename(remote_url)
|
||||
for fmt in cls.supported_formats:
|
||||
output_dir_name = output_dir_name.split(fmt)[0]
|
||||
|
||||
if os.path.exists(os.path.join(download_location, output_dir_name)):
|
||||
logger.error(f"Engine download for {output_dir_name} already exists")
|
||||
return
|
||||
|
||||
if not os.path.exists(temp_downloaded_file_path):
|
||||
# Make a GET request to the URL with stream=True to enable streaming
|
||||
logger.info(f"Downloading {output_dir_name} from {remote_url}")
|
||||
response = requests.get(remote_url, stream=True, timeout=timeout)
|
||||
|
||||
# Check if the request was successful
|
||||
if response.status_code == 200:
|
||||
# Get the total file size from the "Content-Length" header
|
||||
file_size = int(response.headers.get("Content-Length", 0))
|
||||
|
||||
# Create a progress bar using tqdm
|
||||
progress_bar = tqdm(total=file_size, unit="B", unit_scale=True)
|
||||
|
||||
# Open a file for writing in binary mode
|
||||
with open(temp_downloaded_file_path, "wb") as file:
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
if chunk:
|
||||
# Write the chunk to the file
|
||||
file.write(chunk)
|
||||
# Update the progress bar
|
||||
progress_bar.update(len(chunk))
|
||||
|
||||
# Close the progress bar
|
||||
progress_bar.close()
|
||||
logger.info(f"Successfully downloaded {os.path.basename(temp_downloaded_file_path)}")
|
||||
else:
|
||||
logger.error(f"Failed to download the file. Status code: {response.status_code}")
|
||||
return
|
||||
|
||||
os.makedirs(download_location, exist_ok=True)
|
||||
|
||||
# Extract the downloaded file
|
||||
# Process .tar.xz files
|
||||
if temp_downloaded_file_path.lower().endswith('.tar.xz'):
|
||||
try:
|
||||
with tarfile.open(temp_downloaded_file_path, 'r:xz') as tar:
|
||||
tar.extractall(path=download_location)
|
||||
|
||||
logger.info(
|
||||
f'Successfully extracted {os.path.basename(temp_downloaded_file_path)} to {download_location}')
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f'Error extracting {temp_downloaded_file_path}: {e}')
|
||||
except FileNotFoundError:
|
||||
logger.error(f'File not found: {temp_downloaded_file_path}')
|
||||
|
||||
# Process .zip files
|
||||
elif temp_downloaded_file_path.lower().endswith('.zip'):
|
||||
try:
|
||||
with zipfile.ZipFile(temp_downloaded_file_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(download_location)
|
||||
logger.info(
|
||||
f'Successfully extracted {os.path.basename(temp_downloaded_file_path)} to {download_location}')
|
||||
except zipfile.BadZipFile as e:
|
||||
logger.error(f'Error: {temp_downloaded_file_path} is not a valid ZIP file.')
|
||||
except FileNotFoundError:
|
||||
logger.error(f'File not found: {temp_downloaded_file_path}')
|
||||
|
||||
# Process .dmg files (macOS only)
|
||||
elif temp_downloaded_file_path.lower().endswith('.dmg'):
|
||||
import dmglib
|
||||
dmg = dmglib.DiskImage(temp_downloaded_file_path)
|
||||
for mount_point in dmg.attach():
|
||||
try:
|
||||
copy_directory_contents(mount_point, os.path.join(download_location, output_dir_name))
|
||||
logger.info(f'Successfully copied {os.path.basename(temp_downloaded_file_path)} to {download_location}')
|
||||
except FileNotFoundError:
|
||||
logger.error(f'Error: The source .app bundle does not exist.')
|
||||
except PermissionError:
|
||||
logger.error(f'Error: Permission denied to copy {download_location}.')
|
||||
except Exception as e:
|
||||
logger.error(f'An error occurred: {e}')
|
||||
dmg.detach()
|
||||
|
||||
else:
|
||||
logger.error("Unknown file. Unable to extract binary.")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
# remove downloaded file on completion
|
||||
shutil.rmtree(temp_download_dir)
|
||||
return download_location
|
||||
|
||||
|
||||
# Function to copy directory contents but ignore symbolic links and hidden files
|
||||
def copy_directory_contents(src_dir, dest_dir):
|
||||
try:
|
||||
# Create the destination directory if it doesn't exist
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
for item in os.listdir(src_dir):
|
||||
item_path = os.path.join(src_dir, item)
|
||||
|
||||
# Ignore symbolic links
|
||||
if os.path.islink(item_path):
|
||||
continue
|
||||
|
||||
# Ignore hidden files or directories (those starting with a dot)
|
||||
if not item.startswith('.'):
|
||||
dest_item_path = os.path.join(dest_dir, item)
|
||||
|
||||
# If it's a directory, recursively copy its contents
|
||||
if os.path.isdir(item_path):
|
||||
copy_directory_contents(item_path, dest_item_path)
|
||||
else:
|
||||
# Otherwise, copy the file
|
||||
shutil.copy2(item_path, dest_item_path)
|
||||
|
||||
except PermissionError as ex:
|
||||
logger.error(f"Permissions error: {ex}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error copying directory contents: {e}")
|
||||
@@ -21,7 +21,7 @@ class BaseRenderEngine(object):
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
return cls.__name__.lower()
|
||||
return str(cls.__name__).lower()
|
||||
|
||||
@classmethod
|
||||
def default_renderer_path(cls):
|
||||
@@ -39,7 +39,18 @@ class BaseRenderEngine(object):
|
||||
def version(self):
|
||||
raise NotImplementedError("version not implemented")
|
||||
|
||||
def get_help(self):
|
||||
@staticmethod
|
||||
def downloader(): # override when subclassing if using a downloader class
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def worker_class(): # override when subclassing to link worker class
|
||||
raise NotImplementedError("Worker class not implemented")
|
||||
|
||||
def ui_options(self): # override to return options for ui
|
||||
return {}
|
||||
|
||||
def get_help(self): # override if renderer uses different help flag
|
||||
path = self.renderer_path()
|
||||
if not path:
|
||||
raise FileNotFoundError("renderer path not found")
|
||||
@@ -47,6 +58,9 @@ class BaseRenderEngine(object):
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
return help_doc
|
||||
|
||||
def get_project_info(self, project_path, timeout=10):
|
||||
raise NotImplementedError(f"get_project_info not implemented for {self.__name__}")
|
||||
|
||||
@classmethod
|
||||
def get_output_formats(cls):
|
||||
raise NotImplementedError(f"get_output_formats not implemented for {cls.__name__}")
|
||||
@@ -54,3 +68,7 @@ class BaseRenderEngine(object):
|
||||
@classmethod
|
||||
def get_arguments(cls):
|
||||
pass
|
||||
|
||||
def perform_presubmission_tasks(self, project_path):
|
||||
return project_path
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ class BaseRenderWorker(Base):
|
||||
project_length = Column(Integer)
|
||||
start_frame = Column(Integer)
|
||||
end_frame = Column(Integer, nullable=True)
|
||||
current_frame = Column(Integer)
|
||||
parent = Column(String, nullable=True)
|
||||
children = Column(JSON)
|
||||
name = Column(String)
|
||||
@@ -48,7 +47,7 @@ class BaseRenderWorker(Base):
|
||||
name=None):
|
||||
|
||||
if not ignore_extensions:
|
||||
if not any(ext in input_path for ext in self.engine.supported_extensions):
|
||||
if not any(ext in input_path for ext in self.engine.supported_extensions()):
|
||||
err_meg = f'Cannot find valid project with supported file extension for {self.engine.name()} renderer'
|
||||
logger.error(err_meg)
|
||||
raise ValueError(err_meg)
|
||||
@@ -76,7 +75,7 @@ class BaseRenderWorker(Base):
|
||||
|
||||
# Frame Ranges
|
||||
self.project_length = -1
|
||||
self.current_frame = -1 # negative indicates not started
|
||||
self.current_frame = 0 # should this be a 1 ?
|
||||
self.start_frame = 0 # should this be a 1 ?
|
||||
self.end_frame = None
|
||||
|
||||
@@ -237,7 +236,7 @@ class BaseRenderWorker(Base):
|
||||
|
||||
if self.children:
|
||||
from src.distributed_job_manager import DistributedJobManager
|
||||
DistributedJobManager.wait_for_subjobs(parent_job=self)
|
||||
DistributedJobManager.wait_for_subjobs(local_job=self)
|
||||
|
||||
# Post Render Work
|
||||
logger.debug("Starting post-processing work")
|
||||
@@ -305,6 +304,7 @@ class BaseRenderWorker(Base):
|
||||
'children': self.children,
|
||||
'date_created': self.date_created,
|
||||
'start_time': self.start_time,
|
||||
'end_time': self.end_time,
|
||||
'status': self.status.value,
|
||||
'file_hash': self.file_hash,
|
||||
'percent_complete': self.percent_complete(),
|
||||
@@ -314,7 +314,6 @@ class BaseRenderWorker(Base):
|
||||
'errors': getattr(self, 'errors', None),
|
||||
'start_frame': self.start_frame,
|
||||
'end_frame': self.end_frame,
|
||||
'current_frame': self.current_frame,
|
||||
'total_frames': self.total_frames,
|
||||
'last_output': getattr(self, 'last_output', None),
|
||||
'log_path': self.log_path()
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
supported_formats = ['.zip', '.tar.xz', '.dmg']
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def download_and_extract_app(remote_url, download_location, timeout=120):
|
||||
|
||||
# Create a temp download directory
|
||||
temp_download_dir = tempfile.mkdtemp()
|
||||
temp_downloaded_file_path = os.path.join(temp_download_dir, os.path.basename(remote_url))
|
||||
|
||||
try:
|
||||
output_dir_name = os.path.basename(remote_url)
|
||||
for fmt in supported_formats:
|
||||
output_dir_name = output_dir_name.split(fmt)[0]
|
||||
|
||||
if os.path.exists(os.path.join(download_location, output_dir_name)):
|
||||
logger.error(f"Engine download for {output_dir_name} already exists")
|
||||
return
|
||||
|
||||
if not os.path.exists(temp_downloaded_file_path):
|
||||
# Make a GET request to the URL with stream=True to enable streaming
|
||||
logger.info(f"Downloading {output_dir_name} from {remote_url}")
|
||||
response = requests.get(remote_url, stream=True, timeout=timeout)
|
||||
|
||||
# Check if the request was successful
|
||||
if response.status_code == 200:
|
||||
# Get the total file size from the "Content-Length" header
|
||||
file_size = int(response.headers.get("Content-Length", 0))
|
||||
|
||||
# Create a progress bar using tqdm
|
||||
progress_bar = tqdm(total=file_size, unit="B", unit_scale=True)
|
||||
|
||||
# Open a file for writing in binary mode
|
||||
with open(temp_downloaded_file_path, "wb") as file:
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
if chunk:
|
||||
# Write the chunk to the file
|
||||
file.write(chunk)
|
||||
# Update the progress bar
|
||||
progress_bar.update(len(chunk))
|
||||
|
||||
# Close the progress bar
|
||||
progress_bar.close()
|
||||
logger.info(f"Successfully downloaded {os.path.basename(temp_downloaded_file_path)}")
|
||||
else:
|
||||
logger.error(f"Failed to download the file. Status code: {response.status_code}")
|
||||
return
|
||||
|
||||
os.makedirs(download_location, exist_ok=True)
|
||||
|
||||
# Extract the downloaded file
|
||||
# Process .tar.xz files
|
||||
if temp_downloaded_file_path.lower().endswith('.tar.xz'):
|
||||
try:
|
||||
with tarfile.open(temp_downloaded_file_path, 'r:xz') as tar:
|
||||
tar.extractall(path=download_location)
|
||||
|
||||
logger.info(
|
||||
f'Successfully extracted {os.path.basename(temp_downloaded_file_path)} to {download_location}')
|
||||
except tarfile.TarError as e:
|
||||
logger.error(f'Error extracting {temp_downloaded_file_path}: {e}')
|
||||
except FileNotFoundError:
|
||||
logger.error(f'File not found: {temp_downloaded_file_path}')
|
||||
|
||||
# Process .zip files
|
||||
elif temp_downloaded_file_path.lower().endswith('.zip'):
|
||||
try:
|
||||
with zipfile.ZipFile(temp_downloaded_file_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(download_location)
|
||||
logger.info(
|
||||
f'Successfully extracted {os.path.basename(temp_downloaded_file_path)} to {download_location}')
|
||||
except zipfile.BadZipFile as e:
|
||||
logger.error(f'Error: {temp_downloaded_file_path} is not a valid ZIP file.')
|
||||
except FileNotFoundError:
|
||||
logger.error(f'File not found: {temp_downloaded_file_path}')
|
||||
|
||||
# Process .dmg files (macOS only)
|
||||
elif temp_downloaded_file_path.lower().endswith('.dmg'):
|
||||
import dmglib
|
||||
dmg = dmglib.DiskImage(temp_downloaded_file_path)
|
||||
for mount_point in dmg.attach():
|
||||
try:
|
||||
copy_directory_contents(mount_point, os.path.join(download_location, output_dir_name))
|
||||
logger.info(f'Successfully copied {os.path.basename(temp_downloaded_file_path)} to {download_location}')
|
||||
except FileNotFoundError:
|
||||
logger.error(f'Error: The source .app bundle does not exist.')
|
||||
except PermissionError:
|
||||
logger.error(f'Error: Permission denied to copy {download_location}.')
|
||||
except Exception as e:
|
||||
logger.error(f'An error occurred: {e}')
|
||||
dmg.detach()
|
||||
|
||||
else:
|
||||
logger.error("Unknown file. Unable to extract binary.")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
# remove downloaded file on completion
|
||||
shutil.rmtree(temp_download_dir)
|
||||
return download_location
|
||||
|
||||
|
||||
# Function to copy directory contents but ignore symbolic links and hidden files
|
||||
def copy_directory_contents(src_dir, dest_dir):
|
||||
try:
|
||||
# Create the destination directory if it doesn't exist
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
for item in os.listdir(src_dir):
|
||||
item_path = os.path.join(src_dir, item)
|
||||
|
||||
# Ignore symbolic links
|
||||
if os.path.islink(item_path):
|
||||
continue
|
||||
|
||||
# Ignore hidden files or directories (those starting with a dot)
|
||||
if not item.startswith('.'):
|
||||
dest_item_path = os.path.join(dest_dir, item)
|
||||
|
||||
# If it's a directory, recursively copy its contents
|
||||
if os.path.isdir(item_path):
|
||||
copy_directory_contents(item_path, dest_item_path)
|
||||
else:
|
||||
# Otherwise, copy the file
|
||||
shutil.copy2(item_path, dest_item_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error copying directory contents: {e}")
|
||||
@@ -1,61 +0,0 @@
|
||||
import logging
|
||||
|
||||
from src.engines.engine_manager import EngineManager
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class RenderWorkerFactory:
|
||||
|
||||
@staticmethod
|
||||
def supported_classes():
|
||||
# to add support for any additional RenderWorker classes, import their classes and add to list here
|
||||
from src.engines.blender.blender_worker import BlenderRenderWorker
|
||||
from src.engines.aerender.aerender_worker import AERenderWorker
|
||||
from src.engines.ffmpeg.ffmpeg_worker import FFMPEGRenderWorker
|
||||
classes = [BlenderRenderWorker, AERenderWorker, FFMPEGRenderWorker]
|
||||
return classes
|
||||
|
||||
@staticmethod
|
||||
def create_worker(renderer, input_path, output_path, engine_version=None, args=None, parent=None, name=None):
|
||||
|
||||
worker_class = RenderWorkerFactory.class_for_name(renderer)
|
||||
|
||||
# check to make sure we have versions installed
|
||||
all_versions = EngineManager.all_versions_for_engine(renderer)
|
||||
if not all_versions:
|
||||
raise FileNotFoundError(f"Cannot find any installed {renderer} engines")
|
||||
|
||||
# Find the path to the requested engine version or use default
|
||||
engine_path = None if engine_version else all_versions[0]['path']
|
||||
if engine_version:
|
||||
for ver in all_versions:
|
||||
if ver['version'] == engine_version:
|
||||
engine_path = ver['path']
|
||||
break
|
||||
|
||||
# Download the required engine if not found locally
|
||||
if not engine_path:
|
||||
download_result = EngineManager.download_engine(renderer, engine_version)
|
||||
if not download_result:
|
||||
raise FileNotFoundError(f"Cannot download requested version: {renderer} {engine_version}")
|
||||
engine_path = download_result['path']
|
||||
logger.info("Engine downloaded. Creating worker.")
|
||||
|
||||
if not engine_path:
|
||||
raise FileNotFoundError(f"Cannot find requested engine version {engine_version}")
|
||||
|
||||
return worker_class(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args,
|
||||
parent=parent, name=name)
|
||||
|
||||
@staticmethod
|
||||
def supported_renderers():
|
||||
return [x.engine.name() for x in RenderWorkerFactory.supported_classes()]
|
||||
|
||||
@staticmethod
|
||||
def class_for_name(name):
|
||||
name = name.lower()
|
||||
for render_class in RenderWorkerFactory.supported_classes():
|
||||
if render_class.engine.name() == name:
|
||||
return render_class
|
||||
raise LookupError(f'Cannot find class for name: {name}')
|
||||
@@ -3,9 +3,7 @@ import os
|
||||
import shutil
|
||||
import threading
|
||||
|
||||
from src.engines.blender.blender_downloader import BlenderDownloader
|
||||
from src.engines.blender.blender_engine import Blender
|
||||
from src.engines.ffmpeg.ffmpeg_downloader import FFMPEGDownloader
|
||||
from src.engines.ffmpeg.ffmpeg_engine import FFMPEG
|
||||
from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu
|
||||
|
||||
@@ -14,21 +12,27 @@ logger = logging.getLogger()
|
||||
|
||||
class EngineManager:
|
||||
|
||||
engines_path = "~/zordon-uploads/engines"
|
||||
downloader_classes = {
|
||||
"blender": BlenderDownloader,
|
||||
"ffmpeg": FFMPEGDownloader,
|
||||
# Add more engine types and corresponding downloader classes as needed
|
||||
}
|
||||
engines_path = None
|
||||
download_tasks = []
|
||||
|
||||
@classmethod
|
||||
def supported_engines(cls):
|
||||
@staticmethod
|
||||
def supported_engines():
|
||||
return [Blender, FFMPEG]
|
||||
|
||||
@classmethod
|
||||
def engine_with_name(cls, engine_name):
|
||||
for obj in cls.supported_engines():
|
||||
if obj.name().lower() == engine_name.lower():
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def all_engines(cls):
|
||||
results = []
|
||||
|
||||
if not cls.engines_path:
|
||||
raise FileNotFoundError("Engines path must be set before requesting downloads")
|
||||
|
||||
# Parse downloaded engine directory
|
||||
results = []
|
||||
try:
|
||||
all_items = os.listdir(cls.engines_path)
|
||||
all_directories = [item for item in all_items if os.path.isdir(os.path.join(cls.engines_path, item))]
|
||||
@@ -57,8 +61,8 @@ class EngineManager:
|
||||
|
||||
result_dict['path'] = path
|
||||
results.append(result_dict)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Cannot find local engines download directory")
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Cannot find local engines download directory: {e}")
|
||||
|
||||
# add system installs to this list
|
||||
for eng in cls.supported_engines():
|
||||
@@ -72,7 +76,9 @@ class EngineManager:
|
||||
|
||||
@classmethod
|
||||
def all_versions_for_engine(cls, engine):
|
||||
return [x for x in cls.all_engines() if x['engine'] == engine]
|
||||
versions = [x for x in cls.all_engines() if x['engine'] == engine]
|
||||
sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True)
|
||||
return sorted_versions
|
||||
|
||||
@classmethod
|
||||
def newest_engine_version(cls, engine, system_os=None, cpu=None):
|
||||
@@ -80,9 +86,8 @@ class EngineManager:
|
||||
cpu = cpu or current_system_cpu()
|
||||
|
||||
try:
|
||||
filtered = [x for x in cls.all_engines() if x['engine'] == engine and x['system_os'] == system_os and x['cpu'] == cpu]
|
||||
versions = sorted(filtered, key=lambda x: x['version'], reverse=True)
|
||||
return versions[0]
|
||||
filtered = [x for x in cls.all_versions_for_engine(engine) if x['system_os'] == system_os and x['cpu'] == cpu]
|
||||
return filtered[0]
|
||||
except IndexError:
|
||||
logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}")
|
||||
return None
|
||||
@@ -99,79 +104,173 @@ class EngineManager:
|
||||
@classmethod
|
||||
def version_is_available_to_download(cls, engine, version, system_os=None, cpu=None):
|
||||
try:
|
||||
return cls.downloader_classes[engine].version_is_available_to_download(version=version, system_os=system_os,
|
||||
cpu=cpu)
|
||||
downloader = cls.engine_with_name(engine).downloader()
|
||||
return downloader.version_is_available_to_download(version=version, system_os=system_os, cpu=cpu)
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def find_most_recent_version(cls, engine=None, system_os=None, cpu=None, lts_only=False):
|
||||
try:
|
||||
return cls.downloader_classes[engine].find_most_recent_version(system_os=system_os, cpu=cpu)
|
||||
downloader = cls.engine_with_name(engine).downloader()
|
||||
return downloader.find_most_recent_version(system_os=system_os, cpu=cpu)
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def download_engine(cls, engine, version, system_os=None, cpu=None):
|
||||
existing_download = cls.is_version_downloaded(engine, version, system_os, cpu)
|
||||
if existing_download:
|
||||
logger.info(f"Requested download of {engine} {version}, but local copy already exists")
|
||||
return existing_download
|
||||
def is_already_downloading(cls, engine, version, system_os=None, cpu=None):
|
||||
for task in cls.download_tasks:
|
||||
task_parts = task.name.split('-')
|
||||
task_engine, task_version, task_system_os, task_cpu = task_parts[:4]
|
||||
|
||||
# Check if the provided engine type is valid
|
||||
if engine not in cls.downloader_classes:
|
||||
logger.error("No valid engine found")
|
||||
if engine == task_engine and version == task_version:
|
||||
if system_os in (task_system_os, None) and cpu in (task_cpu, None):
|
||||
return task
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def download_engine(cls, engine, version, system_os=None, cpu=None, background=False):
|
||||
|
||||
engine_to_download = cls.engine_with_name(engine)
|
||||
existing_task = cls.is_already_downloading(engine, version, system_os, cpu)
|
||||
if existing_task:
|
||||
logger.debug(f"Already downloading {engine} {version}")
|
||||
if not background:
|
||||
existing_task.join() # If download task exists, wait until its done downloading
|
||||
return
|
||||
elif not engine_to_download.downloader():
|
||||
logger.warning("No valid downloader for this engine. Please update this software manually.")
|
||||
return
|
||||
elif not cls.engines_path:
|
||||
raise FileNotFoundError("Engines path must be set before requesting downloads")
|
||||
|
||||
# Get the appropriate downloader class based on the engine type
|
||||
cls.downloader_classes[engine].download_engine(version, download_location=cls.engines_path,
|
||||
system_os=system_os, cpu=cpu, timeout=300)
|
||||
thread = EngineDownloadWorker(engine, version, system_os, cpu)
|
||||
cls.download_tasks.append(thread)
|
||||
thread.start()
|
||||
|
||||
# Check that engine was properly downloaded
|
||||
found_engine = cls.is_version_downloaded(engine, version, system_os, cpu)
|
||||
if not found_engine:
|
||||
logger.error(f"Error downloading {engine}")
|
||||
return found_engine
|
||||
if background:
|
||||
return thread
|
||||
else:
|
||||
thread.join()
|
||||
found_engine = cls.is_version_downloaded(engine, version, system_os, cpu) # Check that engine downloaded
|
||||
if not found_engine:
|
||||
logger.error(f"Error downloading {engine}")
|
||||
return found_engine
|
||||
|
||||
|
||||
@classmethod
|
||||
def delete_engine_download(cls, engine, version, system_os=None, cpu=None):
|
||||
logger.info(f"Requested deletion of engine: {engine}-{version}")
|
||||
|
||||
found = cls.is_version_downloaded(engine, version, system_os, cpu)
|
||||
if found:
|
||||
dir_path = os.path.dirname(found['path'])
|
||||
shutil.rmtree(dir_path, ignore_errors=True)
|
||||
if found and found['type'] == 'managed': # don't delete system installs
|
||||
# find the root directory of the engine executable
|
||||
root_dir_name = '-'.join([engine, version, found['system_os'], found['cpu']])
|
||||
remove_path = os.path.join(found['path'].split(root_dir_name)[0], root_dir_name)
|
||||
# delete the file path
|
||||
logger.info(f"Deleting engine at path: {remove_path}")
|
||||
shutil.rmtree(remove_path, ignore_errors=False)
|
||||
logger.info(f"Engine {engine}-{version}-{found['system_os']}-{found['cpu']} successfully deleted")
|
||||
return True
|
||||
elif found: # these are managed by the system / user. Don't delete these.
|
||||
logger.error(f'Cannot delete requested {engine} {version}. Managed externally.')
|
||||
else:
|
||||
logger.error(f"Cannot find engine: {engine}-{version}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def update_all_engines(cls):
|
||||
def engine_update_task(engine, engine_downloader):
|
||||
logger.debug(f"Checking for updates to {engine}")
|
||||
latest_version = engine_downloader.find_most_recent_version()
|
||||
def engine_update_task(engine):
|
||||
logger.debug(f"Checking for updates to {engine.name()}")
|
||||
latest_version = engine.downloader().find_most_recent_version()
|
||||
if latest_version:
|
||||
logger.debug(f"Latest version of {engine} available: {latest_version.get('version')}")
|
||||
if not cls.is_version_downloaded(engine, latest_version.get('version')):
|
||||
logger.info(f"Downloading {engine} ({latest_version['version']})")
|
||||
cls.download_engine(engine=engine, version=latest_version['version'])
|
||||
logger.debug(f"Latest version of {engine.name()} available: {latest_version.get('version')}")
|
||||
if not cls.is_version_downloaded(engine.name(), latest_version.get('version')):
|
||||
logger.info(f"Downloading latest version of {engine.name()}...")
|
||||
cls.download_engine(engine=engine.name(), version=latest_version['version'], background=True)
|
||||
else:
|
||||
logger.warning(f"Unable to get latest version for {engine}")
|
||||
logger.warning(f"Unable to get check for updates for {engine.name()}")
|
||||
|
||||
logger.info(f"Checking for updates for render engines...")
|
||||
threads = []
|
||||
for engine, engine_downloader in cls.downloader_classes.items():
|
||||
thread = threading.Thread(target=engine_update_task, args=(engine, engine_downloader))
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
for engine in cls.supported_engines():
|
||||
if engine.downloader():
|
||||
thread = threading.Thread(target=engine_update_task, args=(engine,))
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
|
||||
@classmethod
|
||||
def create_worker(cls, renderer, input_path, output_path, engine_version=None, args=None, parent=None, name=None):
|
||||
|
||||
worker_class = cls.engine_with_name(renderer).worker_class()
|
||||
|
||||
# check to make sure we have versions installed
|
||||
all_versions = EngineManager.all_versions_for_engine(renderer)
|
||||
if not all_versions:
|
||||
raise FileNotFoundError(f"Cannot find any installed {renderer} engines")
|
||||
|
||||
# Find the path to the requested engine version or use default
|
||||
engine_path = None if engine_version else all_versions[0]['path']
|
||||
if engine_version:
|
||||
for ver in all_versions:
|
||||
if ver['version'] == engine_version:
|
||||
engine_path = ver['path']
|
||||
break
|
||||
|
||||
# Download the required engine if not found locally
|
||||
if not engine_path:
|
||||
download_result = EngineManager.download_engine(renderer, engine_version)
|
||||
if not download_result:
|
||||
raise FileNotFoundError(f"Cannot download requested version: {renderer} {engine_version}")
|
||||
engine_path = download_result['path']
|
||||
logger.info("Engine downloaded. Creating worker.")
|
||||
|
||||
if not engine_path:
|
||||
raise FileNotFoundError(f"Cannot find requested engine version {engine_version}")
|
||||
|
||||
return worker_class(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args,
|
||||
parent=parent, name=name)
|
||||
|
||||
@classmethod
|
||||
def engine_for_project_path(cls, path):
|
||||
name, extension = os.path.splitext(path)
|
||||
extension = extension.lower().strip('.')
|
||||
for engine in cls.supported_engines():
|
||||
if extension in engine.supported_extensions():
|
||||
return engine
|
||||
undefined_renderer_support = [x for x in cls.supported_engines() if not x.supported_extensions()]
|
||||
return undefined_renderer_support[0]
|
||||
|
||||
|
||||
class EngineDownloadWorker(threading.Thread):
|
||||
def __init__(self, engine, version, system_os=None, cpu=None):
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
self.version = version
|
||||
self.system_os = system_os
|
||||
self.cpu = cpu
|
||||
|
||||
def run(self):
|
||||
existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu)
|
||||
if existing_download:
|
||||
logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists")
|
||||
return existing_download
|
||||
|
||||
# Get the appropriate downloader class based on the engine type
|
||||
EngineManager.engine_with_name(self.engine).downloader().download_engine(
|
||||
self.version, download_location=EngineManager.engines_path, system_os=self.system_os, cpu=self.cpu,
|
||||
timeout=300)
|
||||
|
||||
# remove itself from the downloader list
|
||||
EngineManager.download_tasks.remove(self)
|
||||
|
||||
for thread in threads: # wait to finish
|
||||
thread.join()
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
# print(EngineManager.newest_engine_version('blender', 'macos', 'arm64'))
|
||||
EngineManager.delete_engine_download('blender', '3.2.1', 'macos', 'a')
|
||||
|
||||
# EngineManager.delete_engine_download('blender', '3.2.1', 'macos', 'a')
|
||||
EngineManager.engines_path = "/Users/brettwilliams/zordon-uploads/engines"
|
||||
# print(EngineManager.is_version_downloaded("ffmpeg", "6.0"))
|
||||
print(EngineManager.all_engines())
|
||||
|
||||
@@ -4,14 +4,16 @@ import re
|
||||
|
||||
import requests
|
||||
|
||||
from src.engines.core.downloader_core import download_and_extract_app
|
||||
from src.engines.core.base_downloader import EngineDownloader
|
||||
from src.engines.ffmpeg.ffmpeg_engine import FFMPEG
|
||||
from src.utilities.misc_helper import current_system_cpu, current_system_os
|
||||
|
||||
logger = logging.getLogger()
|
||||
supported_formats = ['.zip', '.tar.xz', '.dmg']
|
||||
|
||||
|
||||
class FFMPEGDownloader:
|
||||
class FFMPEGDownloader(EngineDownloader):
|
||||
|
||||
engine = FFMPEG
|
||||
|
||||
# macOS FFMPEG mirror maintained by Evermeet - https://evermeet.cx/ffmpeg/
|
||||
macos_url = "https://evermeet.cx/pub/ffmpeg/"
|
||||
@@ -88,17 +90,7 @@ class FFMPEGDownloader:
|
||||
return releases
|
||||
|
||||
@classmethod
|
||||
def find_most_recent_version(cls, system_os=None, cpu=None, lts_only=False):
|
||||
try:
|
||||
system_os = system_os or current_system_os()
|
||||
cpu = cpu or current_system_cpu()
|
||||
return cls.all_versions(system_os, cpu)[0]
|
||||
except TypeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def all_versions(cls, system_os=None, cpu=None):
|
||||
def __all_versions(cls, system_os=None, cpu=None):
|
||||
system_os = system_os or current_system_os()
|
||||
cpu = cpu or current_system_cpu()
|
||||
versions_per_os = {'linux': cls.__get_linux_versions, 'macos': cls.__get_macos_versions,
|
||||
@@ -115,13 +107,6 @@ class FFMPEGDownloader:
|
||||
'version': version})
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def version_is_available_to_download(cls, version, system_os=None, cpu=None):
|
||||
for ver in cls.all_versions(system_os, cpu):
|
||||
if ver['version'] == version:
|
||||
return ver
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def __get_remote_url_for_version(cls, version, system_os, cpu):
|
||||
# Platform specific naming cleanup
|
||||
@@ -141,13 +126,30 @@ class FFMPEGDownloader:
|
||||
logger.error("Unknown system os")
|
||||
return remote_url
|
||||
|
||||
@classmethod
|
||||
def find_most_recent_version(cls, system_os=None, cpu=None, lts_only=False):
|
||||
try:
|
||||
system_os = system_os or current_system_os()
|
||||
cpu = cpu or current_system_cpu()
|
||||
return cls.__all_versions(system_os, cpu)[0]
|
||||
except (IndexError, requests.exceptions.RequestException):
|
||||
logger.error(f"Cannot get most recent version of ffmpeg")
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def version_is_available_to_download(cls, version, system_os=None, cpu=None):
|
||||
for ver in cls.__all_versions(system_os, cpu):
|
||||
if ver['version'] == version:
|
||||
return ver
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120):
|
||||
system_os = system_os or current_system_os()
|
||||
cpu = cpu or current_system_cpu()
|
||||
|
||||
# Verify requested version is available
|
||||
found_version = [item for item in cls.all_versions(system_os, cpu) if item['version'] == version]
|
||||
found_version = [item for item in cls.__all_versions(system_os, cpu) if item['version'] == version]
|
||||
if not found_version:
|
||||
logger.error(f"Cannot find FFMPEG version {version} for {system_os} and {cpu}")
|
||||
return
|
||||
@@ -160,7 +162,7 @@ class FFMPEGDownloader:
|
||||
# Download and extract
|
||||
try:
|
||||
logger.info(f"Requesting download of ffmpeg-{version}-{system_os}-{cpu}")
|
||||
download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout)
|
||||
cls.download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout)
|
||||
|
||||
# naming cleanup to match existing naming convention
|
||||
output_path = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from src.engines.core.base_engine import *
|
||||
@@ -7,6 +8,30 @@ class FFMPEG(BaseRenderEngine):
|
||||
|
||||
binary_names = {'linux': 'ffmpeg', 'windows': 'ffmpeg.exe', 'macos': 'ffmpeg'}
|
||||
|
||||
@staticmethod
|
||||
def downloader():
|
||||
from src.engines.ffmpeg.ffmpeg_downloader import FFMPEGDownloader
|
||||
return FFMPEGDownloader
|
||||
|
||||
@staticmethod
|
||||
def worker_class():
|
||||
from src.engines.ffmpeg.ffmpeg_worker import FFMPEGRenderWorker
|
||||
return FFMPEGRenderWorker
|
||||
|
||||
def ui_options(self):
|
||||
from src.engines.ffmpeg.ffmpeg_ui import FFMPEGUI
|
||||
return FFMPEGUI.get_options(self)
|
||||
|
||||
@classmethod
|
||||
def supported_extensions(cls):
|
||||
help_text = (subprocess.check_output([cls().renderer_path(), '-h', 'full'], stderr=subprocess.STDOUT)
|
||||
.decode('utf-8'))
|
||||
found = re.findall('extensions that .* is allowed to access \(default "(.*)"', help_text)
|
||||
found_extensions = set()
|
||||
for match in found:
|
||||
found_extensions.update(match.split(','))
|
||||
return list(found_extensions)
|
||||
|
||||
def version(self):
|
||||
version = None
|
||||
try:
|
||||
@@ -19,6 +44,42 @@ class FFMPEG(BaseRenderEngine):
|
||||
logger.error("Failed to get FFMPEG version: {}".format(e))
|
||||
return version
|
||||
|
||||
def get_project_info(self, project_path, timeout=10):
|
||||
try:
|
||||
# Run ffprobe and parse the output as JSON
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'quiet', '-print_format', 'json',
|
||||
'-show_streams', '-select_streams', 'v', project_path
|
||||
]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
video_info = json.loads(result.stdout)
|
||||
|
||||
# Extract the necessary information
|
||||
video_stream = video_info['streams'][0]
|
||||
frame_rate = eval(video_stream['r_frame_rate'])
|
||||
duration = float(video_stream['duration'])
|
||||
width = video_stream['width']
|
||||
height = video_stream['height']
|
||||
|
||||
# Calculate total frames (end frame)
|
||||
total_frames = int(duration * frame_rate)
|
||||
end_frame = total_frames - 1
|
||||
|
||||
# The start frame is typically 0
|
||||
start_frame = 0
|
||||
|
||||
return {
|
||||
'frame_start': start_frame,
|
||||
'frame_end': end_frame,
|
||||
'fps': frame_rate,
|
||||
'resolution_x': width,
|
||||
'resolution_y': height
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return None
|
||||
|
||||
def get_encoders(self):
|
||||
raw_stdout = subprocess.check_output([self.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL,
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
@@ -34,7 +95,7 @@ class FFMPEG(BaseRenderEngine):
|
||||
try:
|
||||
formats_raw = subprocess.check_output([self.renderer_path(), '-formats'], stderr=subprocess.DEVNULL,
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
pattern = '(?P<type>[DE]{1,2})\s+(?P<id>\S{2,})\s+(?P<name>.*)\r'
|
||||
pattern = '(?P<type>[DE]{1,2})\s+(?P<id>\S{2,})\s+(?P<name>.*)'
|
||||
all_formats = [m.groupdict() for m in re.finditer(pattern, formats_raw)]
|
||||
return all_formats
|
||||
except Exception as e:
|
||||
@@ -54,7 +115,7 @@ class FFMPEG(BaseRenderEngine):
|
||||
return found_extensions
|
||||
|
||||
def get_output_formats(self):
|
||||
return [x for x in self.get_all_formats() if 'E' in x['type'].upper()]
|
||||
return [x['id'] for x in self.get_all_formats() if 'E' in x['type'].upper()]
|
||||
|
||||
def get_frame_count(self, path_to_file):
|
||||
raw_stdout = subprocess.check_output([self.renderer_path(), '-i', path_to_file, '-map', '0:v:0', '-c', 'copy',
|
||||
@@ -66,7 +127,8 @@ class FFMPEG(BaseRenderEngine):
|
||||
return frame_number
|
||||
|
||||
def get_arguments(self):
|
||||
help_text = subprocess.check_output([self.renderer_path(), '-h', 'long'], stderr=subprocess.STDOUT).decode('utf-8')
|
||||
help_text = (subprocess.check_output([self.renderer_path(), '-h', 'long'], stderr=subprocess.STDOUT)
|
||||
.decode('utf-8'))
|
||||
lines = help_text.splitlines()
|
||||
|
||||
options = {}
|
||||
|
||||
5
src/engines/ffmpeg/ffmpeg_ui.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class FFMPEGUI:
|
||||
@staticmethod
|
||||
def get_options(instance):
|
||||
options = []
|
||||
return options
|
||||
@@ -10,15 +10,10 @@ class FFMPEGRenderWorker(BaseRenderWorker):
|
||||
|
||||
engine = FFMPEG
|
||||
|
||||
def __init__(self, input_path, output_path, args=None, parent=None, name=None):
|
||||
super(FFMPEGRenderWorker, self).__init__(input_path=input_path, output_path=output_path, args=args,
|
||||
parent=parent, name=name)
|
||||
|
||||
stream_info = subprocess.check_output([self.renderer_path, "-i", # https://stackoverflow.com/a/61604105
|
||||
input_path, "-map", "0:v:0", "-c", "copy", "-f", "null", "-y",
|
||||
"/dev/null"], stderr=subprocess.STDOUT).decode('utf-8')
|
||||
found_frames = re.findall('frame=\s*(\d+)', stream_info)
|
||||
self.project_length = found_frames[-1] if found_frames else '-1'
|
||||
def __init__(self, input_path, output_path, engine_path, args=None, parent=None, name=None):
|
||||
super(FFMPEGRenderWorker, self).__init__(input_path=input_path, output_path=output_path,
|
||||
engine_path=engine_path, args=args, parent=parent, name=name)
|
||||
self.current_frame = -1
|
||||
|
||||
def generate_worker_subprocess(self):
|
||||
|
||||
|
||||
70
src/init.py
Normal file
@@ -0,0 +1,70 @@
|
||||
''' app/init.py '''
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from .render_queue import RenderQueue
|
||||
from .ui.main_window import MainWindow
|
||||
|
||||
from src.api.api_server import start_server
|
||||
from src.utilities.config import Config
|
||||
from src.utilities.misc_helper import system_safe_path
|
||||
|
||||
|
||||
def run() -> int:
|
||||
"""
|
||||
Initializes the application and runs it.
|
||||
|
||||
Returns:
|
||||
int: The exit status code.
|
||||
"""
|
||||
|
||||
# Load Config YAML
|
||||
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config')
|
||||
Config.load_config(system_safe_path(os.path.join(config_dir, 'config.yaml')))
|
||||
|
||||
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
|
||||
level=Config.server_log_level.upper())
|
||||
|
||||
app: QApplication = QApplication(sys.argv)
|
||||
|
||||
# Start server in background
|
||||
background_server = threading.Thread(target=start_server)
|
||||
background_server.daemon = True
|
||||
background_server.start()
|
||||
|
||||
# Setup logging for console ui
|
||||
buffer_handler = BufferingHandler()
|
||||
buffer_handler.setFormatter(logging.getLogger().handlers[0].formatter)
|
||||
logger = logging.getLogger()
|
||||
logger.addHandler(buffer_handler)
|
||||
|
||||
window: MainWindow = MainWindow()
|
||||
window.buffer_handler = buffer_handler
|
||||
window.show()
|
||||
|
||||
return_code = app.exec()
|
||||
RenderQueue.prepare_for_shutdown()
|
||||
return sys.exit(return_code)
|
||||
|
||||
|
||||
class BufferingHandler(logging.Handler, QObject):
|
||||
new_record = pyqtSignal(str)
|
||||
|
||||
def __init__(self, capacity=100):
|
||||
logging.Handler.__init__(self)
|
||||
QObject.__init__(self)
|
||||
self.buffer = deque(maxlen=capacity) # Define a buffer with a fixed capacity
|
||||
|
||||
def emit(self, record):
|
||||
msg = self.format(record)
|
||||
self.buffer.append(msg) # Add message to the buffer
|
||||
self.new_record.emit(msg) # Emit signal
|
||||
|
||||
def get_buffer(self):
|
||||
return list(self.buffer) # Return a copy of the buffer
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
@@ -18,10 +19,8 @@ class JobNotFoundError(Exception):
|
||||
|
||||
|
||||
class RenderQueue:
|
||||
engine = create_engine('sqlite:///database.db')
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
engine = None
|
||||
session = None
|
||||
job_queue = []
|
||||
maximum_renderer_instances = {'blender': 1, 'aerender': 1, 'ffmpeg': 4}
|
||||
last_saved_counts = {}
|
||||
@@ -29,10 +28,6 @@ class RenderQueue:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def start_queue(cls):
|
||||
cls.load_state()
|
||||
|
||||
@classmethod
|
||||
def add_to_render_queue(cls, render_job, force_start=False):
|
||||
logger.debug('Adding priority {} job to render queue: {}'.format(render_job.priority, render_job))
|
||||
@@ -65,12 +60,10 @@ class RenderQueue:
|
||||
|
||||
@classmethod
|
||||
def job_with_id(cls, job_id, none_ok=False):
|
||||
for job in cls.all_jobs():
|
||||
if job.id == job_id:
|
||||
return job
|
||||
if not none_ok:
|
||||
raise JobNotFoundError(f"Cannot find job with id: {job_id}")
|
||||
return None
|
||||
found_job = next((x for x in cls.all_jobs() if x.id == job_id), None)
|
||||
if not found_job and not none_ok:
|
||||
raise JobNotFoundError(job_id)
|
||||
return found_job
|
||||
|
||||
@classmethod
|
||||
def clear_history(cls):
|
||||
@@ -81,7 +74,11 @@ class RenderQueue:
|
||||
cls.save_state()
|
||||
|
||||
@classmethod
|
||||
def load_state(cls):
|
||||
def load_state(cls, database_directory):
|
||||
if not cls.engine:
|
||||
cls.engine = create_engine(f"sqlite:///{os.path.join(database_directory, 'database.db')}")
|
||||
Base.metadata.create_all(cls.engine)
|
||||
cls.session = sessionmaker(bind=cls.engine)()
|
||||
from src.engines.core.base_worker import BaseRenderWorker
|
||||
cls.job_queue = cls.session.query(BaseRenderWorker).all()
|
||||
|
||||
@@ -91,10 +88,11 @@ class RenderQueue:
|
||||
|
||||
@classmethod
|
||||
def prepare_for_shutdown(cls):
|
||||
logger.debug("Closing session")
|
||||
running_jobs = cls.jobs_with_status(RenderStatus.RUNNING) # cancel all running jobs
|
||||
for job in running_jobs:
|
||||
cls.cancel_job(job)
|
||||
[cls.cancel_job(job) for job in running_jobs]
|
||||
cls.save_state()
|
||||
cls.session.close()
|
||||
|
||||
@classmethod
|
||||
def is_available_for_job(cls, renderer, priority=2):
|
||||
|
||||
535
src/ui/add_job.py
Normal file
@@ -0,0 +1,535 @@
|
||||
import copy
|
||||
import os.path
|
||||
import pathlib
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import psutil
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, Qt, pyqtSlot
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox,
|
||||
QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem
|
||||
)
|
||||
from requests import Response
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.ui.engine_help_viewer import EngineHelpViewer
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
|
||||
|
||||
class NewRenderJobForm(QWidget):
|
||||
def __init__(self, project_path=None):
|
||||
super().__init__()
|
||||
self.project_path = project_path
|
||||
|
||||
# UI
|
||||
self.project_group = None
|
||||
self.load_file_group = None
|
||||
self.current_engine_options = None
|
||||
self.file_format_combo = None
|
||||
self.renderer_options_layout = None
|
||||
self.cameras_list = None
|
||||
self.cameras_group = None
|
||||
self.renderer_version_combo = None
|
||||
self.worker_thread = None
|
||||
self.msg_box = None
|
||||
self.engine_help_viewer = None
|
||||
self.raw_args = None
|
||||
self.submit_progress_label = None
|
||||
self.submit_progress = None
|
||||
self.renderer_type = None
|
||||
self.process_label = None
|
||||
self.process_progress_bar = None
|
||||
self.splitjobs_same_os = None
|
||||
self.enable_splitjobs = None
|
||||
self.server_input = None
|
||||
self.submit_button = None
|
||||
self.notes_input = None
|
||||
self.priority_input = None
|
||||
self.end_frame_input = None
|
||||
self.start_frame_input = None
|
||||
self.output_path_input = None
|
||||
self.scene_file_input = None
|
||||
self.scene_file_browse_button = None
|
||||
self.job_name_input = None
|
||||
|
||||
# Job / Server Data
|
||||
self.server_proxy = RenderServerProxy(socket.gethostname())
|
||||
self.renderer_info = None
|
||||
self.project_info = None
|
||||
|
||||
# Setup
|
||||
self.setWindowTitle("New Job")
|
||||
self.setup_ui()
|
||||
self.setup_project()
|
||||
|
||||
# get renderer info in bg thread
|
||||
t = threading.Thread(target=self.update_renderer_info)
|
||||
t.start()
|
||||
|
||||
self.show()
|
||||
|
||||
def setup_ui(self):
|
||||
# Main Layout
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# Loading File Group
|
||||
self.load_file_group = QGroupBox("Loading")
|
||||
load_file_layout = QVBoxLayout(self.load_file_group)
|
||||
# progress bar
|
||||
progress_layout = QHBoxLayout()
|
||||
self.process_progress_bar = QProgressBar()
|
||||
self.process_progress_bar.setMinimum(0)
|
||||
self.process_progress_bar.setMaximum(0)
|
||||
self.process_label = QLabel("Processing")
|
||||
progress_layout.addWidget(self.process_label)
|
||||
progress_layout.addWidget(self.process_progress_bar)
|
||||
load_file_layout.addLayout(progress_layout)
|
||||
main_layout.addWidget(self.load_file_group)
|
||||
|
||||
# Project Group
|
||||
self.project_group = QGroupBox("Project")
|
||||
server_layout = QVBoxLayout(self.project_group)
|
||||
# File Path
|
||||
scene_file_picker_layout = QHBoxLayout()
|
||||
self.scene_file_input = QLineEdit()
|
||||
self.scene_file_input.setText(self.project_path)
|
||||
self.scene_file_browse_button = QPushButton("Browse...")
|
||||
self.scene_file_browse_button.clicked.connect(self.browse_scene_file)
|
||||
scene_file_picker_layout.addWidget(QLabel("File:"))
|
||||
scene_file_picker_layout.addWidget(self.scene_file_input)
|
||||
scene_file_picker_layout.addWidget(self.scene_file_browse_button)
|
||||
server_layout.addLayout(scene_file_picker_layout)
|
||||
# Server List
|
||||
server_list_layout = QHBoxLayout()
|
||||
server_list_layout.setSpacing(0)
|
||||
self.server_input = QComboBox()
|
||||
server_list_layout.addWidget(QLabel("Hostname:"), 1)
|
||||
server_list_layout.addWidget(self.server_input, 3)
|
||||
server_layout.addLayout(server_list_layout)
|
||||
main_layout.addWidget(self.project_group)
|
||||
self.update_server_list()
|
||||
# Priority
|
||||
priority_layout = QHBoxLayout()
|
||||
priority_layout.addWidget(QLabel("Priority:"), 1)
|
||||
self.priority_input = QComboBox()
|
||||
self.priority_input.addItems(["High", "Medium", "Low"])
|
||||
self.priority_input.setCurrentIndex(1)
|
||||
priority_layout.addWidget(self.priority_input, 3)
|
||||
server_layout.addLayout(priority_layout)
|
||||
# Splitjobs
|
||||
self.enable_splitjobs = QCheckBox("Automatically split render across multiple servers")
|
||||
self.enable_splitjobs.setEnabled(True)
|
||||
server_layout.addWidget(self.enable_splitjobs)
|
||||
self.splitjobs_same_os = QCheckBox("Only render on same OS")
|
||||
self.splitjobs_same_os.setEnabled(True)
|
||||
server_layout.addWidget(self.splitjobs_same_os)
|
||||
|
||||
# Output Settings Group
|
||||
self.output_settings_group = QGroupBox("Output Settings")
|
||||
output_settings_layout = QVBoxLayout(self.output_settings_group)
|
||||
# output path
|
||||
output_path_layout = QHBoxLayout()
|
||||
output_path_layout.addWidget(QLabel("Render name:"))
|
||||
self.output_path_input = QLineEdit()
|
||||
output_path_layout.addWidget(self.output_path_input)
|
||||
output_settings_layout.addLayout(output_path_layout)
|
||||
# file format
|
||||
file_format_layout = QHBoxLayout()
|
||||
file_format_layout.addWidget(QLabel("Format:"))
|
||||
self.file_format_combo = QComboBox()
|
||||
file_format_layout.addWidget(self.file_format_combo)
|
||||
output_settings_layout.addLayout(file_format_layout)
|
||||
# frame range
|
||||
frame_range_layout = QHBoxLayout(self.output_settings_group)
|
||||
self.start_frame_input = QSpinBox()
|
||||
self.start_frame_input.setRange(1, 99999)
|
||||
self.end_frame_input = QSpinBox()
|
||||
self.end_frame_input.setRange(1, 99999)
|
||||
frame_range_layout.addWidget(QLabel("Frames:"))
|
||||
frame_range_layout.addWidget(self.start_frame_input)
|
||||
frame_range_layout.addWidget(QLabel("to"))
|
||||
frame_range_layout.addWidget(self.end_frame_input)
|
||||
output_settings_layout.addLayout(frame_range_layout)
|
||||
# resolution
|
||||
resolution_layout = QHBoxLayout(self.output_settings_group)
|
||||
self.resolution_x_input = QSpinBox()
|
||||
self.resolution_x_input.setRange(1, 9999) # Assuming max resolution width 9999
|
||||
self.resolution_x_input.setValue(1920)
|
||||
self.resolution_y_input = QSpinBox()
|
||||
self.resolution_y_input.setRange(1, 9999) # Assuming max resolution height 9999
|
||||
self.resolution_y_input.setValue(1080)
|
||||
self.frame_rate_input = QDoubleSpinBox()
|
||||
self.frame_rate_input.setRange(1, 9999) # Assuming max resolution width 9999
|
||||
self.frame_rate_input.setDecimals(3)
|
||||
self.frame_rate_input.setValue(23.976)
|
||||
resolution_layout.addWidget(QLabel("Resolution:"))
|
||||
resolution_layout.addWidget(self.resolution_x_input)
|
||||
resolution_layout.addWidget(QLabel("x"))
|
||||
resolution_layout.addWidget(self.resolution_y_input)
|
||||
resolution_layout.addWidget(QLabel("@"))
|
||||
resolution_layout.addWidget(self.frame_rate_input)
|
||||
resolution_layout.addWidget(QLabel("fps"))
|
||||
output_settings_layout.addLayout(resolution_layout)
|
||||
# add group to layout
|
||||
main_layout.addWidget(self.output_settings_group)
|
||||
|
||||
# Renderer Group
|
||||
self.renderer_group = QGroupBox("Renderer Settings")
|
||||
renderer_group_layout = QVBoxLayout(self.renderer_group)
|
||||
renderer_layout = QHBoxLayout()
|
||||
renderer_layout.addWidget(QLabel("Renderer:"))
|
||||
self.renderer_type = QComboBox()
|
||||
self.renderer_type.currentIndexChanged.connect(self.renderer_changed)
|
||||
renderer_layout.addWidget(self.renderer_type)
|
||||
# Version
|
||||
renderer_layout.addWidget(QLabel("Version:"))
|
||||
self.renderer_version_combo = QComboBox()
|
||||
renderer_layout.addWidget(self.renderer_version_combo)
|
||||
renderer_group_layout.addLayout(renderer_layout)
|
||||
# dynamic options
|
||||
self.renderer_options_layout = QVBoxLayout()
|
||||
renderer_group_layout.addLayout(self.renderer_options_layout)
|
||||
# Raw Args
|
||||
raw_args_layout = QHBoxLayout(self.renderer_group)
|
||||
raw_args_layout.addWidget(QLabel("Raw Args:"))
|
||||
self.raw_args = QLineEdit()
|
||||
raw_args_layout.addWidget(self.raw_args)
|
||||
args_help_button = QPushButton("?")
|
||||
args_help_button.clicked.connect(self.args_help_button_clicked)
|
||||
raw_args_layout.addWidget(args_help_button)
|
||||
renderer_group_layout.addLayout(raw_args_layout)
|
||||
main_layout.addWidget(self.renderer_group)
|
||||
|
||||
# Cameras Group
|
||||
self.cameras_group = QGroupBox("Cameras")
|
||||
cameras_layout = QVBoxLayout(self.cameras_group)
|
||||
self.cameras_list = QListWidget()
|
||||
self.cameras_group.setHidden(True)
|
||||
cameras_layout.addWidget(self.cameras_list)
|
||||
main_layout.addWidget(self.cameras_group)
|
||||
|
||||
# Notes Group
|
||||
self.notes_group = QGroupBox("Additional Notes")
|
||||
notes_layout = QVBoxLayout(self.notes_group)
|
||||
self.notes_input = QPlainTextEdit()
|
||||
notes_layout.addWidget(self.notes_input)
|
||||
main_layout.addWidget(self.notes_group)
|
||||
|
||||
# Submit Button
|
||||
self.submit_button = QPushButton("Submit Job")
|
||||
self.submit_button.clicked.connect(self.submit_job)
|
||||
main_layout.addWidget(self.submit_button)
|
||||
|
||||
self.submit_progress = QProgressBar()
|
||||
self.submit_progress.setMinimum(0)
|
||||
self.submit_progress.setMaximum(0)
|
||||
self.submit_progress.setHidden(True)
|
||||
main_layout.addWidget(self.submit_progress)
|
||||
|
||||
self.submit_progress_label = QLabel("Submitting...")
|
||||
self.submit_progress_label.setHidden(True)
|
||||
main_layout.addWidget(self.submit_progress_label)
|
||||
|
||||
self.toggle_renderer_enablement(False)
|
||||
|
||||
def update_renderer_info(self):
|
||||
# get the renderer info and add them all to the ui
|
||||
self.renderer_info = self.server_proxy.get_renderer_info()
|
||||
self.renderer_type.addItems(self.renderer_info.keys())
|
||||
# select the best renderer for the file type
|
||||
engine = EngineManager.engine_for_project_path(self.project_path)
|
||||
self.renderer_type.setCurrentText(engine.name().lower())
|
||||
# refresh ui
|
||||
self.renderer_changed()
|
||||
|
||||
def renderer_changed(self):
|
||||
# load the version numbers
|
||||
current_renderer = self.renderer_type.currentText().lower() or self.renderer_type.itemText(0)
|
||||
self.renderer_version_combo.clear()
|
||||
self.file_format_combo.clear()
|
||||
if current_renderer:
|
||||
renderer_vers = [version_info['version'] for version_info in self.renderer_info[current_renderer]['versions']]
|
||||
self.renderer_version_combo.addItems(renderer_vers)
|
||||
self.file_format_combo.addItems(self.renderer_info[current_renderer]['supported_export_formats'])
|
||||
|
||||
def update_server_list(self):
|
||||
clients = ZeroconfServer.found_hostnames()
|
||||
self.server_input.clear()
|
||||
self.server_input.addItems(clients)
|
||||
|
||||
def browse_scene_file(self):
|
||||
file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
|
||||
if file_name:
|
||||
self.scene_file_input.setText(file_name)
|
||||
self.setup_project()
|
||||
|
||||
def setup_project(self):
|
||||
# UI stuff on main thread
|
||||
self.process_progress_bar.setHidden(False)
|
||||
self.process_label.setHidden(False)
|
||||
self.toggle_renderer_enablement(False)
|
||||
|
||||
output_name, _ = os.path.splitext(os.path.basename(self.scene_file_input.text()))
|
||||
output_name = output_name.replace(' ', '_')
|
||||
self.output_path_input.setText(output_name)
|
||||
file_name = self.scene_file_input.text()
|
||||
|
||||
# setup bg worker
|
||||
self.worker_thread = GetProjectInfoWorker(window=self, project_path=file_name)
|
||||
self.worker_thread.message_signal.connect(self.post_get_project_info_update)
|
||||
self.worker_thread.start()
|
||||
|
||||
def browse_output_path(self):
|
||||
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
|
||||
if directory:
|
||||
self.output_path_input.setText(directory)
|
||||
|
||||
def args_help_button_clicked(self):
|
||||
url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/renderer/'
|
||||
f'{self.renderer_type.currentText()}/help')
|
||||
self.engine_help_viewer = EngineHelpViewer(url)
|
||||
self.engine_help_viewer.show()
|
||||
|
||||
# -------- Update --------
|
||||
|
||||
def post_get_project_info_update(self):
|
||||
"""Called by the GetProjectInfoWorker - Do not call directly."""
|
||||
try:
|
||||
# Set the best renderer we can find
|
||||
input_path = self.scene_file_input.text()
|
||||
engine = EngineManager.engine_for_project_path(input_path)
|
||||
|
||||
engine_index = self.renderer_type.findText(engine.name().lower())
|
||||
if engine_index >= 0:
|
||||
self.renderer_type.setCurrentIndex(engine_index)
|
||||
else:
|
||||
self.renderer_type.setCurrentIndex(0) #todo: find out why we don't have renderer info yet
|
||||
# not ideal but if we don't have the renderer info we have to pick something
|
||||
|
||||
self.output_path_input.setText(os.path.basename(input_path))
|
||||
|
||||
# cleanup progress UI
|
||||
self.load_file_group.setHidden(True)
|
||||
self.toggle_renderer_enablement(True)
|
||||
|
||||
# Load scene data
|
||||
self.start_frame_input.setValue(self.project_info.get('frame_start'))
|
||||
self.end_frame_input.setValue(self.project_info.get('frame_end'))
|
||||
self.resolution_x_input.setValue(self.project_info.get('resolution_x'))
|
||||
self.resolution_y_input.setValue(self.project_info.get('resolution_y'))
|
||||
self.frame_rate_input.setValue(self.project_info.get('fps'))
|
||||
|
||||
# Cameras
|
||||
self.cameras_list.clear()
|
||||
if self.project_info.get('cameras'):
|
||||
self.cameras_group.setHidden(False)
|
||||
found_active = False
|
||||
for camera in self.project_info['cameras']:
|
||||
# create the list items and make them checkable
|
||||
item = QListWidgetItem(f"{camera['name']} - {camera['lens']}mm")
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
is_checked = camera['is_active'] or len(self.project_info['cameras']) == 1
|
||||
found_active = found_active or is_checked
|
||||
item.setCheckState(Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked)
|
||||
self.cameras_list.addItem(item)
|
||||
if not found_active:
|
||||
self.cameras_list.item(0).setCheckState(Qt.CheckState.Checked)
|
||||
else:
|
||||
self.cameras_group.setHidden(True)
|
||||
|
||||
# Dynamic Engine Options
|
||||
clear_layout(self.renderer_options_layout) # clear old options
|
||||
# dynamically populate option list
|
||||
self.current_engine_options = engine().ui_options()
|
||||
for option in self.current_engine_options:
|
||||
h_layout = QHBoxLayout()
|
||||
label = QLabel(option['name'].capitalize() + ':')
|
||||
h_layout.addWidget(label)
|
||||
if option.get('options'):
|
||||
combo_box = QComboBox()
|
||||
for opt in option['options']:
|
||||
combo_box.addItem(opt)
|
||||
h_layout.addWidget(combo_box)
|
||||
else:
|
||||
text_box = QLineEdit()
|
||||
h_layout.addWidget(text_box)
|
||||
self.renderer_options_layout.addLayout(h_layout)
|
||||
except AttributeError as e:
|
||||
pass
|
||||
|
||||
def toggle_renderer_enablement(self, enabled=False):
|
||||
"""Toggle on/off all the render settings"""
|
||||
self.project_group.setHidden(not enabled)
|
||||
self.output_settings_group.setHidden(not enabled)
|
||||
self.renderer_group.setHidden(not enabled)
|
||||
self.notes_group.setHidden(not enabled)
|
||||
if not enabled:
|
||||
self.cameras_group.setHidden(True)
|
||||
self.submit_button.setEnabled(enabled)
|
||||
|
||||
def after_job_submission(self, result):
|
||||
|
||||
# UI cleanup
|
||||
self.submit_progress.setMaximum(0)
|
||||
self.submit_button.setHidden(False)
|
||||
self.submit_progress.setHidden(True)
|
||||
self.submit_progress_label.setHidden(True)
|
||||
self.process_progress_bar.setHidden(True)
|
||||
self.process_label.setHidden(True)
|
||||
self.toggle_renderer_enablement(True)
|
||||
|
||||
self.msg_box = QMessageBox()
|
||||
if result.ok:
|
||||
self.msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
self.msg_box.setIcon(QMessageBox.Icon.Information)
|
||||
self.msg_box.setText("Job successfully submitted to server. Submit another?")
|
||||
self.msg_box.setWindowTitle("Success")
|
||||
x = self.msg_box.exec()
|
||||
if x == QMessageBox.StandardButton.No:
|
||||
self.close()
|
||||
else:
|
||||
self.msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
|
||||
self.msg_box.setIcon(QMessageBox.Icon.Critical)
|
||||
self.msg_box.setText(result.text or "Unknown error")
|
||||
self.msg_box.setWindowTitle("Error")
|
||||
self.msg_box.exec()
|
||||
|
||||
# -------- Submit Job Calls --------
|
||||
|
||||
def submit_job(self):
|
||||
|
||||
# Pre-worker UI
|
||||
self.submit_progress.setHidden(False)
|
||||
self.submit_progress_label.setHidden(False)
|
||||
self.submit_button.setHidden(True)
|
||||
self.submit_progress.setMaximum(0)
|
||||
|
||||
# submit job in background thread
|
||||
self.worker_thread = SubmitWorker(window=self)
|
||||
self.worker_thread.update_ui_signal.connect(self.update_submit_progress)
|
||||
self.worker_thread.message_signal.connect(self.after_job_submission)
|
||||
self.worker_thread.start()
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def update_submit_progress(self, hostname, percent):
|
||||
# Update the UI here. This slot will be executed in the main thread
|
||||
self.submit_progress_label.setText(f"Transferring to {hostname} - {percent}%")
|
||||
self.submit_progress.setMaximum(100)
|
||||
self.submit_progress.setValue(int(percent))
|
||||
|
||||
|
||||
class SubmitWorker(QThread):
|
||||
"""Worker class called to submit all the jobs to the server and update the UI accordingly"""
|
||||
|
||||
message_signal = pyqtSignal(Response)
|
||||
update_ui_signal = pyqtSignal(str, str)
|
||||
|
||||
def __init__(self, window):
|
||||
super().__init__()
|
||||
self.window = window
|
||||
|
||||
def run(self):
|
||||
def create_callback(encoder):
|
||||
encoder_len = encoder.len
|
||||
|
||||
def callback(monitor):
|
||||
percent = f"{monitor.bytes_read / encoder_len * 100:.0f}"
|
||||
self.update_ui_signal.emit(hostname, percent)
|
||||
return callback
|
||||
|
||||
hostname = self.window.server_input.currentText()
|
||||
job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(),
|
||||
'renderer': self.window.renderer_type.currentText().lower(),
|
||||
'renderer_version': self.window.renderer_version_combo.currentText(),
|
||||
'args': {'raw': self.window.raw_args.text()},
|
||||
'output_path': self.window.output_path_input.text(),
|
||||
'start_frame': self.window.start_frame_input.value(),
|
||||
'end_frame': self.window.end_frame_input.value(),
|
||||
'priority': self.window.priority_input.currentIndex() + 1,
|
||||
'notes': self.window.notes_input.toPlainText(),
|
||||
'enable_split_jobs': self.window.enable_splitjobs.isChecked(),
|
||||
'split_jobs_same_os': self.window.splitjobs_same_os.isChecked()}
|
||||
|
||||
# get the dynamic args
|
||||
for i in range(self.window.renderer_options_layout.count()):
|
||||
item = self.window.renderer_options_layout.itemAt(i)
|
||||
layout = item.layout() # get the layout
|
||||
for x in range(layout.count()):
|
||||
z = layout.itemAt(x)
|
||||
widget = z.widget()
|
||||
if isinstance(widget, QComboBox):
|
||||
job_json['args'][self.window.current_engine_options[i]['name']] = widget.currentText()
|
||||
elif isinstance(widget, QLineEdit):
|
||||
job_json['args'][self.window.current_engine_options[i]['name']] = widget.text()
|
||||
|
||||
# determine if any cameras are checked
|
||||
selected_cameras = []
|
||||
if self.window.cameras_list.count() and not self.window.cameras_group.isHidden():
|
||||
for index in range(self.window.cameras_list.count()):
|
||||
item = self.window.cameras_list.item(index)
|
||||
if item.checkState() == Qt.CheckState.Checked:
|
||||
selected_cameras.append(item.text().rsplit('-', 1)[0].strip()) # cleanup to just camera name
|
||||
|
||||
# process cameras into nested format
|
||||
input_path = self.window.scene_file_input.text()
|
||||
if selected_cameras:
|
||||
job_list = []
|
||||
for cam in selected_cameras:
|
||||
job_copy = copy.deepcopy(job_json)
|
||||
job_copy['args']['camera'] = cam
|
||||
job_copy['name'] = pathlib.Path(input_path).stem.replace(' ', '_') + "-" + cam.replace(' ', '')
|
||||
job_list.append(job_copy)
|
||||
else:
|
||||
job_list = [job_json]
|
||||
|
||||
# presubmission tasks
|
||||
engine = EngineManager.engine_with_name(self.window.renderer_type.currentText().lower())
|
||||
input_path = engine().perform_presubmission_tasks(input_path)
|
||||
# submit
|
||||
result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list,
|
||||
callback=create_callback)
|
||||
self.message_signal.emit(result)
|
||||
|
||||
|
||||
class GetProjectInfoWorker(QThread):
|
||||
"""Worker class called to retrieve information about a project file on a background thread and update the UI"""
|
||||
|
||||
message_signal = pyqtSignal()
|
||||
|
||||
def __init__(self, window, project_path):
|
||||
super().__init__()
|
||||
self.window = window
|
||||
self.project_path = project_path
|
||||
|
||||
def run(self):
|
||||
engine = EngineManager.engine_for_project_path(self.project_path)
|
||||
self.window.project_info = engine().get_project_info(self.project_path)
|
||||
self.message_signal.emit()
|
||||
|
||||
|
||||
def clear_layout(layout):
|
||||
if layout is not None:
|
||||
# Go through the layout's items in reverse order
|
||||
for i in reversed(range(layout.count())):
|
||||
# Take the item at the current position
|
||||
item = layout.takeAt(i)
|
||||
|
||||
# Check if the item is a widget
|
||||
if item.widget():
|
||||
# Remove the widget and delete it
|
||||
widget_to_remove = item.widget()
|
||||
widget_to_remove.setParent(None)
|
||||
widget_to_remove.deleteLater()
|
||||
elif item.layout():
|
||||
# If the item is a sub-layout, clear its contents recursively
|
||||
clear_layout(item.layout())
|
||||
# Then delete the layout
|
||||
item.layout().deleteLater()
|
||||
|
||||
# Run the application
|
||||
if __name__ == '__main__':
|
||||
app = QApplication([])
|
||||
window = NewRenderJobForm()
|
||||
app.exec()
|
||||
60
src/ui/console.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget, QPlainTextEdit
|
||||
from PyQt6.QtCore import pyqtSignal, QObject
|
||||
|
||||
|
||||
# Create a custom logging handler that emits a signal
|
||||
class QSignalHandler(logging.Handler, QObject):
|
||||
new_record = pyqtSignal(str)
|
||||
|
||||
def __init__(self):
|
||||
logging.Handler.__init__(self)
|
||||
QObject.__init__(self)
|
||||
|
||||
def emit(self, record):
|
||||
msg = self.format(record)
|
||||
self.new_record.emit(msg) # Emit signal
|
||||
|
||||
|
||||
class ConsoleWindow(QMainWindow):
|
||||
def __init__(self, buffer_handler):
|
||||
super().__init__()
|
||||
self.buffer_handler = buffer_handler
|
||||
self.log_handler = None
|
||||
self.init_ui()
|
||||
self.init_logging()
|
||||
|
||||
def init_ui(self):
|
||||
self.setGeometry(100, 100, 600, 800)
|
||||
self.setWindowTitle("Log Output")
|
||||
|
||||
self.text_edit = QPlainTextEdit(self)
|
||||
self.text_edit.setReadOnly(True)
|
||||
self.text_edit.setFont(QFont("Courier", 10))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.text_edit)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
central_widget = QWidget()
|
||||
central_widget.setLayout(layout)
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
def init_logging(self):
|
||||
|
||||
self.buffer_handler.new_record.connect(self.append_log_record)
|
||||
# Display all messages that were buffered before the window was opened
|
||||
for record in self.buffer_handler.get_buffer():
|
||||
self.text_edit.appendPlainText(record)
|
||||
|
||||
self.log_handler = QSignalHandler()
|
||||
# self.log_handler.new_record.connect(self.append_log_record)
|
||||
self.log_handler.setFormatter(self.buffer_handler.formatter)
|
||||
logging.getLogger().addHandler(self.log_handler)
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
def append_log_record(self, record):
|
||||
self.text_edit.appendPlainText(record)
|
||||
175
src/ui/engine_browser.py
Normal file
@@ -0,0 +1,175 @@
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QPushButton, QTableWidget, QTableWidgetItem, QHBoxLayout, QAbstractItemView,
|
||||
QHeaderView, QProgressBar, QLabel, QMessageBox
|
||||
)
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.utilities.misc_helper import is_localhost
|
||||
|
||||
|
||||
class EngineBrowserWindow(QMainWindow):
|
||||
def __init__(self, hostname=None):
|
||||
super().__init__()
|
||||
self.delete_button = None
|
||||
self.install_button = None
|
||||
self.progress_label = None
|
||||
self.progress_bar = None
|
||||
self.table_widget = None
|
||||
self.launch_button = None
|
||||
self.hostname = hostname or socket.gethostname()
|
||||
self.setWindowTitle(f'Engine Browser ({self.hostname})')
|
||||
self.setGeometry(100, 100, 500, 300)
|
||||
self.engine_data = []
|
||||
self.initUI()
|
||||
self.init_timer()
|
||||
|
||||
def initUI(self):
|
||||
# Central widget
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
# Layout
|
||||
layout = QVBoxLayout(central_widget)
|
||||
|
||||
# Table
|
||||
self.table_widget = QTableWidget(0, 4)
|
||||
self.table_widget.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table_widget.verticalHeader().setVisible(False)
|
||||
self.table_widget.itemSelectionChanged.connect(self.engine_picked)
|
||||
self.table_widget.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
layout.addWidget(self.table_widget)
|
||||
self.update_table()
|
||||
|
||||
# Progress Bar Layout
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setMinimum(0)
|
||||
self.progress_bar.setMaximum(0)
|
||||
# self.progress_bar.setHidden(True)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Progress Bar Label
|
||||
self.progress_label = QLabel('Downloading blah blah')
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
# Buttons Layout
|
||||
buttons_layout = QHBoxLayout()
|
||||
|
||||
# Install Button
|
||||
self.install_button = QPushButton('Install')
|
||||
self.install_button.clicked.connect(self.install_button_click) # Connect to slot
|
||||
# buttons_layout.addWidget(self.install_button)
|
||||
|
||||
# Launch Button
|
||||
self.launch_button = QPushButton('Launch')
|
||||
self.launch_button.clicked.connect(self.launch_button_click) # Connect to slot
|
||||
self.launch_button.setEnabled(False)
|
||||
buttons_layout.addWidget(self.launch_button)
|
||||
|
||||
#Delete Button
|
||||
self.delete_button = QPushButton('Delete')
|
||||
self.delete_button.clicked.connect(self.delete_button_click) # Connect to slot
|
||||
self.delete_button.setEnabled(False)
|
||||
buttons_layout.addWidget(self.delete_button)
|
||||
|
||||
# Add Buttons Layout to the Main Layout
|
||||
layout.addLayout(buttons_layout)
|
||||
|
||||
self.update_download_status()
|
||||
|
||||
def init_timer(self):
|
||||
# Set up the timer
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self.update_download_status)
|
||||
self.timer.start(1000)
|
||||
|
||||
def update_table(self):
|
||||
|
||||
def update_table_worker():
|
||||
raw_server_data = RenderServerProxy(self.hostname).get_renderer_info()
|
||||
if not raw_server_data:
|
||||
return
|
||||
|
||||
table_data = [] # convert the data into a flat list
|
||||
for engine_name, engine_data in raw_server_data.items():
|
||||
table_data.extend(engine_data['versions'])
|
||||
self.engine_data = table_data
|
||||
|
||||
self.table_widget.setRowCount(len(self.engine_data))
|
||||
self.table_widget.setColumnCount(4)
|
||||
|
||||
for row, engine in enumerate(self.engine_data):
|
||||
self.table_widget.setItem(row, 0, QTableWidgetItem(engine['engine']))
|
||||
self.table_widget.setItem(row, 1, QTableWidgetItem(engine['version']))
|
||||
self.table_widget.setItem(row, 2, QTableWidgetItem(engine['type']))
|
||||
self.table_widget.setItem(row, 3, QTableWidgetItem(engine['path']))
|
||||
|
||||
self.table_widget.selectRow(0)
|
||||
|
||||
self.table_widget.clear()
|
||||
self.table_widget.setHorizontalHeaderLabels(['Engine', 'Version', 'Type', 'Path'])
|
||||
self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
self.table_widget.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
||||
self.table_widget.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
||||
update_thread = threading.Thread(target=update_table_worker,)
|
||||
update_thread.start()
|
||||
|
||||
def engine_picked(self):
|
||||
engine_info = self.engine_data[self.table_widget.currentRow()]
|
||||
self.delete_button.setEnabled(engine_info['type'] == 'managed')
|
||||
self.launch_button.setEnabled(is_localhost(self.hostname))
|
||||
|
||||
def update_download_status(self):
|
||||
running_tasks = [x for x in EngineManager.download_tasks if x.is_alive()]
|
||||
hide_progress = not bool(running_tasks)
|
||||
self.progress_bar.setHidden(hide_progress)
|
||||
self.progress_label.setHidden(hide_progress)
|
||||
# Update the status labels
|
||||
if len(EngineManager.download_tasks) == 0:
|
||||
new_status = ""
|
||||
elif len(EngineManager.download_tasks) == 1:
|
||||
task = EngineManager.download_tasks[0]
|
||||
new_status = f"Downloading {task.engine.capitalize()} {task.version}..."
|
||||
else:
|
||||
new_status = f"Downloading {len(EngineManager.download_tasks)} engines..."
|
||||
self.progress_label.setText(new_status)
|
||||
|
||||
def launch_button_click(self):
|
||||
engine_info = self.engine_data[self.table_widget.currentRow()]
|
||||
path = engine_info['path']
|
||||
if sys.platform.startswith('darwin'):
|
||||
subprocess.run(['open', path])
|
||||
elif sys.platform.startswith('win32'):
|
||||
os.startfile(path)
|
||||
elif sys.platform.startswith('linux'):
|
||||
subprocess.run(['xdg-open', path])
|
||||
else:
|
||||
raise OSError("Unsupported operating system")
|
||||
|
||||
def install_button_click(self):
|
||||
self.update_download_status()
|
||||
|
||||
def delete_button_click(self):
|
||||
engine_info = self.engine_data[self.table_widget.currentRow()]
|
||||
reply = QMessageBox.question(self, f"Delete {engine_info['engine']} {engine_info['version']}?",
|
||||
f"Do you want to delete {engine_info['engine']} {engine_info['version']}?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
|
||||
if reply is not QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
result = RenderServerProxy(self.hostname).delete_engine(engine_info['engine'], engine_info['version'])
|
||||
if result.ok:
|
||||
self.update_table()
|
||||
else:
|
||||
QMessageBox.warning(self, f"Delete {engine_info['engine']} {engine_info['version']} Failed",
|
||||
f"Failed to delete {engine_info['engine']} {engine_info['version']}.",
|
||||
QMessageBox.StandardButton.Ok)
|
||||
30
src/ui/engine_help_viewer.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import requests
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget, QPlainTextEdit
|
||||
|
||||
|
||||
class EngineHelpViewer(QMainWindow):
|
||||
def __init__(self, log_path):
|
||||
super().__init__()
|
||||
|
||||
self.help_path = log_path
|
||||
self.setGeometry(100, 100, 600, 800)
|
||||
self.setWindowTitle("Help Output")
|
||||
|
||||
self.text_edit = QPlainTextEdit(self)
|
||||
self.text_edit.setReadOnly(True)
|
||||
self.text_edit.setFont(QFont("Courier", 10))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.text_edit)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
central_widget = QWidget()
|
||||
central_widget.setLayout(layout)
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
self.fetch_help()
|
||||
|
||||
def fetch_help(self):
|
||||
result = requests.get(self.help_path)
|
||||
self.text_edit.setPlainText(result.text)
|
||||
30
src/ui/log_viewer.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import requests
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget, QPlainTextEdit
|
||||
|
||||
|
||||
class LogViewer(QMainWindow):
|
||||
def __init__(self, log_path):
|
||||
super().__init__()
|
||||
|
||||
self.log_path = log_path
|
||||
self.setGeometry(100, 100, 600, 800)
|
||||
self.setWindowTitle("Log Output")
|
||||
|
||||
self.text_edit = QPlainTextEdit(self)
|
||||
self.text_edit.setReadOnly(True)
|
||||
self.text_edit.setFont(QFont("Courier", 10))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.text_edit)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
central_widget = QWidget()
|
||||
central_widget.setLayout(layout)
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
self.fetch_logs()
|
||||
|
||||
def fetch_logs(self):
|
||||
result = requests.get(self.log_path)
|
||||
self.text_edit.setPlainText(result.text)
|
||||
564
src/ui/main_window.py
Normal file
@@ -0,0 +1,564 @@
|
||||
''' app/ui/main_window.py '''
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
from PyQt6.QtCore import Qt, QByteArray, QBuffer, QIODevice, QThread
|
||||
from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon
|
||||
from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QListWidget, QTableWidget, QAbstractItemView, \
|
||||
QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem, \
|
||||
QFileDialog
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.render_queue import RenderQueue
|
||||
from src.utilities.misc_helper import get_time_elapsed, resources_dir, is_localhost
|
||||
from src.utilities.status_utils import RenderStatus
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
from .add_job import NewRenderJobForm
|
||||
from .console import ConsoleWindow
|
||||
from .engine_browser import EngineBrowserWindow
|
||||
from .log_viewer import LogViewer
|
||||
from .widgets.menubar import MenuBar
|
||||
from .widgets.proportional_image_label import ProportionalImageLabel
|
||||
from .widgets.statusbar import StatusBar
|
||||
from .widgets.toolbar import ToolBar
|
||||
from src.api.serverproxy_manager import ServerProxyManager
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""
|
||||
MainWindow
|
||||
|
||||
Args:
|
||||
QMainWindow (QMainWindow): Inheritance
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initialize the Main-Window.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# Load the queue
|
||||
self.engine_browser_window = None
|
||||
self.server_info_group = None
|
||||
self.current_hostname = None
|
||||
self.subprocess_runner = None
|
||||
|
||||
# To pass to console
|
||||
self.buffer_handler = None
|
||||
|
||||
# Window-Settings
|
||||
self.setWindowTitle("Zordon")
|
||||
self.setGeometry(100, 100, 900, 800)
|
||||
central_widget = QWidget(self)
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
main_layout = QHBoxLayout(central_widget)
|
||||
|
||||
# Create a QLabel widget to display the image
|
||||
self.image_label = ProportionalImageLabel()
|
||||
self.image_label.setMaximumSize(700, 500)
|
||||
self.image_label.setFixedHeight(500)
|
||||
self.image_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
|
||||
self.load_image_path(os.path.join(resources_dir(), 'Rectangle.png'))
|
||||
|
||||
# Server list
|
||||
self.server_list_view = QListWidget()
|
||||
self.server_list_view.itemClicked.connect(self.server_picked)
|
||||
list_font = QFont()
|
||||
list_font.setPointSize(16)
|
||||
self.server_list_view.setFont(list_font)
|
||||
self.added_hostnames = []
|
||||
|
||||
self.setup_ui(main_layout)
|
||||
|
||||
self.create_toolbars()
|
||||
|
||||
# Add Widgets to Window
|
||||
self.setMenuBar(MenuBar(self))
|
||||
self.setStatusBar(StatusBar(self))
|
||||
|
||||
# start background update
|
||||
self.bg_update_thread = QThread()
|
||||
self.bg_update_thread.run = self.__background_update
|
||||
self.bg_update_thread.start()
|
||||
|
||||
# Setup other windows
|
||||
self.new_job_window = None
|
||||
self.console_window = None
|
||||
self.log_viewer_window = None
|
||||
|
||||
# Pick default job
|
||||
self.job_picked()
|
||||
|
||||
def setup_ui(self, main_layout):
|
||||
|
||||
# Servers
|
||||
server_list_group = QGroupBox("Available Servers")
|
||||
list_layout = QVBoxLayout()
|
||||
list_layout.addWidget(self.server_list_view)
|
||||
list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
server_list_group.setLayout(list_layout)
|
||||
server_info_group = QGroupBox("Server Info")
|
||||
|
||||
# Server Info Group
|
||||
self.server_info_hostname = QLabel()
|
||||
self.server_info_os = QLabel()
|
||||
self.server_info_cpu = QLabel()
|
||||
self.server_info_ram = QLabel()
|
||||
server_info_engines_button = QPushButton("Render Engines")
|
||||
server_info_engines_button.clicked.connect(self.engine_browser)
|
||||
server_info_layout = QVBoxLayout()
|
||||
server_info_layout.addWidget(self.server_info_hostname)
|
||||
server_info_layout.addWidget(self.server_info_os)
|
||||
server_info_layout.addWidget(self.server_info_cpu)
|
||||
server_info_layout.addWidget(self.server_info_ram)
|
||||
server_info_layout.addWidget(server_info_engines_button)
|
||||
server_info_group.setLayout(server_info_layout)
|
||||
|
||||
# Server Button Layout
|
||||
server_button_layout = QHBoxLayout()
|
||||
add_server_button = QPushButton(text="+")
|
||||
remove_server_button = QPushButton(text="-")
|
||||
server_button_layout.addWidget(add_server_button)
|
||||
server_button_layout.addWidget(remove_server_button)
|
||||
|
||||
# Layouts
|
||||
info_layout = QVBoxLayout()
|
||||
info_layout.addWidget(server_list_group, stretch=True)
|
||||
info_layout.addWidget(server_info_group)
|
||||
info_layout.setContentsMargins(0, 0, 0, 0)
|
||||
server_list_group.setFixedWidth(260)
|
||||
self.server_picked()
|
||||
|
||||
# Job list
|
||||
self.job_list_view = QTableWidget()
|
||||
self.job_list_view.setRowCount(0)
|
||||
self.job_list_view.setColumnCount(8)
|
||||
self.job_list_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.job_list_view.verticalHeader().setVisible(False)
|
||||
self.job_list_view.itemSelectionChanged.connect(self.job_picked)
|
||||
self.job_list_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.refresh_job_headers()
|
||||
|
||||
# Image Layout
|
||||
image_group = QGroupBox("Job Preview")
|
||||
image_layout = QVBoxLayout(image_group)
|
||||
image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
image_center_layout = QHBoxLayout()
|
||||
image_center_layout.addWidget(self.image_label)
|
||||
image_layout.addWidget(self.image_label)
|
||||
# image_layout.addLayout(image_center_layout)
|
||||
|
||||
# Job Layout
|
||||
job_list_group = QGroupBox("Render Jobs")
|
||||
job_list_layout = QVBoxLayout(job_list_group)
|
||||
job_list_layout.setContentsMargins(0, 0, 0, 0)
|
||||
image_layout.addWidget(self.job_list_view, stretch=True)
|
||||
image_layout.addLayout(job_list_layout)
|
||||
|
||||
# Add them all to the window
|
||||
main_layout.addLayout(info_layout)
|
||||
|
||||
right_layout = QVBoxLayout()
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.addWidget(image_group)
|
||||
# right_layout.addWidget(job_list_group)
|
||||
main_layout.addLayout(right_layout)
|
||||
|
||||
def __background_update(self):
|
||||
while True:
|
||||
self.update_servers()
|
||||
self.fetch_jobs()
|
||||
time.sleep(0.5)
|
||||
|
||||
def closeEvent(self, event):
|
||||
running_jobs = len(RenderQueue.running_jobs())
|
||||
if running_jobs:
|
||||
reply = QMessageBox.question(self, "Running Jobs",
|
||||
f"You have {running_jobs} jobs running.\n"
|
||||
f"Quitting will cancel these renders. Continue?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
event.accept()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
# -- Server Code -- #
|
||||
|
||||
@property
|
||||
def current_server_proxy(self):
|
||||
return ServerProxyManager.get_proxy_for_hostname(self.current_hostname)
|
||||
|
||||
def server_picked(self):
|
||||
"""Update the UI elements relevant to the server selection."""
|
||||
try:
|
||||
# Retrieve the new hostname selected by the user
|
||||
new_hostname = self.server_list_view.currentItem().text()
|
||||
|
||||
# Check if the hostname has changed to avoid unnecessary updates
|
||||
if new_hostname != self.current_hostname:
|
||||
# Update the current hostname and clear the job list
|
||||
self.current_hostname = new_hostname
|
||||
self.job_list_view.setRowCount(0)
|
||||
self.fetch_jobs(clear_table=True)
|
||||
|
||||
# Select the first row if there are jobs listed
|
||||
if self.job_list_view.rowCount():
|
||||
self.job_list_view.selectRow(0)
|
||||
|
||||
# Update server information display
|
||||
self.update_server_info_display(new_hostname)
|
||||
|
||||
except AttributeError:
|
||||
# Handle cases where the server list view might not be properly initialized
|
||||
pass
|
||||
|
||||
def update_server_info_display(self, hostname):
|
||||
"""Updates the server information section of the UI."""
|
||||
self.server_info_hostname.setText(hostname or "unknown")
|
||||
server_info = ZeroconfServer.get_hostname_properties(hostname)
|
||||
|
||||
# Use the get method with defaults to avoid KeyError
|
||||
os_info = f"OS: {server_info.get('system_os', 'Unknown')} {server_info.get('system_os_version', '')}"
|
||||
cpu_info = f"CPU: {server_info.get('system_cpu', 'Unknown')} - {server_info.get('system_cpu_cores', 'Unknown')} cores"
|
||||
|
||||
self.server_info_os.setText(os_info.strip())
|
||||
self.server_info_cpu.setText(cpu_info)
|
||||
|
||||
def fetch_jobs(self, clear_table=False):
|
||||
|
||||
if not self.current_server_proxy:
|
||||
return
|
||||
|
||||
if clear_table:
|
||||
self.job_list_view.clear()
|
||||
self.refresh_job_headers()
|
||||
|
||||
job_fetch = self.current_server_proxy.get_all_jobs(ignore_token=clear_table)
|
||||
if job_fetch:
|
||||
num_jobs = len(job_fetch)
|
||||
self.job_list_view.setRowCount(num_jobs)
|
||||
|
||||
for row, job in enumerate(job_fetch):
|
||||
|
||||
display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \
|
||||
('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status
|
||||
tags = (job['status'],)
|
||||
start_time = datetime.datetime.fromisoformat(job['start_time']) if job['start_time'] else None
|
||||
end_time = datetime.datetime.fromisoformat(job['end_time']) if job['end_time'] else None
|
||||
|
||||
time_elapsed = "" if (job['status'] != RenderStatus.RUNNING.value and not end_time) else \
|
||||
get_time_elapsed(start_time, end_time)
|
||||
|
||||
name = job.get('name') or os.path.basename(job.get('input_path', ''))
|
||||
renderer = f"{job.get('renderer', '')}-{job.get('renderer_version')}"
|
||||
priority = str(job.get('priority', ''))
|
||||
total_frames = str(job.get('total_frames', ''))
|
||||
|
||||
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(renderer),
|
||||
QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed),
|
||||
QTableWidgetItem(total_frames), QTableWidgetItem(job['date_created'])]
|
||||
|
||||
for col, item in enumerate(items):
|
||||
self.job_list_view.setItem(row, col, item)
|
||||
|
||||
# -- Job Code -- #
|
||||
def job_picked(self):
|
||||
|
||||
def fetch_preview(job_id):
|
||||
try:
|
||||
before_fetch_hostname = self.current_server_proxy.hostname
|
||||
response = self.current_server_proxy.request(f'job/{job_id}/thumbnail?size=big')
|
||||
if response.ok:
|
||||
import io
|
||||
image_data = response.content
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
if self.current_server_proxy.hostname == before_fetch_hostname and job_id == \
|
||||
self.selected_job_ids()[0]:
|
||||
self.load_image_data(image)
|
||||
except ConnectionError as e:
|
||||
logger.error(f"Connection error fetching image: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching image: {e}")
|
||||
|
||||
job_id = self.selected_job_ids()[0] if self.selected_job_ids() else None
|
||||
local_server = is_localhost(self.current_hostname)
|
||||
|
||||
if job_id:
|
||||
fetch_thread = threading.Thread(target=fetch_preview, args=(job_id,))
|
||||
fetch_thread.daemon = True
|
||||
fetch_thread.start()
|
||||
|
||||
selected_row = self.job_list_view.selectionModel().selectedRows()[0]
|
||||
current_status = self.job_list_view.item(selected_row.row(), 4).text()
|
||||
|
||||
# show / hide the stop button
|
||||
show_stop_button = current_status.lower() == 'running'
|
||||
self.topbar.actions_call['Stop Job'].setEnabled(show_stop_button)
|
||||
self.topbar.actions_call['Stop Job'].setVisible(show_stop_button)
|
||||
self.topbar.actions_call['Delete Job'].setEnabled(not show_stop_button)
|
||||
self.topbar.actions_call['Delete Job'].setVisible(not show_stop_button)
|
||||
|
||||
self.topbar.actions_call['Render Log'].setEnabled(True)
|
||||
self.topbar.actions_call['Download'].setEnabled(not local_server)
|
||||
self.topbar.actions_call['Download'].setVisible(not local_server)
|
||||
self.topbar.actions_call['Open Files'].setEnabled(local_server)
|
||||
self.topbar.actions_call['Open Files'].setVisible(local_server)
|
||||
else:
|
||||
# load default
|
||||
default_image_path = os.path.join(resources_dir(), 'Rectangle.png')
|
||||
self.load_image_path(default_image_path)
|
||||
self.topbar.actions_call['Stop Job'].setVisible(False)
|
||||
self.topbar.actions_call['Stop Job'].setEnabled(False)
|
||||
self.topbar.actions_call['Delete Job'].setEnabled(False)
|
||||
self.topbar.actions_call['Render Log'].setEnabled(False)
|
||||
self.topbar.actions_call['Download'].setEnabled(False)
|
||||
self.topbar.actions_call['Download'].setVisible(True)
|
||||
self.topbar.actions_call['Open Files'].setEnabled(False)
|
||||
self.topbar.actions_call['Open Files'].setVisible(False)
|
||||
|
||||
def selected_job_ids(self):
|
||||
selected_rows = self.job_list_view.selectionModel().selectedRows()
|
||||
job_ids = []
|
||||
for selected_row in selected_rows:
|
||||
id_item = self.job_list_view.item(selected_row.row(), 0)
|
||||
job_ids.append(id_item.text())
|
||||
return job_ids
|
||||
|
||||
def refresh_job_headers(self):
|
||||
self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Renderer", "Priority", "Status",
|
||||
"Time Elapsed", "Frames", "Date Created"])
|
||||
self.job_list_view.setColumnHidden(0, True)
|
||||
|
||||
self.job_list_view.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
self.job_list_view.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.job_list_view.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.job_list_view.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.job_list_view.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.job_list_view.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.job_list_view.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents)
|
||||
|
||||
# -- Image Code -- #
|
||||
|
||||
def load_image_path(self, image_path):
|
||||
# Load and set the image using QPixmap
|
||||
pixmap = QPixmap(image_path)
|
||||
if not pixmap:
|
||||
logger.error("Error loading image")
|
||||
return
|
||||
self.image_label.setPixmap(pixmap)
|
||||
|
||||
def load_image_data(self, pillow_image):
|
||||
# Convert the Pillow Image to a QByteArray (byte buffer)
|
||||
byte_array = QByteArray()
|
||||
buffer = QBuffer(byte_array)
|
||||
buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
pillow_image.save(buffer, "PNG")
|
||||
buffer.close()
|
||||
|
||||
# Create a QImage from the QByteArray
|
||||
image = QImage.fromData(byte_array)
|
||||
|
||||
# Create a QPixmap from the QImage
|
||||
pixmap = QPixmap.fromImage(image)
|
||||
|
||||
if not pixmap:
|
||||
logger.error("Error loading image")
|
||||
return
|
||||
self.image_label.setPixmap(pixmap)
|
||||
|
||||
def update_servers(self):
|
||||
found_servers = list(set(ZeroconfServer.found_hostnames() + self.added_hostnames))
|
||||
# Always make sure local hostname is first
|
||||
if found_servers and not is_localhost(found_servers[0]):
|
||||
for hostname in found_servers:
|
||||
if is_localhost(hostname):
|
||||
found_servers.remove(hostname)
|
||||
found_servers.insert(0, hostname)
|
||||
break
|
||||
|
||||
old_count = self.server_list_view.count()
|
||||
|
||||
# Update proxys
|
||||
for hostname in found_servers:
|
||||
ServerProxyManager.get_proxy_for_hostname(hostname) # setup background updates
|
||||
|
||||
# Add in all the missing servers
|
||||
current_server_list = []
|
||||
for i in range(self.server_list_view.count()):
|
||||
current_server_list.append(self.server_list_view.item(i).text())
|
||||
for hostname in found_servers:
|
||||
if hostname not in current_server_list:
|
||||
properties = ZeroconfServer.get_hostname_properties(hostname)
|
||||
image_path = os.path.join(resources_dir(), 'icons', f"{properties.get('system_os', 'Monitor')}.png")
|
||||
list_widget = QListWidgetItem(QIcon(image_path), hostname)
|
||||
self.server_list_view.addItem(list_widget)
|
||||
|
||||
# find any servers that shouldn't be shown any longer
|
||||
servers_to_remove = []
|
||||
for i in range(self.server_list_view.count()):
|
||||
name = self.server_list_view.item(i).text()
|
||||
if name not in found_servers:
|
||||
servers_to_remove.append(name)
|
||||
|
||||
# remove any servers that shouldn't be shown any longer
|
||||
for server in servers_to_remove:
|
||||
# Find and remove the item with the specified text
|
||||
for i in range(self.server_list_view.count()):
|
||||
item = self.server_list_view.item(i)
|
||||
if item is not None and item.text() == server:
|
||||
self.server_list_view.takeItem(i)
|
||||
break # Stop searching after the first match is found
|
||||
|
||||
if not old_count and self.server_list_view.count():
|
||||
self.server_list_view.setCurrentRow(0)
|
||||
self.server_picked()
|
||||
|
||||
def create_toolbars(self) -> None:
|
||||
"""
|
||||
Creates and adds the top and right toolbars to the main window.
|
||||
"""
|
||||
# Top Toolbar [PyQt6.QtWidgets.QToolBar]
|
||||
self.topbar = ToolBar(self, orientation=Qt.Orientation.Horizontal,
|
||||
style=Qt.ToolButtonStyle.ToolButtonTextUnderIcon, icon_size=(24, 24))
|
||||
self.topbar.setMovable(False)
|
||||
|
||||
resources_directory = resources_dir()
|
||||
|
||||
# Top Toolbar Buttons
|
||||
self.topbar.add_button(
|
||||
"New Job", f"{resources_directory}/icons/AddProduct.png", self.new_job)
|
||||
self.topbar.add_button(
|
||||
"Engines", f"{resources_directory}/icons/SoftwareInstaller.png", self.engine_browser)
|
||||
self.topbar.add_button(
|
||||
"Console", f"{resources_directory}/icons/Console.png", self.open_console_window)
|
||||
self.topbar.add_separator()
|
||||
self.topbar.add_button(
|
||||
"Stop Job", f"{resources_directory}/icons/StopSign.png", self.stop_job)
|
||||
self.topbar.add_button(
|
||||
"Delete Job", f"{resources_directory}/icons/Trash.png", self.delete_job)
|
||||
self.topbar.add_button(
|
||||
"Render Log", f"{resources_directory}/icons/Document.png", self.job_logs)
|
||||
self.topbar.add_button(
|
||||
"Download", f"{resources_directory}/icons/Download.png", self.download_files)
|
||||
self.topbar.add_button(
|
||||
"Open Files", f"{resources_directory}/icons/SearchFolder.png", self.open_files)
|
||||
|
||||
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.topbar)
|
||||
|
||||
# -- Toolbar Buttons -- #
|
||||
|
||||
def open_console_window(self) -> None:
|
||||
"""
|
||||
Event handler for the "Open Console" button
|
||||
"""
|
||||
self.console_window = ConsoleWindow(self.buffer_handler)
|
||||
self.console_window.buffer_handler = self.buffer_handler
|
||||
self.console_window.show()
|
||||
|
||||
def engine_browser(self):
|
||||
self.engine_browser_window = EngineBrowserWindow(hostname=self.current_hostname)
|
||||
self.engine_browser_window.show()
|
||||
|
||||
def job_logs(self) -> None:
|
||||
"""
|
||||
Event handler for the "Logs" button.
|
||||
"""
|
||||
selected_job_ids = self.selected_job_ids()
|
||||
if selected_job_ids:
|
||||
url = f'http://{self.current_server_proxy.optimized_hostname()}:{self.current_server_proxy.port}/api/job/{selected_job_ids[0]}/logs'
|
||||
self.log_viewer_window = LogViewer(url)
|
||||
self.log_viewer_window.show()
|
||||
|
||||
def stop_job(self, event):
|
||||
"""
|
||||
Event handler for the "Exit" button. Closes the application.
|
||||
"""
|
||||
job_ids = self.selected_job_ids()
|
||||
if not job_ids:
|
||||
return
|
||||
|
||||
if len(job_ids) == 1:
|
||||
job = next((job for job in self.current_server_proxy.get_all_jobs() if job.get('id') == job_ids[0]), None)
|
||||
if job:
|
||||
display_name = job.get('name', os.path.basename(job.get('input_path', '')))
|
||||
message = f"Are you sure you want to delete the job:\n{display_name}?"
|
||||
else:
|
||||
return # Job not found, handle this case as needed
|
||||
else:
|
||||
message = f"Are you sure you want to delete these {len(job_ids)} jobs?"
|
||||
|
||||
# Display the message box and check the response in one go
|
||||
msg_box = QMessageBox(QMessageBox.Icon.Warning, "Delete Job", message,
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self)
|
||||
|
||||
if msg_box.exec() == QMessageBox.StandardButton.Yes:
|
||||
for job_id in job_ids:
|
||||
self.current_server_proxy.cancel_job(job_id, confirm=True)
|
||||
self.fetch_jobs(clear_table=True)
|
||||
|
||||
def delete_job(self, event):
|
||||
"""
|
||||
Event handler for the Delete Job button
|
||||
"""
|
||||
job_ids = self.selected_job_ids()
|
||||
if not job_ids:
|
||||
return
|
||||
|
||||
if len(job_ids) == 1:
|
||||
job = next((job for job in self.current_server_proxy.get_all_jobs() if job.get('id') == job_ids[0]), None)
|
||||
if job:
|
||||
display_name = job.get('name', os.path.basename(job.get('input_path', '')))
|
||||
message = f"Are you sure you want to delete the job:\n{display_name}?"
|
||||
else:
|
||||
return # Job not found, handle this case as needed
|
||||
else:
|
||||
message = f"Are you sure you want to delete these {len(job_ids)} jobs?"
|
||||
|
||||
# Display the message box and check the response in one go
|
||||
msg_box = QMessageBox(QMessageBox.Icon.Warning, "Delete Job", message,
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self)
|
||||
|
||||
if msg_box.exec() == QMessageBox.StandardButton.Yes:
|
||||
for job_id in job_ids:
|
||||
self.current_server_proxy.delete_job(job_id, confirm=True)
|
||||
self.fetch_jobs(clear_table=True)
|
||||
|
||||
def download_files(self, event):
|
||||
pass
|
||||
|
||||
def open_files(self, event):
|
||||
job_ids = self.selected_job_ids()
|
||||
if not job_ids:
|
||||
return
|
||||
|
||||
for job_id in job_ids:
|
||||
job_info = self.current_server_proxy.get_job_info(job_id)
|
||||
path = os.path.dirname(job_info['output_path'])
|
||||
|
||||
if sys.platform.startswith('darwin'):
|
||||
subprocess.run(['open', path])
|
||||
elif sys.platform.startswith('win32'):
|
||||
os.startfile(path)
|
||||
elif sys.platform.startswith('linux'):
|
||||
subprocess.run(['xdg-open', path])
|
||||
else:
|
||||
raise OSError("Unsupported operating system")
|
||||
|
||||
def new_job(self) -> None:
|
||||
|
||||
file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
|
||||
if file_name:
|
||||
self.new_job_window = NewRenderJobForm(file_name)
|
||||
self.new_job_window.show()
|
||||
0
src/ui/widgets/__init__.py
Normal file
1
src/ui/widgets/dialog.py
Normal file
@@ -0,0 +1 @@
|
||||
''' app/ui/widgets/dialog.py '''
|
||||
23
src/ui/widgets/menubar.py
Normal file
@@ -0,0 +1,23 @@
|
||||
''' app/ui/widgets/menubar.py '''
|
||||
from PyQt6.QtWidgets import QMenuBar
|
||||
|
||||
|
||||
class MenuBar(QMenuBar):
|
||||
"""
|
||||
Initialize the menu bar.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
file_menu = self.addMenu("File")
|
||||
# edit_menu = self.addMenu("Edit")
|
||||
# view_menu = self.addMenu("View")
|
||||
# help_menu = self.addMenu("Help")
|
||||
|
||||
# Add actions to the menus
|
||||
# file_menu.addAction(self.parent().topbar.actions_call["Open"]) # type: ignore
|
||||
# file_menu.addAction(self.parent().topbar.actions_call["Save"]) # type: ignore
|
||||
# file_menu.addAction(self.parent().topbar.actions_call["Exit"]) # type: ignore
|
||||
40
src/ui/widgets/proportional_image_label.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from PyQt6.QtCore import QRectF
|
||||
from PyQt6.QtGui import QPainter
|
||||
from PyQt6.QtWidgets import QLabel
|
||||
|
||||
|
||||
class ProportionalImageLabel(QLabel):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def setPixmap(self, pixmap):
|
||||
self._pixmap = pixmap
|
||||
super().setPixmap(self._pixmap)
|
||||
|
||||
def paintEvent(self, event):
|
||||
if self._pixmap.isNull():
|
||||
super().paintEvent(event)
|
||||
return
|
||||
|
||||
painter = QPainter(self)
|
||||
targetRect = event.rect()
|
||||
|
||||
# Calculate the aspect ratio of the pixmap
|
||||
aspectRatio = self._pixmap.width() / self._pixmap.height()
|
||||
|
||||
# Calculate the size of the pixmap within the target rectangle while maintaining the aspect ratio
|
||||
if aspectRatio > targetRect.width() / targetRect.height():
|
||||
scaledWidth = targetRect.width()
|
||||
scaledHeight = targetRect.width() / aspectRatio
|
||||
else:
|
||||
scaledHeight = targetRect.height()
|
||||
scaledWidth = targetRect.height() * aspectRatio
|
||||
|
||||
# Calculate the position to center the pixmap within the target rectangle
|
||||
x = targetRect.x() + (targetRect.width() - scaledWidth) / 2
|
||||
y = targetRect.y() + (targetRect.height() - scaledHeight) / 2
|
||||
|
||||
sourceRect = QRectF(0.0, 0.0, self._pixmap.width(), self._pixmap.height())
|
||||
targetRect = QRectF(x, y, scaledWidth, scaledHeight)
|
||||
|
||||
painter.drawPixmap(targetRect, self._pixmap, sourceRect)
|
||||
68
src/ui/widgets/statusbar.py
Normal file
@@ -0,0 +1,68 @@
|
||||
''' app/ui/widgets/statusbar.py '''
|
||||
import os.path
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QPixmap
|
||||
from PyQt6.QtWidgets import QStatusBar, QLabel
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.utilities.misc_helper import resources_dir
|
||||
|
||||
|
||||
class StatusBar(QStatusBar):
|
||||
"""
|
||||
Initialize the status bar.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
"""
|
||||
|
||||
def __init__(self, parent) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
def background_update():
|
||||
|
||||
proxy = RenderServerProxy(socket.gethostname())
|
||||
proxy.start_background_update()
|
||||
image_names = {'Ready': 'GreenCircle.png', 'Offline': "RedSquare.png"}
|
||||
|
||||
# Check for status change every 1s on background thread
|
||||
while True:
|
||||
new_status = proxy.status()
|
||||
new_image_name = image_names.get(new_status, 'Synchronize.png')
|
||||
image_path = os.path.join(resources_dir(), 'icons', new_image_name)
|
||||
self.label.setPixmap((QPixmap(image_path).scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio)))
|
||||
|
||||
# add download status
|
||||
if EngineManager.download_tasks:
|
||||
if len(EngineManager.download_tasks) == 1:
|
||||
task = EngineManager.download_tasks[0]
|
||||
new_status = f"{new_status} | Downloading {task.engine.capitalize()} {task.version}..."
|
||||
else:
|
||||
new_status = f"{new_status} | Downloading {len(EngineManager.download_tasks)} engines"
|
||||
|
||||
self.messageLabel.setText(new_status)
|
||||
time.sleep(1)
|
||||
|
||||
background_thread = threading.Thread(target=background_update,)
|
||||
background_thread.daemon = True
|
||||
background_thread.start()
|
||||
|
||||
# Create a label that holds an image
|
||||
self.label = QLabel()
|
||||
image_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'resources', 'icons',
|
||||
'RedSquare.png')
|
||||
pixmap = (QPixmap(image_path).scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
self.label.setPixmap(pixmap)
|
||||
self.addWidget(self.label)
|
||||
|
||||
# Create a label for the message
|
||||
self.messageLabel = QLabel()
|
||||
self.addWidget(self.messageLabel)
|
||||
|
||||
# Call this method to display a message
|
||||
self.messageLabel.setText("Loading...")
|
||||
49
src/ui/widgets/toolbar.py
Normal file
@@ -0,0 +1,49 @@
|
||||
''' app/ui/widgets/toolbar.py '''
|
||||
from PyQt6.QtCore import Qt, QSize
|
||||
from PyQt6.QtGui import QAction, QIcon
|
||||
from PyQt6.QtWidgets import QToolBar, QWidget, QSizePolicy
|
||||
|
||||
|
||||
class ToolBar(QToolBar):
|
||||
"""
|
||||
Initialize the toolbar.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
orientation: The toolbar's orientation.
|
||||
style: The toolbar's tool button style.
|
||||
icon_size: The toolbar's icon size.
|
||||
"""
|
||||
|
||||
def __init__(self, parent,
|
||||
orientation: Qt.Orientation = Qt.Orientation.Horizontal,
|
||||
style: Qt.ToolButtonStyle = Qt.ToolButtonStyle.ToolButtonTextUnderIcon,
|
||||
icon_size: tuple[int, int] = (32, 32)) -> None:
|
||||
super().__init__(parent)
|
||||
self.actions_call = {}
|
||||
self.setOrientation(orientation)
|
||||
|
||||
self.setToolButtonStyle(style)
|
||||
self.setIconSize(QSize(icon_size[0], icon_size[1]))
|
||||
|
||||
def add_button(self, text: str, icon: str, trigger_action) -> None:
|
||||
"""
|
||||
Add a button to the toolbar.
|
||||
|
||||
Args:
|
||||
text: The button's text.
|
||||
icon: The button's icon.
|
||||
trigger_action: The action to be executed when the button is clicked.
|
||||
"""
|
||||
self.actions_call[text] = QAction(QIcon(icon), text, self)
|
||||
self.actions_call[text].triggered.connect(trigger_action)
|
||||
self.addAction(self.actions_call[text])
|
||||
|
||||
def add_separator(self) -> None:
|
||||
"""
|
||||
Add a separator to the toolbar.
|
||||
"""
|
||||
separator = QWidget(self)
|
||||
separator.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.addWidget(separator)
|
||||
29
src/ui/widgets/treeview.py
Normal file
@@ -0,0 +1,29 @@
|
||||
''' app/ui/widgets/treeview.py '''
|
||||
from PyQt6.QtWidgets import QTreeView
|
||||
from PyQt6.QtGui import QFileSystemModel
|
||||
from PyQt6.QtCore import QDir
|
||||
|
||||
|
||||
class TreeView(QTreeView):
|
||||
"""
|
||||
Initialize the TreeView widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): Parent widget of the TreeView. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.file_system_model: QFileSystemModel = QFileSystemModel()
|
||||
self.file_system_model.setRootPath(QDir.currentPath())
|
||||
self.setModel(self.file_system_model)
|
||||
self.setRootIndex(self.file_system_model.index(QDir.currentPath()))
|
||||
self.setColumnWidth(0, 100)
|
||||
self.setFixedWidth(150)
|
||||
self.setSortingEnabled(True)
|
||||
|
||||
def clear_view(self) -> None:
|
||||
"""
|
||||
Clearing the TreeView
|
||||
"""
|
||||
self.destroy(destroySubWindows=True)
|
||||
36
src/utilities/config.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
import yaml
|
||||
|
||||
|
||||
class Config:
|
||||
# Initialize class variables with default values
|
||||
upload_folder = "~/zordon-uploads/"
|
||||
update_engines_on_launch = True
|
||||
max_content_path = 100000000
|
||||
server_log_level = 'debug'
|
||||
log_buffer_length = 250
|
||||
subjob_connection_timeout = 120
|
||||
flask_log_level = 'error'
|
||||
flask_debug_enable = False
|
||||
queue_eval_seconds = 1
|
||||
port_number = 8080
|
||||
enable_split_jobs = True
|
||||
download_timeout_seconds = 120
|
||||
|
||||
@classmethod
|
||||
def load_config(cls, config_path):
|
||||
with open(config_path, 'r') as ymlfile:
|
||||
cfg = yaml.safe_load(ymlfile)
|
||||
|
||||
cls.upload_folder = os.path.expanduser(cfg.get('upload_folder', cls.upload_folder))
|
||||
cls.update_engines_on_launch = cfg.get('update_engines_on_launch', cls.update_engines_on_launch)
|
||||
cls.max_content_path = cfg.get('max_content_path', cls.max_content_path)
|
||||
cls.server_log_level = cfg.get('server_log_level', cls.server_log_level)
|
||||
cls.log_buffer_length = cfg.get('log_buffer_length', cls.log_buffer_length)
|
||||
cls.subjob_connection_timeout = cfg.get('subjob_connection_timeout', cls.subjob_connection_timeout)
|
||||
cls.flask_log_level = cfg.get('flask_log_level', cls.flask_log_level)
|
||||
cls.flask_debug_enable = cfg.get('flask_debug_enable', cls.flask_debug_enable)
|
||||
cls.queue_eval_seconds = cfg.get('queue_eval_seconds', cls.queue_eval_seconds)
|
||||
cls.port_number = cfg.get('port_number', cls.port_number)
|
||||
cls.enable_split_jobs = cfg.get('enable_split_jobs', cls.enable_split_jobs)
|
||||
cls.download_timeout_seconds = cfg.get('download_timeout_seconds', cls.download_timeout_seconds)
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
@@ -123,3 +124,25 @@ def current_system_os_version():
|
||||
def current_system_cpu():
|
||||
# convert all x86 64 to "x64"
|
||||
return platform.machine().lower().replace('amd64', 'x64').replace('x86_64', 'x64')
|
||||
|
||||
|
||||
def resources_dir():
|
||||
resources_directory = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'resources')
|
||||
return resources_directory
|
||||
|
||||
|
||||
def config_dir():
|
||||
config_directory = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'config')
|
||||
return config_directory
|
||||
|
||||
|
||||
def is_localhost(comparison_hostname):
|
||||
# this is necessary because socket.gethostname() does not always include '.local' - This is a sanitized comparison
|
||||
try:
|
||||
comparison_hostname = comparison_hostname.lower().replace('.local', '')
|
||||
local_hostname = socket.gethostname().lower().replace('.local', '')
|
||||
return comparison_hostname == local_hostname
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import socket
|
||||
|
||||
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange
|
||||
from pubsub import pub
|
||||
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange, NonUniqueNameException
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -24,6 +25,8 @@ class ZeroconfServer:
|
||||
|
||||
@classmethod
|
||||
def start(cls, listen_only=False):
|
||||
if not cls.service_type:
|
||||
raise RuntimeError("The 'configure' method must be run before starting the zeroconf server")
|
||||
if not listen_only:
|
||||
cls._register_service()
|
||||
cls._browse_services()
|
||||
@@ -35,19 +38,22 @@ class ZeroconfServer:
|
||||
|
||||
@classmethod
|
||||
def _register_service(cls):
|
||||
cls.server_ip = socket.gethostbyname(socket.gethostname())
|
||||
try:
|
||||
cls.server_ip = socket.gethostbyname(socket.gethostname())
|
||||
|
||||
info = ServiceInfo(
|
||||
cls.service_type,
|
||||
f"{cls.server_name}.{cls.service_type}",
|
||||
addresses=[socket.inet_aton(cls.server_ip)],
|
||||
port=cls.server_port,
|
||||
properties=cls.properties,
|
||||
)
|
||||
info = ServiceInfo(
|
||||
cls.service_type,
|
||||
f"{cls.server_name}.{cls.service_type}",
|
||||
addresses=[socket.inet_aton(cls.server_ip)],
|
||||
port=cls.server_port,
|
||||
properties=cls.properties,
|
||||
)
|
||||
|
||||
cls.service_info = info
|
||||
cls.zeroconf.register_service(info)
|
||||
logger.info(f"Registered zeroconf service: {cls.service_info.name}")
|
||||
cls.service_info = info
|
||||
cls.zeroconf.register_service(info)
|
||||
logger.info(f"Registered zeroconf service: {cls.service_info.name}")
|
||||
except NonUniqueNameException as e:
|
||||
logger.error(f"Error establishing zeroconf: {e}")
|
||||
|
||||
@classmethod
|
||||
def _unregister_service(cls):
|
||||
@@ -70,11 +76,27 @@ class ZeroconfServer:
|
||||
cls.client_cache[name] = info
|
||||
else:
|
||||
cls.client_cache.pop(name)
|
||||
pub.sendMessage('zeroconf_state_change', hostname=name, state_change=state_change, info=info)
|
||||
|
||||
@classmethod
|
||||
def found_clients(cls):
|
||||
return [x.split(f'.{cls.service_type}')[0] for x in cls.client_cache.keys()]
|
||||
def found_hostnames(cls):
|
||||
fetched_hostnames = [x.split(f'.{cls.service_type}')[0] for x in cls.client_cache.keys()]
|
||||
local_hostname = socket.gethostname()
|
||||
# Define a sort key function
|
||||
def sort_key(hostname):
|
||||
# Return 0 if it's the local hostname so it comes first, else return 1
|
||||
return False if hostname == local_hostname else True
|
||||
|
||||
# Sort the list with the local hostname first
|
||||
sorted_hostnames = sorted(fetched_hostnames, key=sort_key)
|
||||
return sorted_hostnames
|
||||
|
||||
@classmethod
|
||||
def get_hostname_properties(cls, hostname):
|
||||
new_key = hostname + '.' + cls.service_type
|
||||
server_info = cls.client_cache.get(new_key).properties
|
||||
decoded_server_info = {key.decode('utf-8'): value.decode('utf-8') for key, value in server_info.items()}
|
||||
return decoded_server_info
|
||||
|
||||
# Example usage:
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from src.client.dashboard_window import start_client
|
||||
|
||||
if __name__ == '__main__':
|
||||
start_client()
|
||||