18 Commits

Author SHA1 Message Date
Brett Williams
82e50d80bc Use 127.0.0.1 when connecting to localhost 2023-11-22 06:50:13 -08:00
c0d0ec64a8 Dynamic engine options in UI for blender / ffmpeg (#66)
* Make sure progress UI updates occur on main thread

* Cleanup unnecessary code in FFMPEG

* Cleanup extension matching

* Make sure supported_extensions is now called as a method everywhere

* Fix add_job crashing

* Update the renderer to reflect the current file type

* Sort engine versions from newest to oldest

* Consolidate Project Group and Server Group

* Split UI options into its own file for easier updating

* Add ffmpeg ui stem
2023-11-21 01:31:56 -08:00
32afcf945d Use loopback address for local host (fixes issue with locked down networks) (#65) 2023-11-21 01:16:26 -08:00
e9f9521924 Report Engine Download Status in UI (#64)
* Report downloads in status bar

* Update engine_browser.py UI with any active downloads
2023-11-20 19:58:31 -08:00
Brett Williams
0e0eba7b22 Close the session properly - part 2 2023-11-11 10:59:39 -06:00
Brett Williams
86c5d4cc15 Properly close the renderqueue when shutting down 2023-11-11 10:58:21 -06:00
da61bf72f8 Add job polish (#63)
* Remove legacy client

* Misc cleanup

* Add message box after submission success / fail

* Use a new is_localhost method to handle localhost not including '.local'

* Code cleanup

* Fix issue where engine browser would think we're downloading forever

* Add message box after submission success / fail

* Use a new is_localhost method to handle localhost not including '.local'

* Code cleanup

* Fix issue where engine browser would think we're downloading forever

* Add pubsub messages to serverproxy_manager.py

* Add resolution, fps and renderer versions to add_job.py

* Add cameras to add_job.py

* Add message box after submission success / fail

* Use a new is_localhost method to handle localhost not including '.local'

* Code cleanup

* Fix issue where engine browser would think we're downloading forever

* Add message box after submission success / fail

* Code cleanup

* Add cameras to add_job.py

* Add dynamic engine options and output format

* Move UI work out of BG threads and add engine presubmission tasks

* Submit dynamic args when creating a new job

* Hide groups and show messagebox after submission

* Choose file when pressing New Job in main window now
2023-11-11 07:35:56 -06:00
0271abf705 Serverproxy manager (#61)
* Create serverproxy_manager.py

* Replace use of direct RenderServerProxy with ServerProxyManager method
2023-11-05 01:00:36 -05:00
c3b446be8e Don't create empty output directories in the source path (#60) 2023-11-04 23:58:08 -05:00
06a613fcc4 Zeroconf reports system properties (#59)
* Zeroconf.found_clients() now returns dicts of clients, not just hostnames

* Adjustments to distributed_job_manager.py

* Undo config change

* Report system metrics (cpu, os, etc) via zeroconf_server.py

* Zeroconf.found_clients() now returns dicts of clients, not just hostnames

* Adjustments to distributed_job_manager.py

* Undo config change

* Zeroconf.found_clients() now returns dicts of clients, not just hostnames

* Adjustments to distributed_job_manager.py

* Undo config change

* Adjustments to distributed_job_manager.py

* Undo config change

* Rename ZeroconfServer.found_clients() to found_hostnames()
2023-11-04 20:46:27 -05:00
d3b84c6212 Remove legacy client (#58)
* Remove legacy client

* Misc cleanup
2023-11-04 16:13:40 -05:00
Brett Williams
014489e3bf Add engine_help_viewer.py 2023-11-04 10:41:33 -05:00
65c256b641 New UI Redesign in pyqt6 (#56)
* Initial commit for new UI

* Initial commit for new UI

* WIP

* Status bar updates and has an icon for online / offline

* Add log_viewer.py

* Use JSON for delete_engine_download API

* Fix class issue with Downloaders

* Move Config class to new ui

* Add engine_browser.py

* Add a close event handler to the main window

* Fix issue with engine manager not deleting engines properly

* Rearrange all the files

* Add icons and resources

* Cache system info in RenderServerProxy

* Toolbar polish

* Fix resource path in status bar

* Add config_dir to misc_helper.py

* Add try block to zeroconf setup

* Add add_job.py

* Add raw args to add_job.py
2023-11-04 09:52:15 -05:00
bc8e88ea59 Config class (#51)
* Add new Config class to handle loading config files

* Use new config class in api_server.py
2023-10-29 22:22:40 -05:00
6ce69c8d35 Thread Safe Downloads for Renderers (#49)
* Make engines download on another thread

* Fix merge issues
2023-10-29 22:22:29 -05:00
dcc0504d3c Engine and downloader refactoring (#50)
* Make downloaders subclass of base_downloader.py

* Link engines and downloaders together for all engines

* Replace / merge worker_factory.py with engine_manager.py
2023-10-29 20:57:26 -05:00
Brett Williams
22aaa82da7 Simplify database.db logic 2023-10-27 02:41:31 -05:00
Brett Williams
951bebb3a8 Save database.db to upload dir, not code dir 2023-10-27 02:35:21 -05:00
68 changed files with 2557 additions and 1481 deletions

View File

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

@@ -0,0 +1,6 @@
#!/usr/bin/env python3
from src import init
if __name__ == '__main__':
import sys
sys.exit(init.run())

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
resources/icons/Blender.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
resources/icons/Console.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 B

BIN
resources/icons/FFmpeg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
resources/icons/Gear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
resources/icons/Monitor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
resources/icons/Server.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
resources/icons/Trash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

BIN
resources/icons/linux.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
resources/icons/macos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
resources/icons/windows.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
class BlenderUI:
@staticmethod
def get_options(instance):
options = [
{'name': 'engine', 'options': instance.supported_render_engines()},
]
return options

View File

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

View File

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

View 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}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {}

View File

@@ -0,0 +1,5 @@
class FFMPEGUI:
@staticmethod
def get_options(instance):
options = []
return options

View File

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

View File

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

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

View File

1
src/ui/widgets/dialog.py Normal file
View File

@@ -0,0 +1 @@
''' app/ui/widgets/dialog.py '''

23
src/ui/widgets/menubar.py Normal file
View 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

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

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

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

View File

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

View File

@@ -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__":

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env python3
from src.client.dashboard_window import start_client
if __name__ == '__main__':
start_client()