6 Commits

Author SHA1 Message Date
Brett Williams
05cd0470dd Fix build issues using pyinstaller 2025-03-13 14:17:50 -05:00
Brett Williams
7827f73530 Show CPU brand in UI instead of arch. Resolves #110. 2025-02-28 19:54:58 -06:00
562cb23da3 Feature/112 api version (#119)
* Add api_version to status api and server_proxy.py

* Add api_version to Zeroconf and filter out incompatible versions when finding available servers

* Filter incompatible versions from the UI
2025-02-28 19:39:32 -06:00
Brett Williams
6b68d42b93 Misc minor fixes 2025-02-28 18:50:44 -06:00
Brett Williams
cdf4b2bbe1 Update the README.md with an app screenshot 2025-02-28 18:43:40 -06:00
Brett Williams
dc8f4d3e2a Use Fusion Qt style on non-Mac platforms 2025-02-28 18:40:59 -06:00
18 changed files with 117 additions and 37 deletions

View File

@@ -1,6 +1,15 @@
![Zordon Screenshot](docs/screenshot.png)
---
# Zordon # Zordon
A tool designed for small render farms, such as those used in home studios or small businesses, to efficiently manage and run render jobs for Blender, FFMPEG, and other video renderers. It simplifies the process of distributing rendering tasks across multiple available machines, optimizing the rendering workflow for artists, animators, and video professionals. A lightweight, zero-install, distributed rendering and management tool designed to streamline and optimize rendering workflows across multiple machines
## What is Zordon?
Zordon is tool designed for small render farms, such as those used in home studios or small businesses, to efficiently manage and run render jobs for Blender, FFMPEG, and other video renderers. It simplifies the process of distributing rendering tasks across multiple available machines, optimizing the rendering workflow for artists, animators, and video professionals.
Notice: This should be considered a beta and is meant for casual / hobbiest use. Do not use in mission critical environments! Notice: This should be considered a beta and is meant for casual / hobbiest use. Do not use in mission critical environments!

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 838 KiB

View File

@@ -5,8 +5,10 @@ from PyInstaller.utils.hooks import collect_all
import os import os
import sys import sys
import platform import platform
sys.path.insert(0, os.path.abspath('.')) src_path = os.path.abspath("src")
sys.path.insert(0, src_path)
from version import APP_NAME, APP_VERSION, APP_AUTHOR from version import APP_NAME, APP_VERSION, APP_AUTHOR
sys.path.insert(0, os.path.abspath('.'))
datas = [('resources', 'resources'), ('src/engines/blender/scripts/', 'src/engines/blender/scripts')] datas = [('resources', 'resources'), ('src/engines/blender/scripts/', 'src/engines/blender/scripts')]
binaries = [] binaries = []
@@ -55,7 +57,7 @@ if platform.system() == 'Darwin': # macOS
a.datas, a.datas,
strip=True, strip=True,
name=f'{APP_NAME}.app', name=f'{APP_NAME}.app',
icon=None, icon='resources/Server.png',
bundle_identifier=None, bundle_identifier=None,
version=APP_VERSION version=APP_VERSION
) )

View File

@@ -36,3 +36,6 @@ lxml>=5.1.0
click>=8.1.7 click>=8.1.7
requests_toolbelt>=1.0.0 requests_toolbelt>=1.0.0
pyinstaller_versionfile>=2.1.1 pyinstaller_versionfile>=2.1.1
py-cpuinfo~=9.0.0
requests-toolbelt~=1.0.0
ifaddr~=0.2.0

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from init import run from src.init import run
if __name__ == '__main__': if __name__ == '__main__':
run(server_only=True) run(server_only=True)

View File

@@ -11,6 +11,7 @@ import tempfile
import time import time
from datetime import datetime from datetime import datetime
import cpuinfo
import psutil import psutil
import yaml import yaml
from flask import Flask, request, send_file, after_this_request, Response, redirect, url_for from flask import Flask, request, send_file, after_this_request, Response, redirect, url_for
@@ -25,11 +26,13 @@ from src.utilities.config import Config
from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu, \ from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu, \
current_system_os_version, num_to_alphanumeric current_system_os_version, num_to_alphanumeric
from src.utilities.status_utils import string_to_status from src.utilities.status_utils import string_to_status
from src.version import APP_VERSION
logger = logging.getLogger() logger = logging.getLogger()
server = Flask(__name__) server = Flask(__name__)
ssl._create_default_https_context = ssl._create_unverified_context # disable SSL for downloads ssl._create_default_https_context = ssl._create_unverified_context # disable SSL for downloads
API_VERSION = "1"
def start_server(hostname=None): def start_server(hostname=None):
@@ -228,6 +231,7 @@ def status():
"system_os": current_system_os(), "system_os": current_system_os(),
"system_os_version": current_system_os_version(), "system_os_version": current_system_os_version(),
"system_cpu": current_system_cpu(), "system_cpu": current_system_cpu(),
"system_cpu_brand": cpuinfo.get_cpu_info()['brand_raw'],
"cpu_percent": psutil.cpu_percent(percpu=False), "cpu_percent": psutil.cpu_percent(percpu=False),
"cpu_percent_per_cpu": psutil.cpu_percent(percpu=True), "cpu_percent_per_cpu": psutil.cpu_percent(percpu=True),
"cpu_count": psutil.cpu_count(logical=False), "cpu_count": psutil.cpu_count(logical=False),
@@ -236,7 +240,9 @@ def status():
"memory_percent": psutil.virtual_memory().percent, "memory_percent": psutil.virtual_memory().percent,
"job_counts": RenderQueue.job_counts(), "job_counts": RenderQueue.job_counts(),
"hostname": server.config['HOSTNAME'], "hostname": server.config['HOSTNAME'],
"port": server.config['PORT'] "port": server.config['PORT'],
"app_version": APP_VERSION,
"api_version": API_VERSION
} }

View File

@@ -17,7 +17,7 @@ class PreviewManager:
_running_jobs = {} _running_jobs = {}
@classmethod @classmethod
def __generate_job_preview_worker(cls, job, replace_existing=False, max_width=320): def __generate_job_preview_worker(cls, job, replace_existing=False, max_width=480):
# Determine best source file to use for thumbs # Determine best source file to use for thumbs
job_file_list = job.file_list() job_file_list = job.file_list()

View File

@@ -46,6 +46,7 @@ class RenderServerProxy:
self.system_cpu_count = None self.system_cpu_count = None
self.system_os = None self.system_os = None
self.system_os_version = None self.system_os_version = None
self.system_api_version = None
# -------------------------------------------- # --------------------------------------------
# Basics / Connection: # Basics / Connection:
@@ -100,8 +101,10 @@ class RenderServerProxy:
return None return None
def request(self, payload, timeout=5): def request(self, payload, timeout=5):
from src.api.api_server import API_VERSION
hostname = LOOPBACK if self.is_localhost else self.hostname hostname = LOOPBACK if self.is_localhost else self.hostname
return requests.get(f'http://{hostname}:{self.port}/api/{payload}', timeout=timeout) return requests.get(f'http://{hostname}:{self.port}/api/{payload}', timeout=timeout,
headers={"X-API-Version": str(API_VERSION)})
# -------------------------------------------- # --------------------------------------------
# Background Updates: # Background Updates:
@@ -162,6 +165,7 @@ class RenderServerProxy:
self.system_cpu_count = status['cpu_count'] self.system_cpu_count = status['cpu_count']
self.system_os = status['system_os'] self.system_os = status['system_os']
self.system_os_version = status['system_os_version'] self.system_os_version = status['system_os_version']
self.system_api_version = status['api_version']
return status return status
# -------------------------------------------- # --------------------------------------------

View File

@@ -374,13 +374,15 @@ class DistributedJobManager:
:param system_os: str, Restrict results to servers running a specific OS :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 :return: A list of dictionaries with each dict containing hostname and cpu_count of available servers
""" """
from api.api_server import API_VERSION
available_servers = [] available_servers = []
for hostname in ZeroconfServer.found_hostnames(): for hostname in ZeroconfServer.found_hostnames():
host_properties = ZeroconfServer.get_hostname_properties(hostname) host_properties = ZeroconfServer.get_hostname_properties(hostname)
if not system_os or (system_os and system_os == host_properties.get('system_os')): if host_properties.get('api_version') == API_VERSION:
response = RenderServerProxy(hostname).is_engine_available(engine_name) if not system_os or (system_os and system_os == host_properties.get('system_os')):
if response and response.get('available', False): response = RenderServerProxy(hostname).is_engine_available(engine_name)
available_servers.append(response) if response and response.get('available', False):
available_servers.append(response)
return available_servers return available_servers

View File

@@ -31,6 +31,12 @@ class BlenderRenderWorker(BaseRenderWorker):
cmd.append('-b') cmd.append('-b')
cmd.append(self.input_path) cmd.append(self.input_path)
# Set Render Engine
blender_engine = self.args.get('engine')
if blender_engine:
blender_engine = blender_engine.upper()
cmd.extend(['-E', blender_engine])
# Start Python expressions - # todo: investigate splitting into separate 'setup' script # Start Python expressions - # todo: investigate splitting into separate 'setup' script
cmd.append('--python-expr') cmd.append('--python-expr')
python_exp = 'import bpy; bpy.context.scene.render.use_overwrite = False;' python_exp = 'import bpy; bpy.context.scene.render.use_overwrite = False;'
@@ -40,8 +46,7 @@ class BlenderRenderWorker(BaseRenderWorker):
if custom_camera: if custom_camera:
python_exp = python_exp + f"bpy.context.scene.camera = bpy.data.objects['{custom_camera}'];" python_exp = python_exp + f"bpy.context.scene.camera = bpy.data.objects['{custom_camera}'];"
# Set Render Device (gpu/cpu/any) # Set Render Device for Cycles (gpu/cpu/any)
blender_engine = self.args.get('engine', 'BLENDER_EEVEE').upper()
if blender_engine == 'CYCLES': if blender_engine == 'CYCLES':
render_device = self.args.get('render_device', 'any').lower() render_device = self.args.get('render_device', 'any').lower()
if render_device not in {'any', 'gpu', 'cpu'}: if render_device not in {'any', 'gpu', 'cpu'}:
@@ -66,7 +71,7 @@ class BlenderRenderWorker(BaseRenderWorker):
# Remove the extension only if it is not composed entirely of digits # Remove the extension only if it is not composed entirely of digits
path_without_ext = main_part if not ext[1:].isdigit() else self.output_path path_without_ext = main_part if not ext[1:].isdigit() else self.output_path
path_without_ext += "_" path_without_ext += "_"
cmd.extend(['-E', blender_engine, '-o', path_without_ext, '-F', export_format]) cmd.extend(['-o', path_without_ext, '-F', export_format])
# set frame range # set frame range
cmd.extend(['-s', self.start_frame, '-e', self.end_frame, '-a']) cmd.extend(['-s', self.start_frame, '-e', self.end_frame, '-a'])

View File

@@ -305,18 +305,21 @@ class EngineDownloadWorker(threading.Thread):
self.cpu = cpu self.cpu = cpu
def run(self): def run(self):
existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu) try:
if existing_download: existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu)
logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists") if existing_download:
return 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 # Get the appropriate downloader class based on the engine type
EngineManager.engine_with_name(self.engine).downloader().download_engine( EngineManager.engine_with_name(self.engine).downloader().download_engine(
self.version, download_location=EngineManager.engines_path, system_os=self.system_os, cpu=self.cpu, self.version, download_location=EngineManager.engines_path, system_os=self.system_os, cpu=self.cpu,
timeout=300) timeout=300)
except Exception as e:
# remove itself from the downloader list logger.error(f"Error in download worker: {e}")
EngineManager.download_tasks.remove(self) finally:
# remove itself from the downloader list
EngineManager.download_tasks.remove(self)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -6,6 +6,9 @@ import sys
import threading import threading
from collections import deque from collections import deque
import cpuinfo
from api.api_server import API_VERSION
from src.api.api_server import start_server from src.api.api_server import start_server
from src.api.preview_manager import PreviewManager from src.api.preview_manager import PreviewManager
from src.api.serverproxy_manager import ServerProxyManager from src.api.serverproxy_manager import ServerProxyManager
@@ -16,7 +19,7 @@ from src.utilities.config import Config
from src.utilities.misc_helper import (system_safe_path, current_system_cpu, current_system_os, from src.utilities.misc_helper import (system_safe_path, current_system_cpu, current_system_os,
current_system_os_version, check_for_updates) current_system_os_version, check_for_updates)
from src.utilities.zeroconf_server import ZeroconfServer from src.utilities.zeroconf_server import ZeroconfServer
from version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER from src.version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER
logger = logging.getLogger() logger = logging.getLogger()
@@ -108,9 +111,11 @@ def run(server_only=False) -> int:
# start zeroconf server # start zeroconf server
ZeroconfServer.configure(f"_{APP_NAME.lower()}._tcp.local.", local_hostname, Config.port_number) ZeroconfServer.configure(f"_{APP_NAME.lower()}._tcp.local.", local_hostname, Config.port_number)
ZeroconfServer.properties = {'system_cpu': current_system_cpu(), ZeroconfServer.properties = {'system_cpu': current_system_cpu(),
'system_cpu_brand': cpuinfo.get_cpu_info()['brand_raw'],
'system_cpu_cores': multiprocessing.cpu_count(), 'system_cpu_cores': multiprocessing.cpu_count(),
'system_os': current_system_os(), 'system_os': current_system_os(),
'system_os_version': current_system_os_version()} 'system_os_version': current_system_os_version(),
'api_version': API_VERSION}
ZeroconfServer.start() ZeroconfServer.start()
logger.info(f"{APP_NAME} Render Server started - Hostname: {local_hostname}") logger.info(f"{APP_NAME} Render Server started - Hostname: {local_hostname}")
RenderQueue.start() # Start evaluating the render queue RenderQueue.start() # Start evaluating the render queue
@@ -175,6 +180,8 @@ def __show_gui(buffer_handler):
# load application # load application
app: QApplication = QApplication(sys.argv) app: QApplication = QApplication(sys.argv)
if app.style().objectName() != 'macos':
app.setStyle('Fusion')
# configure main window # configure main window
from src.ui.main_window import MainWindow from src.ui.main_window import MainWindow

View File

@@ -5,7 +5,7 @@ from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QHBoxLayout from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QHBoxLayout
from version import * from src.version import *
class AboutDialog(QDialog): class AboutDialog(QDialog):

View File

@@ -3,7 +3,6 @@ import datetime
import io import io
import logging import logging
import os import os
import subprocess
import sys import sys
import threading import threading
import time import time
@@ -16,6 +15,7 @@ from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QListWidget, QTab
QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem, \ QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem, \
QFileDialog QFileDialog
from api.api_server import API_VERSION
from src.render_queue import RenderQueue from src.render_queue import RenderQueue
from src.utilities.misc_helper import get_time_elapsed, resources_dir, is_localhost from src.utilities.misc_helper import get_time_elapsed, resources_dir, is_localhost
from src.utilities.status_utils import RenderStatus from src.utilities.status_utils import RenderStatus
@@ -29,7 +29,8 @@ from src.ui.widgets.proportional_image_label import ProportionalImageLabel
from src.ui.widgets.statusbar import StatusBar from src.ui.widgets.statusbar import StatusBar
from src.ui.widgets.toolbar import ToolBar from src.ui.widgets.toolbar import ToolBar
from src.api.serverproxy_manager import ServerProxyManager from src.api.serverproxy_manager import ServerProxyManager
from src.utilities.misc_helper import launch_url from src.utilities.misc_helper import launch_url, iso_datestring_to_formatted_datestring
from src.version import APP_NAME
logger = logging.getLogger() logger = logging.getLogger()
@@ -63,7 +64,7 @@ class MainWindow(QMainWindow):
self.buffer_handler = None self.buffer_handler = None
# Window-Settings # Window-Settings
self.setWindowTitle("Zordon") self.setWindowTitle(APP_NAME)
self.setGeometry(100, 100, 900, 800) self.setGeometry(100, 100, 900, 800)
central_widget = QWidget(self) central_widget = QWidget(self)
self.setCentralWidget(central_widget) self.setCentralWidget(central_widget)
@@ -242,7 +243,7 @@ class MainWindow(QMainWindow):
# Use the get method with defaults to avoid KeyError # 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', '')}" 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" cpu_info = f"CPU: {server_info.get('system_cpu_brand', 'Unknown')} ({server_info.get('system_cpu_cores', 'Unknown')} cores)"
self.server_info_os.setText(os_info.strip()) self.server_info_os.setText(os_info.strip())
self.server_info_cpu.setText(cpu_info) self.server_info_cpu.setText(cpu_info)
@@ -256,7 +257,7 @@ class MainWindow(QMainWindow):
self.job_list_view.clear() self.job_list_view.clear()
self.refresh_job_headers() self.refresh_job_headers()
job_fetch = self.current_server_proxy.get_all_jobs(ignore_token=clear_table) job_fetch = self.current_server_proxy.get_all_jobs(ignore_token=False)
if job_fetch: if job_fetch:
num_jobs = len(job_fetch) num_jobs = len(job_fetch)
self.job_list_view.setRowCount(num_jobs) self.job_list_view.setRowCount(num_jobs)
@@ -276,10 +277,11 @@ class MainWindow(QMainWindow):
renderer = f"{job.get('renderer', '')}-{job.get('renderer_version')}" renderer = f"{job.get('renderer', '')}-{job.get('renderer_version')}"
priority = str(job.get('priority', '')) priority = str(job.get('priority', ''))
total_frames = str(job.get('total_frames', '')) total_frames = str(job.get('total_frames', ''))
date_created_string = iso_datestring_to_formatted_datestring(job['date_created'])
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(renderer), items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(renderer),
QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed), QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed),
QTableWidgetItem(total_frames), QTableWidgetItem(job['date_created'])] QTableWidgetItem(total_frames), QTableWidgetItem(date_created_string)]
for col, item in enumerate(items): for col, item in enumerate(items):
self.job_list_view.setItem(row, col, item) self.job_list_view.setItem(row, col, item)
@@ -410,6 +412,8 @@ class MainWindow(QMainWindow):
def update_servers(self): def update_servers(self):
found_servers = list(set(ZeroconfServer.found_hostnames() + self.added_hostnames)) found_servers = list(set(ZeroconfServer.found_hostnames() + self.added_hostnames))
found_servers = [x for x in found_servers if ZeroconfServer.get_hostname_properties(x)['api_version'] == API_VERSION]
# Always make sure local hostname is first # Always make sure local hostname is first
if found_servers and not is_localhost(found_servers[0]): if found_servers and not is_localhost(found_servers[0]):
for hostname in found_servers: for hostname in found_servers:
@@ -591,3 +595,21 @@ class MainWindow(QMainWindow):
if file_name: if file_name:
self.new_job_window = NewRenderJobForm(file_name) self.new_job_window = NewRenderJobForm(file_name)
self.new_job_window.show() self.new_job_window.show()
if __name__ == "__main__":
# lazy load GUI frameworks
from PyQt6.QtWidgets import QApplication
# load application
# QtCore.QCoreApplication.setAttribute(QtCore.Qt.ApplicationAttribute.AA_MacDontSwapCtrlAndMeta)
app: QApplication = QApplication(sys.argv)
# configure main main_window
main_window = MainWindow()
# main_window.buffer_handler = buffer_handler
app.setActiveWindow(main_window)
main_window.show()
sys.exit(app.exec())

View File

@@ -60,7 +60,7 @@ class MenuBar(QMenuBar):
@staticmethod @staticmethod
def check_for_updates(): def check_for_updates():
from src.utilities.misc_helper import check_for_updates from src.utilities.misc_helper import check_for_updates
from version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER from src.version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER
found_update = check_for_updates(APP_REPO_NAME, APP_REPO_OWNER, APP_NAME, APP_VERSION) found_update = check_for_updates(APP_REPO_NAME, APP_REPO_OWNER, APP_NAME, APP_VERSION)
if found_update: if found_update:
dialog = UpdateDialog(found_update, APP_VERSION) dialog = UpdateDialog(found_update, APP_VERSION)

View File

@@ -210,3 +210,18 @@ def num_to_alphanumeric(num):
result += characters[remainder] result += characters[remainder]
return result[::-1] # Reverse the result to get the correct alphanumeric string return result[::-1] # Reverse the result to get the correct alphanumeric string
def iso_datestring_to_formatted_datestring(iso_date_string):
from dateutil import parser
import pytz
# Parse the ISO date string into a datetime object and convert timezones
date = parser.isoparse(iso_date_string).astimezone(pytz.UTC)
local_timezone = datetime.now().astimezone().tzinfo
date_local = date.astimezone(local_timezone)
# Format the date to the desired readable yet sortable format with 12-hour time
formatted_date = date_local.strftime('%Y-%m-%d %I:%M %p')
return formatted_date

View File

@@ -32,9 +32,11 @@ class ZeroconfServer:
def start(cls, listen_only=False): def start(cls, listen_only=False):
if not cls.service_type: if not cls.service_type:
raise RuntimeError("The 'configure' method must be run before starting the zeroconf server") raise RuntimeError("The 'configure' method must be run before starting the zeroconf server")
logger.debug("Starting zeroconf service") elif not listen_only:
if not listen_only: logger.debug(f"Starting zeroconf service")
cls._register_service() cls._register_service()
else:
logger.debug(f"Starting zeroconf service - Listen only mode")
cls._browse_services() cls._browse_services()
@classmethod @classmethod