mirror of
https://github.com/blw1138/Zordon.git
synced 2025-12-17 16:58:12 +00:00
Compare commits
17 Commits
feature/do
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05cd0470dd | ||
|
|
7827f73530 | ||
| 562cb23da3 | |||
|
|
6b68d42b93 | ||
|
|
cdf4b2bbe1 | ||
|
|
dc8f4d3e2a | ||
| 2548280dcc | |||
|
|
98ab837057 | ||
|
|
3fda87935e | ||
|
|
e35a5a689c | ||
| dea7574888 | |||
| a19db9fcf7 | |||
| 80b0adb2ad | |||
| 18873cec6f | |||
| af6d6e1525 | |||
| 8bbf19cb30 | |||
| 6bdb488ce1 |
38
.github/workflows/create-executables.yml
vendored
Normal file
38
.github/workflows/create-executables.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Create Executables
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
- types: [created]
|
||||
|
||||
jobs:
|
||||
pyinstaller-build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Create Executables (Windows)
|
||||
uses: sayyid5416/pyinstaller@v1
|
||||
with:
|
||||
python_ver: '3.11'
|
||||
spec: 'main.spec'
|
||||
requirements: 'requirements.txt'
|
||||
upload_exe_with_name: 'Zordon'
|
||||
pyinstaller-build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create Executables (Linux)
|
||||
uses: sayyid5416/pyinstaller@v1
|
||||
with:
|
||||
python_ver: '3.11'
|
||||
spec: 'main.spec'
|
||||
requirements: 'requirements.txt'
|
||||
upload_exe_with_name: 'Zordon'
|
||||
pyinstaller-build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Create Executables (macOS)
|
||||
uses: sayyid5416/pyinstaller@v1
|
||||
with:
|
||||
python_ver: '3.11'
|
||||
spec: 'main.spec'
|
||||
requirements: 'requirements.txt'
|
||||
upload_exe_with_name: 'Zordon'
|
||||
23
.github/workflows/pylint.yml
vendored
23
.github/workflows/pylint.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Pylint
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pylint
|
||||
- name: Analysing the code with pylint
|
||||
run: |
|
||||
pylint $(git ls-files '*.py')
|
||||
13
README.md
13
README.md
@@ -1,6 +1,17 @@
|
||||

|
||||
|
||||
---
|
||||
|
||||
# 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!
|
||||
|
||||
## Supported Renderers
|
||||
|
||||
|
||||
BIN
docs/screenshot.png
Normal file
BIN
docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 838 KiB |
14
main.spec
14
main.spec
@@ -5,8 +5,10 @@ from PyInstaller.utils.hooks import collect_all
|
||||
import os
|
||||
import sys
|
||||
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
|
||||
sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
datas = [('resources', 'resources'), ('src/engines/blender/scripts/', 'src/engines/blender/scripts')]
|
||||
binaries = []
|
||||
@@ -26,7 +28,7 @@ a = Analysis(
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
optimize=1, # fyi: optim level 2 breaks on windows
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
@@ -40,7 +42,7 @@ if platform.system() == 'Darwin': # macOS
|
||||
name=APP_NAME,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
strip=True,
|
||||
upx=True,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
@@ -55,7 +57,7 @@ if platform.system() == 'Darwin': # macOS
|
||||
a.datas,
|
||||
strip=True,
|
||||
name=f'{APP_NAME}.app',
|
||||
icon=None,
|
||||
icon='resources/Server.png',
|
||||
bundle_identifier=None,
|
||||
version=APP_VERSION
|
||||
)
|
||||
@@ -87,7 +89,7 @@ elif platform.system() == 'Windows':
|
||||
name=APP_NAME,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
strip=True,
|
||||
upx=True,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
@@ -108,7 +110,7 @@ else: # linux
|
||||
name=APP_NAME,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
strip=True,
|
||||
upx=True,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
|
||||
@@ -35,3 +35,7 @@ attrs>=23.2.0
|
||||
lxml>=5.1.0
|
||||
click>=8.1.7
|
||||
requests_toolbelt>=1.0.0
|
||||
pyinstaller_versionfile>=2.1.1
|
||||
py-cpuinfo~=9.0.0
|
||||
requests-toolbelt~=1.0.0
|
||||
ifaddr~=0.2.0
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
from init import run
|
||||
from src.init import run
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(server_only=True)
|
||||
|
||||
@@ -11,6 +11,7 @@ import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import cpuinfo
|
||||
import psutil
|
||||
import yaml
|
||||
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, \
|
||||
current_system_os_version, num_to_alphanumeric
|
||||
from src.utilities.status_utils import string_to_status
|
||||
from src.version import APP_VERSION
|
||||
|
||||
logger = logging.getLogger()
|
||||
server = Flask(__name__)
|
||||
ssl._create_default_https_context = ssl._create_unverified_context # disable SSL for downloads
|
||||
|
||||
API_VERSION = "1"
|
||||
|
||||
def start_server(hostname=None):
|
||||
|
||||
@@ -50,8 +53,11 @@ def start_server(hostname=None):
|
||||
flask_log.setLevel(Config.flask_log_level.upper())
|
||||
|
||||
logger.debug('Starting API server')
|
||||
server.run(host='0.0.0.0', port=server.config['PORT'], debug=Config.flask_debug_enable, use_reloader=False,
|
||||
threaded=True)
|
||||
try:
|
||||
server.run(host=hostname, port=server.config['PORT'], debug=Config.flask_debug_enable, use_reloader=False,
|
||||
threaded=True)
|
||||
finally:
|
||||
logger.debug('Stopping API server')
|
||||
|
||||
|
||||
# --------------------------------------------
|
||||
@@ -225,6 +231,7 @@ def status():
|
||||
"system_os": current_system_os(),
|
||||
"system_os_version": current_system_os_version(),
|
||||
"system_cpu": current_system_cpu(),
|
||||
"system_cpu_brand": cpuinfo.get_cpu_info()['brand_raw'],
|
||||
"cpu_percent": psutil.cpu_percent(percpu=False),
|
||||
"cpu_percent_per_cpu": psutil.cpu_percent(percpu=True),
|
||||
"cpu_count": psutil.cpu_count(logical=False),
|
||||
@@ -233,7 +240,9 @@ def status():
|
||||
"memory_percent": psutil.virtual_memory().percent,
|
||||
"job_counts": RenderQueue.job_counts(),
|
||||
"hostname": server.config['HOSTNAME'],
|
||||
"port": server.config['PORT']
|
||||
"port": server.config['PORT'],
|
||||
"app_version": APP_VERSION,
|
||||
"api_version": API_VERSION
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class PreviewManager:
|
||||
_running_jobs = {}
|
||||
|
||||
@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
|
||||
job_file_list = job.file_list()
|
||||
|
||||
@@ -46,6 +46,7 @@ class RenderServerProxy:
|
||||
self.system_cpu_count = None
|
||||
self.system_os = None
|
||||
self.system_os_version = None
|
||||
self.system_api_version = None
|
||||
|
||||
# --------------------------------------------
|
||||
# Basics / Connection:
|
||||
@@ -100,8 +101,10 @@ class RenderServerProxy:
|
||||
return None
|
||||
|
||||
def request(self, payload, timeout=5):
|
||||
from src.api.api_server import API_VERSION
|
||||
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:
|
||||
@@ -162,6 +165,7 @@ class RenderServerProxy:
|
||||
self.system_cpu_count = status['cpu_count']
|
||||
self.system_os = status['system_os']
|
||||
self.system_os_version = status['system_os_version']
|
||||
self.system_api_version = status['api_version']
|
||||
return status
|
||||
|
||||
# --------------------------------------------
|
||||
|
||||
@@ -374,13 +374,15 @@ class DistributedJobManager:
|
||||
: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
|
||||
"""
|
||||
from api.api_server import API_VERSION
|
||||
available_servers = []
|
||||
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)
|
||||
if host_properties.get('api_version') == API_VERSION:
|
||||
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
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ class BlenderRenderWorker(BaseRenderWorker):
|
||||
cmd.append('-b')
|
||||
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
|
||||
cmd.append('--python-expr')
|
||||
python_exp = 'import bpy; bpy.context.scene.render.use_overwrite = False;'
|
||||
@@ -40,8 +46,7 @@ class BlenderRenderWorker(BaseRenderWorker):
|
||||
if custom_camera:
|
||||
python_exp = python_exp + f"bpy.context.scene.camera = bpy.data.objects['{custom_camera}'];"
|
||||
|
||||
# Set Render Device (gpu/cpu/any)
|
||||
blender_engine = self.args.get('engine', 'BLENDER_EEVEE').upper()
|
||||
# Set Render Device for Cycles (gpu/cpu/any)
|
||||
if blender_engine == 'CYCLES':
|
||||
render_device = self.args.get('render_device', 'any').lower()
|
||||
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
|
||||
path_without_ext = main_part if not ext[1:].isdigit() else self.output_path
|
||||
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
|
||||
cmd.extend(['-s', self.start_frame, '-e', self.end_frame, '-a'])
|
||||
|
||||
@@ -305,18 +305,21 @@ class EngineDownloadWorker(threading.Thread):
|
||||
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
|
||||
try:
|
||||
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)
|
||||
# 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)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download worker: {e}")
|
||||
finally:
|
||||
# remove itself from the downloader list
|
||||
EngineManager.download_tasks.remove(self)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
50
src/init.py
50
src/init.py
@@ -6,6 +6,9 @@ import sys
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
import cpuinfo
|
||||
|
||||
from api.api_server import API_VERSION
|
||||
from src.api.api_server import start_server
|
||||
from src.api.preview_manager import PreviewManager
|
||||
from src.api.serverproxy_manager import ServerProxyManager
|
||||
@@ -13,8 +16,10 @@ from src.distributed_job_manager import DistributedJobManager
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.render_queue import RenderQueue
|
||||
from src.utilities.config import Config
|
||||
from src.utilities.misc_helper import system_safe_path, current_system_cpu, current_system_os, current_system_os_version
|
||||
from src.utilities.misc_helper import (system_safe_path, current_system_cpu, current_system_os,
|
||||
current_system_os_version, check_for_updates)
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
from src.version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -29,16 +34,43 @@ def run(server_only=False) -> int:
|
||||
int: The exit status code.
|
||||
"""
|
||||
|
||||
def existing_process(process_name):
|
||||
import psutil
|
||||
current_pid = os.getpid()
|
||||
current_process = psutil.Process(current_pid)
|
||||
for proc in psutil.process_iter(['pid', 'name', 'ppid']):
|
||||
proc_name = proc.info['name'].lower().rstrip('.exe')
|
||||
if proc_name == process_name.lower() and proc.info['pid'] != current_pid:
|
||||
if proc.info['pid'] == current_process.ppid():
|
||||
continue # parent process
|
||||
elif proc.info['ppid'] == current_pid:
|
||||
continue # child process
|
||||
else:
|
||||
return proc # unrelated process
|
||||
return None
|
||||
|
||||
# setup logging
|
||||
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
|
||||
level=Config.server_log_level.upper())
|
||||
logging.getLogger("requests").setLevel(logging.WARNING) # suppress noisy requests/urllib3 logging
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
|
||||
# check for existing instance
|
||||
existing_proc = existing_process(APP_NAME)
|
||||
if existing_proc:
|
||||
logger.fatal(f"Another instance of {APP_NAME} is already running (pid: {existing_proc.pid})")
|
||||
sys.exit(1)
|
||||
|
||||
# Setup logging for console ui
|
||||
buffer_handler = __setup_buffer_handler() if not server_only else None
|
||||
|
||||
logger.info(f"Starting Zordon Render Server")
|
||||
# check for updates
|
||||
update_thread = threading.Thread(target=check_for_updates, args=(APP_REPO_NAME, APP_REPO_OWNER, APP_NAME,
|
||||
APP_VERSION))
|
||||
update_thread.start()
|
||||
|
||||
# main start
|
||||
logger.info(f"Starting {APP_NAME} Render Server")
|
||||
return_code = 0
|
||||
try:
|
||||
# Load Config YAML
|
||||
@@ -77,13 +109,15 @@ def run(server_only=False) -> int:
|
||||
api_server.start()
|
||||
|
||||
# start zeroconf server
|
||||
ZeroconfServer.configure("_zordon._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(),
|
||||
'system_cpu_brand': cpuinfo.get_cpu_info()['brand_raw'],
|
||||
'system_cpu_cores': multiprocessing.cpu_count(),
|
||||
'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()
|
||||
logger.info(f"Zordon Render Server started - Hostname: {local_hostname}")
|
||||
logger.info(f"{APP_NAME} Render Server started - Hostname: {local_hostname}")
|
||||
RenderQueue.start() # Start evaluating the render queue
|
||||
|
||||
# start in gui or server only (cli) mode
|
||||
@@ -100,13 +134,13 @@ def run(server_only=False) -> int:
|
||||
return_code = 1
|
||||
finally:
|
||||
# shut down gracefully
|
||||
logger.info(f"Zordon Render Server is preparing to shut down")
|
||||
logger.info(f"{APP_NAME} Render Server is preparing to shut down")
|
||||
try:
|
||||
RenderQueue.prepare_for_shutdown()
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception during prepare for shutdown: {e}")
|
||||
ZeroconfServer.stop()
|
||||
logger.info(f"Zordon Render Server has shut down")
|
||||
logger.info(f"{APP_NAME} Render Server has shut down")
|
||||
return sys.exit(return_code)
|
||||
|
||||
|
||||
@@ -146,6 +180,8 @@ def __show_gui(buffer_handler):
|
||||
|
||||
# load application
|
||||
app: QApplication = QApplication(sys.argv)
|
||||
if app.style().objectName() != 'macos':
|
||||
app.setStyle('Fusion')
|
||||
|
||||
# configure main window
|
||||
from src.ui.main_window import MainWindow
|
||||
|
||||
@@ -5,7 +5,7 @@ from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QPixmap
|
||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QHBoxLayout
|
||||
|
||||
from version import *
|
||||
from src.version import *
|
||||
|
||||
|
||||
class AboutDialog(QDialog):
|
||||
|
||||
@@ -3,7 +3,6 @@ import datetime
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -16,6 +15,7 @@ from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QListWidget, QTab
|
||||
QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem, \
|
||||
QFileDialog
|
||||
|
||||
from api.api_server import API_VERSION
|
||||
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
|
||||
@@ -29,7 +29,8 @@ from src.ui.widgets.proportional_image_label import ProportionalImageLabel
|
||||
from src.ui.widgets.statusbar import StatusBar
|
||||
from src.ui.widgets.toolbar import ToolBar
|
||||
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()
|
||||
|
||||
@@ -63,7 +64,7 @@ class MainWindow(QMainWindow):
|
||||
self.buffer_handler = None
|
||||
|
||||
# Window-Settings
|
||||
self.setWindowTitle("Zordon")
|
||||
self.setWindowTitle(APP_NAME)
|
||||
self.setGeometry(100, 100, 900, 800)
|
||||
central_widget = QWidget(self)
|
||||
self.setCentralWidget(central_widget)
|
||||
@@ -242,7 +243,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# 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"
|
||||
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_cpu.setText(cpu_info)
|
||||
@@ -256,7 +257,7 @@ class MainWindow(QMainWindow):
|
||||
self.job_list_view.clear()
|
||||
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:
|
||||
num_jobs = len(job_fetch)
|
||||
self.job_list_view.setRowCount(num_jobs)
|
||||
@@ -276,10 +277,11 @@ class MainWindow(QMainWindow):
|
||||
renderer = f"{job.get('renderer', '')}-{job.get('renderer_version')}"
|
||||
priority = str(job.get('priority', ''))
|
||||
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),
|
||||
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):
|
||||
self.job_list_view.setItem(row, col, item)
|
||||
@@ -410,6 +412,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def update_servers(self):
|
||||
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
|
||||
if found_servers and not is_localhost(found_servers[0]):
|
||||
for hostname in found_servers:
|
||||
@@ -591,3 +595,21 @@ class MainWindow(QMainWindow):
|
||||
if file_name:
|
||||
self.new_job_window = NewRenderJobForm(file_name)
|
||||
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())
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
''' app/ui/widgets/menubar.py '''
|
||||
import sys
|
||||
|
||||
from PyQt6.QtGui import QAction
|
||||
from PyQt6.QtWidgets import QMenuBar, QApplication
|
||||
from PyQt6.QtWidgets import QMenuBar, QApplication, QMessageBox, QDialog, QVBoxLayout, QLabel, QPushButton
|
||||
|
||||
|
||||
class MenuBar(QMenuBar):
|
||||
@@ -43,6 +41,9 @@ class MenuBar(QMenuBar):
|
||||
about_action = QAction("About", self)
|
||||
about_action.triggered.connect(self.show_about)
|
||||
help_menu.addAction(about_action)
|
||||
update_action = QAction("Check for Updates...", self)
|
||||
update_action.triggered.connect(self.check_for_updates)
|
||||
help_menu.addAction(update_action)
|
||||
|
||||
def new_job(self):
|
||||
self.parent().new_job()
|
||||
@@ -55,3 +56,46 @@ class MenuBar(QMenuBar):
|
||||
from src.ui.about_window import AboutDialog
|
||||
dialog = AboutDialog()
|
||||
dialog.exec()
|
||||
|
||||
@staticmethod
|
||||
def check_for_updates():
|
||||
from src.utilities.misc_helper import check_for_updates
|
||||
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)
|
||||
if found_update:
|
||||
dialog = UpdateDialog(found_update, APP_VERSION)
|
||||
dialog.exec()
|
||||
else:
|
||||
QMessageBox.information(None, "No Update", "No updates available.")
|
||||
|
||||
|
||||
class UpdateDialog(QDialog):
|
||||
def __init__(self, release_info, current_version, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"Update Available ({current_version} -> {release_info['tag_name']})")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
label = QLabel(f"A new version ({release_info['tag_name']}) is available! Current version: {current_version}")
|
||||
layout.addWidget(label)
|
||||
|
||||
# Label to show the release notes
|
||||
description = QLabel(release_info["body"])
|
||||
layout.addWidget(description)
|
||||
|
||||
# Button to download the latest version
|
||||
download_button = QPushButton(f"Download Latest Version ({release_info['tag_name']})")
|
||||
download_button.clicked.connect(lambda: self.open_url(release_info["html_url"]))
|
||||
layout.addWidget(download_button)
|
||||
|
||||
# OK button to dismiss the dialog
|
||||
ok_button = QPushButton("Dismiss")
|
||||
ok_button.clicked.connect(self.accept) # Close the dialog when clicked
|
||||
layout.addWidget(ok_button)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def open_url(self, url):
|
||||
from PyQt6.QtCore import QUrl
|
||||
from PyQt6.QtGui import QDesktopServices
|
||||
QDesktopServices.openUrl(QUrl(url))
|
||||
self.accept()
|
||||
|
||||
@@ -159,6 +159,33 @@ def copy_directory_contents(src_dir, dst_dir):
|
||||
shutil.copy2(src_path, dst_path)
|
||||
|
||||
|
||||
def check_for_updates(repo_name, repo_owner, app_name, current_version):
|
||||
def get_github_releases(owner, repo):
|
||||
import requests
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/releases"
|
||||
try:
|
||||
response = requests.get(url, timeout=3)
|
||||
response.raise_for_status()
|
||||
releases = response.json()
|
||||
return releases
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking for updates: {e}")
|
||||
return []
|
||||
|
||||
releases = get_github_releases(repo_owner, repo_name)
|
||||
if not releases:
|
||||
return
|
||||
|
||||
latest_version = releases[0]
|
||||
latest_version_tag = latest_version['tag_name']
|
||||
|
||||
from packaging import version
|
||||
if version.parse(latest_version_tag) > version.parse(current_version):
|
||||
logger.info(f"Newer version of {app_name} available. "
|
||||
f"Latest: {latest_version_tag}, Current: {current_version}")
|
||||
return latest_version
|
||||
|
||||
|
||||
def is_localhost(comparison_hostname):
|
||||
# this is necessary because socket.gethostname() does not always include '.local' - This is a sanitized comparison
|
||||
try:
|
||||
@@ -183,3 +210,18 @@ def num_to_alphanumeric(num):
|
||||
result += characters[remainder]
|
||||
|
||||
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
|
||||
|
||||
@@ -32,9 +32,11 @@ class ZeroconfServer:
|
||||
def start(cls, listen_only=False):
|
||||
if not cls.service_type:
|
||||
raise RuntimeError("The 'configure' method must be run before starting the zeroconf server")
|
||||
logger.debug("Starting zeroconf service")
|
||||
if not listen_only:
|
||||
elif not listen_only:
|
||||
logger.debug(f"Starting zeroconf service")
|
||||
cls._register_service()
|
||||
else:
|
||||
logger.debug(f"Starting zeroconf service - Listen only mode")
|
||||
cls._browse_services()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -4,3 +4,5 @@ APP_AUTHOR = "Brett Williams"
|
||||
APP_DESCRIPTION = "Distributed Render Farm Tools"
|
||||
APP_COPYRIGHT_YEAR = "2024"
|
||||
APP_LICENSE = "MIT License"
|
||||
APP_REPO_NAME = APP_NAME
|
||||
APP_REPO_OWNER = "blw1138"
|
||||
Reference in New Issue
Block a user