Compare commits
9 Commits
feature/84
...
windows_pa
| Author | SHA1 | Date | |
|---|---|---|---|
| e767ce8dd9 | |||
| 1bbf11a938 | |||
|
|
858f931f9b | ||
| 2be2eee157 | |||
| 671e2e3f32 | |||
| c1eeabad78 | |||
| aa484f21a4 | |||
| a220858dec | |||
| 9733e185a6 |
2
.github/workflows/pylint.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
||||
6
.gitignore
vendored
@@ -1,8 +1,8 @@
|
||||
/job_history.json
|
||||
*.icloud
|
||||
*.fcpxml
|
||||
/uploads
|
||||
*.pyc
|
||||
/server_state.json
|
||||
/.scheduler_prefs
|
||||
*.db
|
||||
/dist/
|
||||
/build/
|
||||
/.github/
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[MASTER]
|
||||
max-line-length = 120
|
||||
[MESSAGES CONTROL]
|
||||
disable = missing-docstring, invalid-name, import-error, logging-fstring-interpolation
|
||||
21
LICENSE.txt
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Brett Williams
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
15
README.md
@@ -1,10 +1,19 @@
|
||||
# 🎬 Zordon - Render Management Tools
|
||||
# 🎬 Zordon - Render Management Tools 🎬
|
||||
|
||||
Welcome to Zordon! It's a local network render farm manager, aiming to streamline and simplify the rendering process across multiple home computers.
|
||||
Welcome to Zordon! This is a hobby project written with fellow filmmakers in mind. It's a local network render farm manager, aiming to streamline and simplify the rendering process across multiple home computers.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
Install the necessary dependencies: `pip3 install -r requirements.txt`
|
||||
Make sure to install the necessary dependencies: `pip3 install -r requirements.txt`
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
Zordon has two main files: `start_server.py` and `start_client.py`.
|
||||
|
||||
- **start_server.py**: Run this on any computer you want to render jobs. It manages the incoming job queue and kicks off the appropriate render jobs when ready.
|
||||
- **start_client.py**: Run this to administer your render servers. It lets you manage and submit jobs.
|
||||
|
||||
When the server is running, the job queue can be accessed via a web browser on the server's hostname (default port is 8080). You can also access it via the GUI client or a simple view-only dashboard.
|
||||
|
||||
## 🎨 Supported Renderers
|
||||
|
||||
|
||||
@@ -1,12 +1,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
|
||||
port_number: 8080
|
||||
enable_split_jobs: true
|
||||
download_timeout_seconds: 120
|
||||
enable_split_jobs: true
|
||||
280
dashboard.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
import datetime
|
||||
import os.path
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import requests
|
||||
from rich import box
|
||||
from rich.console import Console
|
||||
from rich.layout import Layout
|
||||
from rich.live import Live
|
||||
from rich.panel import Panel
|
||||
from rich.table import Column
|
||||
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.utilities.misc_helper import get_time_elapsed
|
||||
from start_server import start_server
|
||||
|
||||
"""
|
||||
The RenderDashboard is designed to be run on a remote machine or on the local server
|
||||
This provides a detailed status of all jobs running on the server
|
||||
"""
|
||||
|
||||
status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green',
|
||||
RenderStatus.NOT_STARTED: "yellow", RenderStatus.SCHEDULED: 'purple',
|
||||
RenderStatus.RUNNING: 'cyan'}
|
||||
|
||||
categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED,
|
||||
RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.UNDEFINED]
|
||||
|
||||
renderer_colors = {'ffmpeg': '[magenta]', 'blender': '[orange1]', 'aerender': '[purple]'}
|
||||
|
||||
local_hostname = socket.gethostname()
|
||||
|
||||
|
||||
def status_string_to_color(status_string):
|
||||
job_status = string_to_status(status_string)
|
||||
job_color = '[{}]'.format(status_colors[job_status])
|
||||
return job_color
|
||||
|
||||
|
||||
def sorted_jobs(all_jobs):
|
||||
|
||||
sort_by_date = True
|
||||
if not sort_by_date:
|
||||
sorted_job_list = []
|
||||
if all_jobs:
|
||||
for status_category in categories:
|
||||
found_jobs = [x for x in all_jobs if x['status'] == status_category.value]
|
||||
if found_jobs:
|
||||
sorted_found_jobs = sorted(found_jobs, key=lambda d: datetime.datetime.fromisoformat(d['date_created']), reverse=True)
|
||||
sorted_job_list.extend(sorted_found_jobs)
|
||||
else:
|
||||
sorted_job_list = sorted(all_jobs, key=lambda d: datetime.datetime.fromisoformat(d['date_created']), reverse=True)
|
||||
return sorted_job_list
|
||||
|
||||
|
||||
def create_node_tree(all_server_data) -> Tree:
|
||||
main_tree = Tree("[magenta]Server Cluster")
|
||||
|
||||
for server_host, server_data in all_server_data['servers'].items():
|
||||
|
||||
node_title_local = f"[cyan bold]{server_host}[/] [yellow](This Computer)[default]"
|
||||
node_title_remote = f"[cyan]{server_host} [magenta](Remote)[default]"
|
||||
node_tree_text = node_title_local if (server_host == local_hostname) else node_title_remote
|
||||
|
||||
if server_data.get('is_online', False):
|
||||
|
||||
node_tree_text = node_tree_text + " - [green]Running"
|
||||
node_tree = Tree(node_tree_text)
|
||||
|
||||
stats_text = f"CPU: [yellow]{server_data['status']['cpu_percent']}% [default]| RAM: " \
|
||||
f"[yellow]{server_data['status']['memory_percent']}% [default]| Cores: " \
|
||||
f"[yellow]{server_data['status']['cpu_count']} [default]| " \
|
||||
f"{server_data['status']['platform'].split('-')[0]}"
|
||||
|
||||
node_tree.add(Tree(stats_text))
|
||||
|
||||
running_jobs = [job for job in server_data['jobs'] if job['status'] == RenderStatus.RUNNING.value]
|
||||
not_started = [job for job in server_data['jobs'] if job['status'] == RenderStatus.NOT_STARTED.value]
|
||||
scheduled = [job for job in server_data['jobs'] if job['status'] == RenderStatus.SCHEDULED.value]
|
||||
jobs_to_display = running_jobs + not_started + scheduled
|
||||
|
||||
jobs_tree = Tree(f"Running: [green]{len(running_jobs)} [default]| Queued: [cyan]{len(not_started)}"
|
||||
f"[default] | Scheduled: [cyan]{len(scheduled)}")
|
||||
|
||||
for job in jobs_to_display:
|
||||
renderer = f"{renderer_colors[job['renderer']]}{job['renderer']}[default]"
|
||||
filename = os.path.basename(job['input_path']).split('.')[0]
|
||||
if job['status'] == RenderStatus.RUNNING.value:
|
||||
jobs_tree.add(f"[bold]{renderer} {filename} ({job['id']}) - {status_string_to_color(job['status'])}{(float(job['percent_complete']) * 100):.1f}%")
|
||||
else:
|
||||
jobs_tree.add(f"{filename} ({job['id']}) - {status_string_to_color(job['status'])}{job['status'].title()}")
|
||||
|
||||
if not jobs_to_display:
|
||||
jobs_tree.add("[italic]No running jobs")
|
||||
|
||||
node_tree.add(jobs_tree)
|
||||
main_tree.add(node_tree)
|
||||
else:
|
||||
# if server is offline
|
||||
node_tree_text = node_tree_text + " - [red]Offline"
|
||||
node_tree = Tree(node_tree_text)
|
||||
main_tree.add(node_tree)
|
||||
return main_tree
|
||||
|
||||
|
||||
def create_jobs_table(all_server_data) -> Table:
|
||||
table = Table("ID", "Name", "Renderer", Column(header="Priority", justify="center"),
|
||||
Column(header="Status", justify="center"), Column(header="Time Elapsed", justify="right"),
|
||||
Column(header="# Frames", justify="right"), "Client", show_lines=True,
|
||||
box=box.HEAVY_HEAD)
|
||||
|
||||
all_jobs = []
|
||||
for server_name, server_data in all_server_data['servers'].items():
|
||||
for job in server_data['jobs']:
|
||||
#todo: clean this up
|
||||
all_jobs.append(job)
|
||||
|
||||
all_jobs = sorted_jobs(all_jobs)
|
||||
|
||||
for job in all_jobs:
|
||||
|
||||
job_status = string_to_status(job['status'])
|
||||
job_color = '[{}]'.format(status_colors[job_status])
|
||||
job_text = f"{job_color}" + job_status.value.title()
|
||||
|
||||
if job_status == RenderStatus.ERROR and job['errors']:
|
||||
job_text = job_text + "\n" + "\n".join(job['errors'])
|
||||
|
||||
# Project name
|
||||
project_name = job_color + (job['name'] or os.path.basename(job['input_path']))
|
||||
elapsed_time = get_time_elapsed(datetime.datetime.fromisoformat(job['start_time']),
|
||||
datetime.datetime.fromisoformat(job['end_time']))
|
||||
|
||||
if job_status == RenderStatus.RUNNING:
|
||||
job_text = f"{job_color}[bold]Running - {float(job['percent_complete']) * 100:.1f}%"
|
||||
elapsed_time = "[bold]" + elapsed_time
|
||||
project_name = "[bold]" + project_name
|
||||
elif job_status == RenderStatus.CANCELLED or job_status == RenderStatus.ERROR:
|
||||
project_name = "[strike]" + project_name
|
||||
|
||||
# Priority
|
||||
priority_color = ["red", "yellow", "cyan"][(job['priority'] - 1)]
|
||||
|
||||
client_name = job['client'] or 'unknown'
|
||||
client_colors = {'unknown': '[red]', local_hostname: '[yellow]'}
|
||||
client_title = client_colors.get(client_name, '[magenta]') + client_name
|
||||
|
||||
table.add_row(
|
||||
job['id'],
|
||||
project_name,
|
||||
renderer_colors.get(job['renderer'], '[cyan]') + job['renderer'] + '[default]-' + job['renderer_version'],
|
||||
f"[{priority_color}]{job['priority']}",
|
||||
job_text,
|
||||
elapsed_time,
|
||||
str(max(int(job['total_frames']), 1)),
|
||||
client_title
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def create_status_panel(all_server_data):
|
||||
for key, value in all_server_data['servers'].items():
|
||||
if key == local_hostname:
|
||||
return str(value['status'])
|
||||
return "no status"
|
||||
|
||||
|
||||
class KeyboardThread(threading.Thread):
|
||||
|
||||
def __init__(self, input_cbk = None, name='keyboard-input-thread'):
|
||||
self.input_cbk = input_cbk
|
||||
super(KeyboardThread, self).__init__(name=name)
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
self.input_cbk(input()) #waits to get input + Return
|
||||
|
||||
|
||||
def my_callback(inp):
|
||||
#evaluate the keyboard input
|
||||
print('You Entered:', inp)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
get_server_ip = input("Enter server IP or None for local: ") or local_hostname
|
||||
|
||||
server_proxy = RenderServerProxy(get_server_ip, "8080")
|
||||
|
||||
if not server_proxy.connect():
|
||||
if server_proxy.hostname == local_hostname:
|
||||
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)
|
||||
test = server_proxy.connect()
|
||||
print(f"connected? {test}")
|
||||
else:
|
||||
print(f"\nUnable to connect to server: {server_proxy.hostname}")
|
||||
print("\nVerify IP address is correct and server is running")
|
||||
exit(1)
|
||||
|
||||
# start the Keyboard thread
|
||||
# kthread = KeyboardThread(my_callback)
|
||||
|
||||
# Console Layout
|
||||
console = Console()
|
||||
layout = Layout()
|
||||
|
||||
# Divide the "screen" in to three parts
|
||||
layout.split(
|
||||
Layout(name="header", size=3),
|
||||
Layout(ratio=1, name="main")
|
||||
# Layout(size=10, name="footer"),
|
||||
)
|
||||
# Divide the "main" layout in to "side" and "body"
|
||||
layout["main"].split_row(
|
||||
Layout(name="side"),
|
||||
Layout(name="body",
|
||||
ratio=3))
|
||||
# Divide the "side" layout in to two
|
||||
layout["side"].split(Layout(name="side_top"), Layout(name="side_bottom"))
|
||||
|
||||
# Server connection header
|
||||
header_text = Text(f"Connected to server: ")
|
||||
header_text.append(f"{server_proxy.hostname} ", style="green")
|
||||
if server_proxy.hostname == local_hostname:
|
||||
header_text.append("(This Computer)", style="yellow")
|
||||
else:
|
||||
header_text.append("(Remote)", style="magenta")
|
||||
|
||||
# background process to update server data independent of the UI
|
||||
def fetch_server_data(server):
|
||||
while True:
|
||||
fetched_data = server.get_data(timeout=5)
|
||||
if fetched_data:
|
||||
server.fetched_status_data = fetched_data
|
||||
time.sleep(1)
|
||||
|
||||
x = threading.Thread(target=fetch_server_data, args=(server_proxy,))
|
||||
x.daemon = True
|
||||
x.start()
|
||||
|
||||
# draw and update the UI
|
||||
with Live(console=console, screen=False, refresh_per_second=1, transient=True) as live:
|
||||
while True:
|
||||
try:
|
||||
if server_proxy.fetched_status_data:
|
||||
|
||||
server_online = False
|
||||
if server_proxy.fetched_status_data.get('timestamp', None):
|
||||
timestamp = datetime.datetime.fromisoformat(server_proxy.fetched_status_data['timestamp'])
|
||||
time_diff = datetime.datetime.now() - timestamp
|
||||
server_online = time_diff.seconds < 10 # client is offline if not updated in certain time
|
||||
|
||||
layout["body"].update(create_jobs_table(server_proxy.fetched_status_data))
|
||||
layout["side_top"].update(Panel(create_node_tree(server_proxy.fetched_status_data)))
|
||||
layout["side_bottom"].update(Panel(create_status_panel(server_proxy.fetched_status_data)))
|
||||
|
||||
online_text = "Online" if server_online else "Offline"
|
||||
online_color = "green" if server_online else "red"
|
||||
layout["header"].update(Panel(Text(f"Zordon Render Client - Version 0.0.1 alpha - {online_text}",
|
||||
justify="center", style=online_color)))
|
||||
live.update(layout, refresh=False)
|
||||
except Exception as e:
|
||||
print(f"Exception updating table: {e}")
|
||||
traceback.print_exception(e)
|
||||
time.sleep(1)
|
||||
# # # todo: Add input prompt to manage running jobs (ie add, cancel, get info, etc)
|
||||
|
||||
4
lib/engines/ffmpeg_engine.py
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(FFMPEG.get_frame_count('/Users/brett/Desktop/Big_Fire_02.mov'))
|
||||
7
main.py
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from src import init
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
sys.exit(init.run())
|
||||
@@ -1,37 +1,15 @@
|
||||
PyQt6>=6.6.1
|
||||
psutil>=5.9.8
|
||||
requests>=2.31.0
|
||||
Pillow>=10.2.0
|
||||
PyYAML>=6.0.1
|
||||
flask>=3.0.2
|
||||
tqdm>=4.66.2
|
||||
werkzeug>=3.0.1
|
||||
Pypubsub>=4.0.3
|
||||
zeroconf>=0.131.0
|
||||
SQLAlchemy>=2.0.25
|
||||
plyer>=2.1.0
|
||||
pytz>=2023.3.post1
|
||||
future>=0.18.3
|
||||
rich>=13.7.0
|
||||
pytest>=8.0.0
|
||||
numpy>=1.26.3
|
||||
setuptools>=69.0.3
|
||||
pandas>=2.2.0
|
||||
matplotlib>=3.8.2
|
||||
MarkupSafe>=2.1.4
|
||||
dmglib>=0.9.5; sys_platform == 'darwin'
|
||||
python-dateutil>=2.8.2
|
||||
certifi>=2023.11.17
|
||||
shiboken6>=6.6.1
|
||||
Pygments>=2.17.2
|
||||
cycler>=0.12.1
|
||||
contourpy>=1.2.0
|
||||
packaging>=23.2
|
||||
fonttools>=4.47.2
|
||||
Jinja2>=3.1.3
|
||||
pyparsing>=3.1.1
|
||||
kiwisolver>=1.4.5
|
||||
attrs>=23.2.0
|
||||
lxml>=5.1.0
|
||||
click>=8.1.7
|
||||
requests_toolbelt>=1.0.0
|
||||
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
|
||||
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
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 921 B |
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 694 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 816 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 806 B |
28
setup.py
@@ -1,28 +0,0 @@
|
||||
"""
|
||||
This is a setup.py script generated by py2applet
|
||||
|
||||
Usage:
|
||||
python setup.py py2app
|
||||
"""
|
||||
import glob
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
APP = ['main.py']
|
||||
DATA_FILES = [('config', glob.glob('config/*.*')),
|
||||
('resources', glob.glob('resources/*.*'))]
|
||||
OPTIONS = {
|
||||
'excludes': ['PySide6'],
|
||||
'includes': ['zeroconf', 'zeroconf._services.info'],
|
||||
'plist': {
|
||||
'LSMinimumSystemVersion': '10.15', # Specify minimum macOS version
|
||||
},
|
||||
}
|
||||
|
||||
setup(
|
||||
app=APP,
|
||||
data_files=DATA_FILES,
|
||||
options={'py2app': OPTIONS},
|
||||
setup_requires=['py2app'],
|
||||
name='Zordon'
|
||||
)
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def handle_uploaded_project_files(request, jobs_list, upload_directory):
|
||||
"""
|
||||
Handles the uploaded project files.
|
||||
|
||||
This method takes a request with a file, a list of jobs, and an upload directory. It checks if the file was uploaded
|
||||
directly, if it needs to be downloaded from a URL, or if it's already present on the local file system. It then
|
||||
moves the file to the appropriate directory and returns the local path to the file and its name.
|
||||
|
||||
Args:
|
||||
request (Request): The request object containing the file.
|
||||
jobs_list (list): A list of jobs. The first job in the list is used to get the file's URL and local path.
|
||||
upload_directory (str): The directory where the file should be uploaded.
|
||||
|
||||
Raises:
|
||||
ValueError: If no valid project paths are found.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing the local path to the loaded project file and its name.
|
||||
"""
|
||||
# Initialize default values
|
||||
loaded_project_local_path = None
|
||||
|
||||
uploaded_project = request.files.get('file', None)
|
||||
project_url = jobs_list[0].get('url', None)
|
||||
local_path = jobs_list[0].get('local_path', None)
|
||||
renderer = jobs_list[0].get('renderer')
|
||||
downloaded_file_url = None
|
||||
|
||||
if uploaded_project and uploaded_project.filename:
|
||||
referred_name = os.path.basename(uploaded_project.filename)
|
||||
elif project_url:
|
||||
referred_name, downloaded_file_url = download_project_from_url(project_url)
|
||||
if not referred_name:
|
||||
raise ValueError(f"Error downloading file from URL: {project_url}")
|
||||
elif local_path and os.path.exists(local_path):
|
||||
referred_name = os.path.basename(local_path)
|
||||
|
||||
else:
|
||||
raise ValueError("Cannot find any valid project paths")
|
||||
|
||||
# Prepare the local filepath
|
||||
cleaned_path_name = os.path.splitext(referred_name)[0].replace(' ', '_')
|
||||
job_dir = os.path.join(upload_directory, '-'.join(
|
||||
[datetime.now().strftime("%Y.%m.%d_%H.%M.%S"), renderer, cleaned_path_name]))
|
||||
os.makedirs(job_dir, exist_ok=True)
|
||||
project_source_dir = os.path.join(job_dir, 'source')
|
||||
os.makedirs(project_source_dir, exist_ok=True)
|
||||
|
||||
# Move projects to their work directories
|
||||
if uploaded_project and uploaded_project.filename:
|
||||
loaded_project_local_path = os.path.join(project_source_dir, secure_filename(uploaded_project.filename))
|
||||
uploaded_project.save(loaded_project_local_path)
|
||||
logger.info(f"Transfer complete for {loaded_project_local_path.split(upload_directory)[-1]}")
|
||||
elif project_url:
|
||||
loaded_project_local_path = os.path.join(project_source_dir, referred_name)
|
||||
shutil.move(downloaded_file_url, loaded_project_local_path)
|
||||
logger.info(f"Download complete for {loaded_project_local_path.split(upload_directory)[-1]}")
|
||||
elif local_path:
|
||||
loaded_project_local_path = os.path.join(project_source_dir, referred_name)
|
||||
shutil.copy(local_path, loaded_project_local_path)
|
||||
logger.info(f"Import complete for {loaded_project_local_path.split(upload_directory)[-1]}")
|
||||
|
||||
return loaded_project_local_path, referred_name
|
||||
|
||||
|
||||
def download_project_from_url(project_url):
|
||||
# This nested function is to handle downloading from a URL
|
||||
logger.info(f"Downloading project from url: {project_url}")
|
||||
referred_name = os.path.basename(project_url)
|
||||
|
||||
try:
|
||||
response = requests.get(project_url, stream=True)
|
||||
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
|
||||
downloaded_file_url = os.path.join(tempfile.gettempdir(), referred_name)
|
||||
with open(downloaded_file_url, "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()
|
||||
return referred_name, downloaded_file_url
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading file: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def process_zipped_project(zip_path):
|
||||
"""
|
||||
Processes a zipped project.
|
||||
|
||||
This method takes a path to a zip file, extracts its contents, and returns the path to the extracted project file.
|
||||
If the zip file contains more than one project file or none, an error is raised.
|
||||
|
||||
Args:
|
||||
zip_path (str): The path to the zip file.
|
||||
|
||||
Raises:
|
||||
ValueError: If there's more than 1 project file or none in the zip file.
|
||||
|
||||
Returns:
|
||||
str: The path to the main project file.
|
||||
"""
|
||||
work_path = os.path.dirname(zip_path)
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, 'r') as myzip:
|
||||
myzip.extractall(work_path)
|
||||
|
||||
project_files = [x for x in os.listdir(work_path) if os.path.isfile(os.path.join(work_path, x))]
|
||||
project_files = [x for x in project_files if '.zip' not in x]
|
||||
|
||||
logger.debug(f"Zip files: {project_files}")
|
||||
|
||||
# supported_exts = RenderWorkerFactory.class_for_name(renderer).engine.supported_extensions
|
||||
# if supported_exts:
|
||||
# project_files = [file for file in project_files if any(file.endswith(ext) for ext in supported_exts)]
|
||||
|
||||
# If there's more than 1 project file or none, raise an error
|
||||
if len(project_files) != 1:
|
||||
raise ValueError(f'Cannot find a valid project file in {os.path.basename(zip_path)}')
|
||||
|
||||
extracted_project_path = os.path.join(work_path, project_files[0])
|
||||
logger.info(f"Extracted zip file to {extracted_project_path}")
|
||||
|
||||
except (zipfile.BadZipFile, zipfile.LargeZipFile) as e:
|
||||
logger.error(f"Error processing zip file: {e}")
|
||||
raise ValueError(f"Error processing zip file: {e}")
|
||||
return extracted_project_path
|
||||
@@ -1,549 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from zipfile import ZipFile
|
||||
|
||||
import psutil
|
||||
import yaml
|
||||
from flask import Flask, request, send_file, after_this_request, Response, redirect, url_for, abort
|
||||
|
||||
from src.api.add_job_helpers import handle_uploaded_project_files, process_zipped_project
|
||||
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.engine_manager import EngineManager
|
||||
from src.render_queue import RenderQueue, JobNotFoundError
|
||||
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.server_helper import generate_thumbnail_for_job
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
from src.utilities.benchmark import cpu_benchmark, disk_io_benchmark
|
||||
|
||||
logger = logging.getLogger()
|
||||
server = Flask(__name__)
|
||||
ssl._create_default_https_context = ssl._create_unverified_context # disable SSL for downloads
|
||||
|
||||
categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED,
|
||||
RenderStatus.COMPLETED, RenderStatus.CANCELLED]
|
||||
|
||||
|
||||
def sorted_jobs(all_jobs, sort_by_date=True):
|
||||
if not sort_by_date:
|
||||
sorted_job_list = []
|
||||
if all_jobs:
|
||||
for status_category in categories:
|
||||
found_jobs = [x for x in all_jobs if x.status == status_category.value]
|
||||
if found_jobs:
|
||||
sorted_found_jobs = sorted(found_jobs, key=lambda d: d.date_created, reverse=True)
|
||||
sorted_job_list.extend(sorted_found_jobs)
|
||||
else:
|
||||
sorted_job_list = sorted(all_jobs, key=lambda d: d.date_created, reverse=True)
|
||||
return sorted_job_list
|
||||
|
||||
|
||||
@server.get('/api/jobs')
|
||||
def jobs_json():
|
||||
try:
|
||||
all_jobs = [x.json() for x in RenderQueue.all_jobs()]
|
||||
job_cache_int = int(json.dumps(all_jobs).__hash__())
|
||||
job_cache_token = num_to_alphanumeric(job_cache_int)
|
||||
return {'jobs': all_jobs, 'token': job_cache_token}
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception fetching jobs_json: {e}")
|
||||
return {}, 500
|
||||
|
||||
|
||||
@server.get('/api/jobs_long_poll')
|
||||
def long_polling_jobs():
|
||||
try:
|
||||
hash_token = request.args.get('token', None)
|
||||
start_time = time.time()
|
||||
while True:
|
||||
all_jobs = jobs_json()
|
||||
if all_jobs['token'] != hash_token:
|
||||
return all_jobs
|
||||
# Break after 30 seconds to avoid gateway timeout
|
||||
if time.time() - start_time > 30:
|
||||
return {}, 204
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception fetching long_polling_jobs: {e}")
|
||||
return {}, 500
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/thumbnail')
|
||||
def job_thumbnail(job_id):
|
||||
big_thumb = request.args.get('size', False) == "big"
|
||||
video_ok = request.args.get('video_ok', False)
|
||||
found_job = RenderQueue.job_with_id(job_id, none_ok=True)
|
||||
if found_job:
|
||||
|
||||
os.makedirs(server.config['THUMBS_FOLDER'], exist_ok=True)
|
||||
thumb_video_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.mp4')
|
||||
thumb_image_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.jpg')
|
||||
big_video_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '_big.mp4')
|
||||
big_image_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '_big.jpg')
|
||||
|
||||
# generate regular thumb if it doesn't exist
|
||||
if not os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS') and \
|
||||
found_job.status not in [RenderStatus.CANCELLED, RenderStatus.ERROR]:
|
||||
generate_thumbnail_for_job(found_job, thumb_video_path, thumb_image_path, max_width=240)
|
||||
|
||||
# generate big thumb if it doesn't exist
|
||||
if not os.path.exists(big_video_path) and not os.path.exists(big_image_path + '_IN-PROGRESS') and \
|
||||
found_job.status not in [RenderStatus.CANCELLED, RenderStatus.ERROR]:
|
||||
generate_thumbnail_for_job(found_job, big_video_path, big_image_path, max_width=800)
|
||||
|
||||
# generated videos
|
||||
if video_ok:
|
||||
if big_thumb and os.path.exists(big_video_path) and not os.path.exists(
|
||||
big_video_path + '_IN-PROGRESS'):
|
||||
return send_file(big_video_path, mimetype="video/mp4")
|
||||
elif os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS'):
|
||||
return send_file(thumb_video_path, mimetype="video/mp4")
|
||||
|
||||
# Generated thumbs
|
||||
if big_thumb and os.path.exists(big_image_path):
|
||||
return send_file(big_image_path, mimetype='image/jpeg')
|
||||
elif os.path.exists(thumb_image_path):
|
||||
return send_file(thumb_image_path, mimetype='image/jpeg')
|
||||
|
||||
return found_job.status.value, 200
|
||||
return found_job.status.value, 404
|
||||
|
||||
|
||||
# Get job file routing
|
||||
@server.route('/api/job/<job_id>/file/<filename>', methods=['GET'])
|
||||
def get_job_file(job_id, filename):
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
try:
|
||||
for full_path in found_job.file_list():
|
||||
if filename in full_path:
|
||||
return send_file(path_or_file=full_path)
|
||||
except FileNotFoundError:
|
||||
abort(404)
|
||||
|
||||
|
||||
@server.get('/api/jobs/<status_val>')
|
||||
def filtered_jobs_json(status_val):
|
||||
state = string_to_status(status_val)
|
||||
jobs = [x.json() for x in RenderQueue.jobs_with_status(state)]
|
||||
if jobs:
|
||||
return jobs
|
||||
else:
|
||||
return f'Cannot find jobs with status {status_val}', 400
|
||||
|
||||
|
||||
@server.post('/api/job/<job_id>/notify_parent_of_status_change')
|
||||
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(RenderQueue.job_with_id(job_id), subjob_data=subjob_details)
|
||||
return Response(status=200)
|
||||
except JobNotFoundError:
|
||||
return "Job not found", 404
|
||||
|
||||
|
||||
@server.errorhandler(JobNotFoundError)
|
||||
def handle_job_not_found(job_error):
|
||||
return f'Cannot find job with ID {job_error.job_id}', 400
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>')
|
||||
def get_job_status(job_id):
|
||||
return RenderQueue.job_with_id(job_id).json()
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/logs')
|
||||
def get_job_logs(job_id):
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
log_path = system_safe_path(found_job.log_path())
|
||||
log_data = None
|
||||
if log_path and os.path.exists(log_path):
|
||||
with open(log_path) as file:
|
||||
log_data = file.read()
|
||||
return Response(log_data, mimetype='text/plain')
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/file_list')
|
||||
def get_file_list(job_id):
|
||||
return RenderQueue.job_with_id(job_id).file_list()
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/download_all')
|
||||
def download_all(job_id):
|
||||
zip_filename = None
|
||||
|
||||
@after_this_request
|
||||
def clear_zip(response):
|
||||
if zip_filename and os.path.exists(zip_filename):
|
||||
try:
|
||||
os.remove(zip_filename)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error removing zip file '{zip_filename}': {e}")
|
||||
return response
|
||||
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
output_dir = os.path.dirname(found_job.output_path)
|
||||
if os.path.exists(output_dir):
|
||||
zip_filename = system_safe_path(os.path.join(tempfile.gettempdir(),
|
||||
pathlib.Path(found_job.input_path).stem + '.zip'))
|
||||
with ZipFile(zip_filename, 'w') as zipObj:
|
||||
for f in os.listdir(output_dir):
|
||||
zipObj.write(filename=system_safe_path(os.path.join(output_dir, f)),
|
||||
arcname=os.path.basename(f))
|
||||
return send_file(zip_filename, mimetype="zip", as_attachment=True, )
|
||||
else:
|
||||
return f'Cannot find project files for job {job_id}', 500
|
||||
|
||||
|
||||
@server.get('/api/presets')
|
||||
def presets():
|
||||
presets_path = system_safe_path('config/presets.yaml')
|
||||
with open(presets_path) as f:
|
||||
loaded_presets = yaml.load(f, Loader=yaml.FullLoader)
|
||||
return loaded_presets
|
||||
|
||||
|
||||
@server.get('/api/full_status')
|
||||
def full_status():
|
||||
full_results = {'timestamp': datetime.now().isoformat(), 'servers': {}}
|
||||
|
||||
try:
|
||||
snapshot_results = snapshot()
|
||||
server_data = {'status': snapshot_results.get('status', {}), 'jobs': snapshot_results.get('jobs', {}),
|
||||
'is_online': True}
|
||||
full_results['servers'][server.config['HOSTNAME']] = server_data
|
||||
except Exception as e:
|
||||
logger.error(f"Exception fetching full status: {e}")
|
||||
|
||||
return full_results
|
||||
|
||||
|
||||
@server.get('/api/snapshot')
|
||||
def snapshot():
|
||||
server_status = status()
|
||||
server_jobs = [x.json() for x in RenderQueue.all_jobs()]
|
||||
server_data = {'status': server_status, 'jobs': server_jobs, 'timestamp': datetime.now().isoformat()}
|
||||
return server_data
|
||||
|
||||
|
||||
@server.get('/api/_detected_clients')
|
||||
def detected_clients():
|
||||
# todo: dev/debug only. Should not ship this - probably.
|
||||
return ZeroconfServer.found_hostnames()
|
||||
|
||||
|
||||
# New version
|
||||
@server.post('/api/add_job')
|
||||
def add_job_handler():
|
||||
# Process request data
|
||||
try:
|
||||
if request.is_json:
|
||||
jobs_list = [request.json] if not isinstance(request.json, list) else request.json
|
||||
elif request.form.get('json', None):
|
||||
jobs_list = json.loads(request.form['json'])
|
||||
else:
|
||||
return "Invalid data", 400
|
||||
except Exception as e:
|
||||
err_msg = f"Error processing job data: {e}"
|
||||
logger.error(err_msg)
|
||||
return err_msg, 500
|
||||
|
||||
try:
|
||||
loaded_project_local_path, referred_name = handle_uploaded_project_files(request, jobs_list,
|
||||
server.config['UPLOAD_FOLDER'])
|
||||
if loaded_project_local_path.lower().endswith('.zip'):
|
||||
loaded_project_local_path = process_zipped_project(loaded_project_local_path)
|
||||
|
||||
results = []
|
||||
for new_job_data in jobs_list:
|
||||
new_job = DistributedJobManager.create_render_job(new_job_data, loaded_project_local_path)
|
||||
results.append(new_job.json())
|
||||
return results, 200
|
||||
except Exception as e:
|
||||
logger.exception(f"Error adding job: {e}")
|
||||
return 'unknown error', 500
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/cancel')
|
||||
def cancel_job(job_id):
|
||||
if not request.args.get('confirm', False):
|
||||
return 'Confirmation required to cancel job', 400
|
||||
|
||||
if RenderQueue.cancel_job(RenderQueue.job_with_id(job_id)):
|
||||
if request.args.get('redirect', False):
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
return "Job cancelled"
|
||||
else:
|
||||
return "Unknown error", 500
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/delete', methods=['POST', 'GET'])
|
||||
def delete_job(job_id):
|
||||
try:
|
||||
if not request.args.get('confirm', False):
|
||||
return 'Confirmation required to delete job', 400
|
||||
|
||||
# Check if we can remove the 'output' directory
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
output_dir = os.path.dirname(found_job.output_path)
|
||||
if server.config['UPLOAD_FOLDER'] in output_dir and os.path.exists(output_dir):
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
# Remove any thumbnails
|
||||
for filename in os.listdir(server.config['THUMBS_FOLDER']):
|
||||
if job_id in filename:
|
||||
os.remove(os.path.join(server.config['THUMBS_FOLDER'], filename))
|
||||
|
||||
thumb_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.mp4')
|
||||
if os.path.exists(thumb_path):
|
||||
os.remove(thumb_path)
|
||||
|
||||
# See if we own the project_dir (i.e. was it uploaded)
|
||||
project_dir = os.path.dirname(os.path.dirname(found_job.input_path))
|
||||
if server.config['UPLOAD_FOLDER'] in project_dir and os.path.exists(project_dir):
|
||||
# check to see if any other projects are sharing the same project file
|
||||
project_dir_files = [f for f in os.listdir(project_dir) if not f.startswith('.')]
|
||||
if len(project_dir_files) == 0 or (len(project_dir_files) == 1 and 'source' in project_dir_files[0]):
|
||||
logger.info(f"Removing project directory: {project_dir}")
|
||||
shutil.rmtree(project_dir)
|
||||
|
||||
RenderQueue.delete_job(found_job)
|
||||
if request.args.get('redirect', False):
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
return "Job deleted", 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting job: {e}")
|
||||
return f"Error deleting job: {e}", 500
|
||||
|
||||
|
||||
@server.get('/api/clear_history')
|
||||
def clear_history():
|
||||
RenderQueue.clear_history()
|
||||
return 'success'
|
||||
|
||||
|
||||
@server.route('/api/status')
|
||||
def status():
|
||||
|
||||
# Get system info
|
||||
return {"timestamp": datetime.now().isoformat(),
|
||||
"system_os": current_system_os(),
|
||||
"system_os_version": current_system_os_version(),
|
||||
"system_cpu": current_system_cpu(),
|
||||
"cpu_percent": psutil.cpu_percent(percpu=False),
|
||||
"cpu_percent_per_cpu": psutil.cpu_percent(percpu=True),
|
||||
"cpu_count": psutil.cpu_count(logical=False),
|
||||
"memory_total": psutil.virtual_memory().total,
|
||||
"memory_available": psutil.virtual_memory().available,
|
||||
"memory_percent": psutil.virtual_memory().percent,
|
||||
"job_counts": RenderQueue.job_counts(),
|
||||
"hostname": server.config['HOSTNAME'],
|
||||
"port": server.config['PORT']
|
||||
}
|
||||
|
||||
|
||||
@server.get('/api/renderer_info')
|
||||
def renderer_info():
|
||||
|
||||
response_type = request.args.get('response_type', 'standard')
|
||||
|
||||
def process_engine(engine):
|
||||
try:
|
||||
# Get all installed versions of the engine
|
||||
installed_versions = EngineManager.all_versions_for_engine(engine.name())
|
||||
if installed_versions:
|
||||
# Use system-installed versions to avoid permission 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']
|
||||
|
||||
en = engine(install_path)
|
||||
|
||||
if response_type == 'full': # Full dataset - Can be slow
|
||||
return {
|
||||
en.name(): {
|
||||
'is_available': RenderQueue.is_available_for_job(en.name()),
|
||||
'versions': installed_versions,
|
||||
'supported_extensions': engine.supported_extensions(),
|
||||
'supported_export_formats': en.get_output_formats(),
|
||||
'system_info': en.system_info()
|
||||
}
|
||||
}
|
||||
elif response_type == 'standard': # Simpler dataset to reduce response times
|
||||
return {
|
||||
en.name(): {
|
||||
'is_available': RenderQueue.is_available_for_job(en.name()),
|
||||
'versions': installed_versions,
|
||||
}
|
||||
}
|
||||
else:
|
||||
raise AttributeError(f"Invalid response_type: {response_type}")
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching details for {engine.name()} renderer: {e}')
|
||||
return {}
|
||||
|
||||
renderer_data = {}
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
futures = {executor.submit(process_engine, engine): engine.name() for engine in EngineManager.supported_engines()}
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
renderer_data.update(result)
|
||||
|
||||
return renderer_data
|
||||
|
||||
|
||||
@server.get('/api/<engine_name>/is_available')
|
||||
def is_engine_available(engine_name):
|
||||
return {'engine': engine_name, 'available': RenderQueue.is_available_for_job(engine_name),
|
||||
'cpu_count': int(psutil.cpu_count(logical=False)),
|
||||
'versions': EngineManager.all_versions_for_engine(engine_name),
|
||||
'hostname': server.config['HOSTNAME']}
|
||||
|
||||
|
||||
@server.get('/api/is_engine_available_to_download')
|
||||
def is_engine_available_to_download():
|
||||
available_result = EngineManager.version_is_available_to_download(request.args.get('engine'),
|
||||
request.args.get('version'),
|
||||
request.args.get('system_os'),
|
||||
request.args.get('cpu'))
|
||||
return available_result if available_result else \
|
||||
(f"Cannot find available download for {request.args.get('engine')} {request.args.get('version')}", 500)
|
||||
|
||||
|
||||
@server.get('/api/find_most_recent_version')
|
||||
def find_most_recent_version():
|
||||
most_recent = EngineManager.find_most_recent_version(request.args.get('engine'),
|
||||
request.args.get('system_os'),
|
||||
request.args.get('cpu'))
|
||||
return most_recent if most_recent else \
|
||||
(f"Error finding most recent version of {request.args.get('engine')}", 500)
|
||||
|
||||
|
||||
@server.post('/api/download_engine')
|
||||
def download_engine():
|
||||
download_result = EngineManager.download_engine(request.args.get('engine'),
|
||||
request.args.get('version'),
|
||||
request.args.get('system_os'),
|
||||
request.args.get('cpu'))
|
||||
return download_result if download_result else \
|
||||
(f"Error downloading {request.args.get('engine')} {request.args.get('version')}", 500)
|
||||
|
||||
|
||||
@server.post('/api/delete_engine')
|
||||
def delete_engine_download():
|
||||
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 {json_data.get('engine')} {json_data.get('version')}", 500)
|
||||
|
||||
|
||||
@server.get('/api/renderer/<renderer>/args')
|
||||
def get_renderer_args(renderer):
|
||||
try:
|
||||
# todo: possibly deprecate
|
||||
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.get('/api/cpu_benchmark')
|
||||
def get_cpu_benchmark_score():
|
||||
return str(cpu_benchmark(10))
|
||||
|
||||
|
||||
@server.get('/api/disk_benchmark')
|
||||
def get_disk_benchmark():
|
||||
results = disk_io_benchmark()
|
||||
return {'write_speed': results[0], 'read_speed': results[-1]}
|
||||
|
||||
|
||||
def start_server():
|
||||
def eval_loop(delay_sec=1):
|
||||
while True:
|
||||
RenderQueue.evaluate_queue()
|
||||
time.sleep(delay_sec)
|
||||
|
||||
# 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.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')))
|
||||
os.makedirs(EngineManager.engines_path, exist_ok=True)
|
||||
|
||||
# Debug info
|
||||
logger.debug(f"Upload directory: {server.config['UPLOAD_FOLDER']}")
|
||||
logger.debug(f"Thumbs directory: {server.config['THUMBS_FOLDER']}")
|
||||
logger.debug(f"Engines directory: {EngineManager.engines_path}")
|
||||
|
||||
# disable most Flask logging
|
||||
flask_log = logging.getLogger('werkzeug')
|
||||
flask_log.setLevel(Config.flask_log_level.upper())
|
||||
|
||||
# check for updates for render engines if configured or on first launch
|
||||
if Config.update_engines_on_launch or not EngineManager.get_engines():
|
||||
EngineManager.update_all_engines()
|
||||
|
||||
# Set up the RenderQueue object
|
||||
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.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:
|
||||
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()
|
||||
@@ -1,279 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from src.utilities.misc_helper import is_localhost
|
||||
from src.utilities.status_utils import RenderStatus
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
|
||||
status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green',
|
||||
RenderStatus.NOT_STARTED: "yellow", RenderStatus.SCHEDULED: 'purple',
|
||||
RenderStatus.RUNNING: 'cyan', RenderStatus.WAITING_FOR_SUBJOBS: 'blue'}
|
||||
|
||||
categories = [RenderStatus.RUNNING, RenderStatus.WAITING_FOR_SUBJOBS, RenderStatus.ERROR, RenderStatus.NOT_STARTED,
|
||||
RenderStatus.SCHEDULED, RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.UNDEFINED,
|
||||
RenderStatus.CONFIGURING]
|
||||
|
||||
logger = logging.getLogger()
|
||||
OFFLINE_MAX = 4
|
||||
LOOPBACK = '127.0.0.1'
|
||||
|
||||
|
||||
class RenderServerProxy:
|
||||
"""
|
||||
The ServerProxy class is responsible for interacting with a remote server.
|
||||
It provides methods to request data from the server and store the status of the server.
|
||||
|
||||
Attributes:
|
||||
system_cpu (str): The CPU type of the system.
|
||||
system_cpu_count (int): The number of CPUs in the system.
|
||||
system_os (str): The operating system of the system.
|
||||
system_os_version (str): The version of the operating system.
|
||||
"""
|
||||
|
||||
def __init__(self, hostname, server_port="8080"):
|
||||
self.hostname = hostname
|
||||
self.port = server_port
|
||||
self.fetched_status_data = None
|
||||
self.__jobs_cache_token = None
|
||||
self.__jobs_cache = []
|
||||
self.__update_in_background = False
|
||||
self.__background_thread = None
|
||||
self.__offline_flags = 0
|
||||
self.update_cadence = 5
|
||||
self.is_localhost = bool(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 __repr__(self):
|
||||
return f"<RenderServerProxy - {self.hostname}>"
|
||||
|
||||
def connect(self):
|
||||
return self.status()
|
||||
|
||||
def is_online(self):
|
||||
if self.__update_in_background:
|
||||
return self.__offline_flags < OFFLINE_MAX
|
||||
else:
|
||||
return self.get_status() is not None
|
||||
|
||||
def status(self):
|
||||
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 "Ready"
|
||||
|
||||
def request_data(self, payload, timeout=5):
|
||||
try:
|
||||
req = self.request(payload, timeout)
|
||||
if req.ok:
|
||||
self.__offline_flags = 0
|
||||
if req.status_code == 200:
|
||||
return req.json()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f"JSON decode error: {e}")
|
||||
except requests.ReadTimeout as e:
|
||||
logger.warning(f"Timed out: {e}")
|
||||
self.__offline_flags = self.__offline_flags + 1
|
||||
except requests.ConnectionError as e:
|
||||
logger.warning(f"Connection error: {e}")
|
||||
self.__offline_flags = self.__offline_flags + 1
|
||||
except Exception as e:
|
||||
logger.exception(f"Uncaught exception: {e}")
|
||||
|
||||
# If server unexpectedly drops off the network, stop background updates
|
||||
if self.__offline_flags > OFFLINE_MAX:
|
||||
try:
|
||||
self.stop_background_update()
|
||||
except KeyError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def request(self, payload, timeout=5):
|
||||
hostname = LOOPBACK if self.is_localhost else self.hostname
|
||||
return requests.get(f'http://{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():
|
||||
logger.debug(f'Starting background updates for {self.hostname}')
|
||||
while self.__update_in_background:
|
||||
self.__update_job_cache()
|
||||
time.sleep(self.update_cadence)
|
||||
logger.debug(f'Stopping background updates for {self.hostname}')
|
||||
|
||||
self.__background_thread = threading.Thread(target=thread_worker)
|
||||
self.__background_thread.daemon = True
|
||||
self.__background_thread.start()
|
||||
|
||||
def stop_background_update(self):
|
||||
self.__update_in_background = False
|
||||
|
||||
def get_job_info(self, job_id, timeout=5):
|
||||
return self.request_data(f'job/{job_id}', timeout=timeout)
|
||||
|
||||
def get_all_jobs(self, timeout=5, ignore_token=False):
|
||||
if not self.__update_in_background or ignore_token:
|
||||
self.__update_job_cache(timeout, ignore_token)
|
||||
return self.__jobs_cache.copy() if self.__jobs_cache else None
|
||||
|
||||
def __update_job_cache(self, timeout=40, ignore_token=False):
|
||||
|
||||
if self.__offline_flags: # if we're offline, don't bother with the long poll
|
||||
ignore_token = True
|
||||
|
||||
url = f'jobs_long_poll?token={self.__jobs_cache_token}' if (self.__jobs_cache_token and
|
||||
not ignore_token) else 'jobs'
|
||||
status_result = self.request_data(url, timeout=timeout)
|
||||
if status_result is not None:
|
||||
sorted_jobs = []
|
||||
for status_category in categories:
|
||||
found_jobs = [x for x in status_result['jobs'] if x['status'] == status_category.value]
|
||||
if found_jobs:
|
||||
sorted_jobs.extend(found_jobs)
|
||||
self.__jobs_cache = sorted_jobs
|
||||
self.__jobs_cache_token = status_result['token']
|
||||
|
||||
def get_data(self, timeout=5):
|
||||
return self.request_data('full_status', timeout=timeout)
|
||||
|
||||
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):
|
||||
status = self.request_data('status')
|
||||
if status and 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):
|
||||
"""
|
||||
Notifies the parent job of a status change in a subjob.
|
||||
|
||||
Args:
|
||||
parent_id (str): The ID of the parent job.
|
||||
subjob (Job): The subjob that has changed status.
|
||||
|
||||
Returns:
|
||||
Response: The response from the server.
|
||||
"""
|
||||
hostname = LOOPBACK if self.is_localhost else self.hostname
|
||||
return requests.post(f'http://{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):
|
||||
"""
|
||||
Posts a job to the server.
|
||||
|
||||
Args:
|
||||
file_path (str): The path to the file to upload.
|
||||
job_list (list): A list of jobs to post.
|
||||
callback (function, optional): A callback function to call during the upload. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Response: The response from the server.
|
||||
"""
|
||||
try:
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
# Bypass uploading file if posting to localhost
|
||||
if self.is_localhost:
|
||||
jobs_with_path = [{'local_path': file_path, **item} for item in job_list]
|
||||
job_data = json.dumps(jobs_with_path)
|
||||
url = urljoin(f'http://{LOOPBACK}:{self.port}', '/api/add_job')
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
return requests.post(url, data=job_data, headers=headers)
|
||||
|
||||
# Prepare the form data for remote host
|
||||
with open(file_path, 'rb') as file:
|
||||
encoder = MultipartEncoder({
|
||||
'file': (os.path.basename(file_path), file, 'application/octet-stream'),
|
||||
'json': (None, json.dumps(job_list), 'application/json'),
|
||||
})
|
||||
|
||||
# Create a monitor that will track the upload progress
|
||||
monitor = MultipartEncoderMonitor(encoder, callback) if callback else MultipartEncoderMonitor(encoder)
|
||||
headers = {'Content-Type': monitor.content_type}
|
||||
url = urljoin(f'http://{self.hostname}:{self.port}', '/api/add_job')
|
||||
|
||||
# Send the request with proper resource management
|
||||
with requests.post(url, data=monitor, headers=headers) as response:
|
||||
return response
|
||||
|
||||
except requests.ConnectionError as e:
|
||||
logger.error(f"Connection error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
|
||||
def get_job_files(self, job_id, save_path):
|
||||
hostname = LOOPBACK if self.is_localhost else self.hostname
|
||||
url = f"http://{hostname}:{self.port}/api/job/{job_id}/download_all"
|
||||
return self.download_file(url, filename=save_path)
|
||||
|
||||
@staticmethod
|
||||
def download_file(url, filename):
|
||||
with requests.get(url, stream=True) as r:
|
||||
r.raise_for_status()
|
||||
with open(filename, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
return filename
|
||||
|
||||
# --- Renderer --- #
|
||||
|
||||
def get_renderer_info(self, response_type='standard', timeout=5):
|
||||
"""
|
||||
Fetches renderer information from the server.
|
||||
|
||||
Args:
|
||||
response_type (str, optional): Returns standard or full version of renderer info
|
||||
timeout (int, optional): The number of seconds to wait for a response from the server. Defaults to 5.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the renderer information.
|
||||
"""
|
||||
all_data = self.request_data(f"renderer_info?response_type={response_type}", timeout=timeout)
|
||||
return all_data
|
||||
|
||||
def delete_engine(self, engine, version, system_cpu=None):
|
||||
"""
|
||||
Sends a request to the server to delete a specific engine.
|
||||
|
||||
Args:
|
||||
engine (str): The name of the engine to delete.
|
||||
version (str): The version of the engine to delete.
|
||||
system_cpu (str, optional): The system CPU type. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Response: The response from the server.
|
||||
"""
|
||||
form_data = {'engine': engine, 'version': version, 'system_cpu': system_cpu}
|
||||
hostname = LOOPBACK if self.is_localhost else self.hostname
|
||||
return requests.post(f'http://{hostname}:{self.port}/api/delete_engine', json=form_data)
|
||||
@@ -1,35 +0,0 @@
|
||||
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):
|
||||
if state_change == ServiceStateChange.Added or state_change == ServiceStateChange.Updated:
|
||||
cls.get_proxy_for_hostname(hostname)
|
||||
else:
|
||||
cls.get_proxy_for_hostname(hostname).stop_background_update()
|
||||
cls.server_proxys.pop(hostname)
|
||||
|
||||
@classmethod
|
||||
def get_proxy_for_hostname(cls, hostname):
|
||||
found_proxy = cls.server_proxys.get(hostname)
|
||||
if hostname and 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
|
||||
644
src/api_server.py
Executable file
@@ -0,0 +1,644 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from urllib.request import urlretrieve
|
||||
from zipfile import ZipFile
|
||||
|
||||
import json2html
|
||||
import psutil
|
||||
import yaml
|
||||
from flask import Flask, request, render_template, send_file, after_this_request, Response, redirect, url_for, abort
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from src.distributed_job_manager import DistributedJobManager
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.render_queue import RenderQueue, JobNotFoundError
|
||||
from src.server_proxy import RenderServerProxy
|
||||
from src.utilities.server_helper import generate_thumbnail_for_job
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
from src.utilities.misc_helper import system_safe_path
|
||||
from src.workers.worker_factory import RenderWorkerFactory
|
||||
from src.workers.base_worker import string_to_status, RenderStatus
|
||||
|
||||
logger = logging.getLogger()
|
||||
server = Flask(__name__, template_folder='web/templates', static_folder='web/static')
|
||||
ssl._create_default_https_context = ssl._create_unverified_context # disable SSL for downloads
|
||||
|
||||
categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED,
|
||||
RenderStatus.COMPLETED, RenderStatus.CANCELLED]
|
||||
|
||||
|
||||
def sorted_jobs(all_jobs, sort_by_date=True):
|
||||
if not sort_by_date:
|
||||
sorted_job_list = []
|
||||
if all_jobs:
|
||||
for status_category in categories:
|
||||
found_jobs = [x for x in all_jobs if x.status == status_category.value]
|
||||
if found_jobs:
|
||||
sorted_found_jobs = sorted(found_jobs, key=lambda d: d.date_created, reverse=True)
|
||||
sorted_job_list.extend(sorted_found_jobs)
|
||||
else:
|
||||
sorted_job_list = sorted(all_jobs, key=lambda d: d.date_created, reverse=True)
|
||||
return sorted_job_list
|
||||
|
||||
|
||||
@server.route('/')
|
||||
@server.route('/index')
|
||||
def index():
|
||||
with open(system_safe_path('config/presets.yaml')) as f:
|
||||
render_presets = yaml.load(f, Loader=yaml.FullLoader)
|
||||
|
||||
return render_template('index.html', all_jobs=sorted_jobs(RenderQueue.all_jobs()),
|
||||
hostname=server.config['HOSTNAME'], renderer_info=renderer_info(),
|
||||
render_clients=[server.config['HOSTNAME']], preset_list=render_presets)
|
||||
|
||||
|
||||
@server.get('/api/jobs')
|
||||
def jobs_json():
|
||||
try:
|
||||
hash_token = request.args.get('token', None)
|
||||
all_jobs = [x.json() for x in RenderQueue.all_jobs()]
|
||||
job_cache_token = str(json.dumps(all_jobs).__hash__())
|
||||
|
||||
if hash_token and hash_token == job_cache_token:
|
||||
return [], 204 # no need to update
|
||||
else:
|
||||
return {'jobs': all_jobs, 'token': job_cache_token}
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception fetching all_jobs_cached: {e}")
|
||||
return [], 500
|
||||
|
||||
|
||||
@server.route('/ui/job/<job_id>/full_details')
|
||||
def job_detail(job_id):
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
table_html = json2html.json2html.convert(json=found_job.json(),
|
||||
table_attributes='class="table is-narrow is-striped is-fullwidth"')
|
||||
media_url = None
|
||||
if found_job.file_list() and found_job.status == RenderStatus.COMPLETED:
|
||||
media_basename = os.path.basename(found_job.file_list()[0])
|
||||
media_url = f"/api/job/{job_id}/file/{media_basename}"
|
||||
return render_template('details.html', detail_table=table_html, media_url=media_url,
|
||||
hostname=server.config['HOSTNAME'], job_status=found_job.status.value.title(),
|
||||
job=found_job, renderer_info=renderer_info())
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/thumbnail')
|
||||
def job_thumbnail(job_id):
|
||||
big_thumb = request.args.get('size', False) == "big"
|
||||
video_ok = request.args.get('video_ok', False)
|
||||
found_job = RenderQueue.job_with_id(job_id, none_ok=True)
|
||||
if found_job:
|
||||
|
||||
os.makedirs(server.config['THUMBS_FOLDER'], exist_ok=True)
|
||||
thumb_video_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.mp4')
|
||||
thumb_image_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.jpg')
|
||||
big_video_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '_big.mp4')
|
||||
big_image_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '_big.jpg')
|
||||
|
||||
# generate regular thumb if it doesn't exist
|
||||
if not os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS') and \
|
||||
found_job.status not in [RenderStatus.CANCELLED, RenderStatus.ERROR]:
|
||||
generate_thumbnail_for_job(found_job, thumb_video_path, thumb_image_path, max_width=240)
|
||||
|
||||
# generate big thumb if it doesn't exist
|
||||
if not os.path.exists(big_video_path) and not os.path.exists(big_image_path + '_IN-PROGRESS') and \
|
||||
found_job.status not in [RenderStatus.CANCELLED, RenderStatus.ERROR]:
|
||||
generate_thumbnail_for_job(found_job, big_video_path, big_image_path, max_width=800)
|
||||
|
||||
# generated videos
|
||||
if video_ok:
|
||||
if big_thumb and os.path.exists(big_video_path) and not os.path.exists(
|
||||
big_video_path + '_IN-PROGRESS'):
|
||||
return send_file(big_video_path, mimetype="video/mp4")
|
||||
elif os.path.exists(thumb_video_path) and not os.path.exists(thumb_video_path + '_IN-PROGRESS'):
|
||||
return send_file(thumb_video_path, mimetype="video/mp4")
|
||||
|
||||
# Generated thumbs
|
||||
if big_thumb and os.path.exists(big_image_path):
|
||||
return send_file(big_image_path, mimetype='image/jpeg')
|
||||
elif os.path.exists(thumb_image_path):
|
||||
return send_file(thumb_image_path, mimetype='image/jpeg')
|
||||
|
||||
# Misc status icons
|
||||
if found_job.status == RenderStatus.RUNNING:
|
||||
return send_file('web/static/images/gears.png', mimetype="image/png")
|
||||
elif found_job.status == RenderStatus.CANCELLED:
|
||||
return send_file('web/static/images/cancelled.png', mimetype="image/png")
|
||||
elif found_job.status == RenderStatus.SCHEDULED:
|
||||
return send_file('web/static/images/scheduled.png', mimetype="image/png")
|
||||
elif found_job.status == RenderStatus.NOT_STARTED:
|
||||
return send_file('web/static/images/not_started.png', mimetype="image/png")
|
||||
# errors
|
||||
return send_file('web/static/images/error.png', mimetype="image/png")
|
||||
|
||||
|
||||
# Get job file routing
|
||||
@server.route('/api/job/<job_id>/file/<filename>', methods=['GET'])
|
||||
def get_job_file(job_id, filename):
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
try:
|
||||
for full_path in found_job.file_list():
|
||||
if filename in full_path:
|
||||
return send_file(path_or_file=full_path)
|
||||
except FileNotFoundError:
|
||||
abort(404)
|
||||
|
||||
|
||||
@server.get('/api/jobs/<status_val>')
|
||||
def filtered_jobs_json(status_val):
|
||||
state = string_to_status(status_val)
|
||||
jobs = [x.json() for x in RenderQueue.jobs_with_status(state)]
|
||||
if jobs:
|
||||
return jobs
|
||||
else:
|
||||
return f'Cannot find jobs with status {status_val}', 400
|
||||
|
||||
|
||||
@server.post('/api/job/<job_id>/notify_parent_of_status_change')
|
||||
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(RenderQueue.job_with_id(job_id), subjob_data=subjob_details)
|
||||
return Response(status=200)
|
||||
except JobNotFoundError:
|
||||
return "Job not found", 404
|
||||
|
||||
|
||||
@server.errorhandler(JobNotFoundError)
|
||||
def handle_job_not_found(job_error):
|
||||
return f'Cannot find job with ID {job_error.job_id}', 400
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>')
|
||||
def get_job_status(job_id):
|
||||
return RenderQueue.job_with_id(job_id).json()
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/logs')
|
||||
def get_job_logs(job_id):
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
log_path = system_safe_path(found_job.log_path()),
|
||||
log_data = None
|
||||
if log_path and os.path.exists(log_path):
|
||||
with open(log_path) as file:
|
||||
log_data = file.read()
|
||||
return Response(log_data, mimetype='text/plain')
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/file_list')
|
||||
def get_file_list(job_id):
|
||||
return RenderQueue.job_with_id(job_id).file_list()
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/make_ready')
|
||||
def make_job_ready(job_id):
|
||||
try:
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
if found_job.status in [RenderStatus.CONFIGURING, RenderStatus.NOT_STARTED]:
|
||||
if found_job.children:
|
||||
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')
|
||||
found_job.status = RenderStatus.NOT_STARTED
|
||||
RenderQueue.save_state()
|
||||
return found_job.json(), 200
|
||||
except Exception as e:
|
||||
return "Error making job ready: {e}", 500
|
||||
return "Not valid command", 405
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/download_all')
|
||||
def download_all(job_id):
|
||||
zip_filename = None
|
||||
|
||||
@after_this_request
|
||||
def clear_zip(response):
|
||||
if zip_filename and os.path.exists(zip_filename):
|
||||
os.remove(zip_filename)
|
||||
return response
|
||||
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
output_dir = os.path.dirname(found_job.output_path)
|
||||
if os.path.exists(output_dir):
|
||||
zip_filename = system_safe_path(os.path.join(tempfile.gettempdir(),
|
||||
pathlib.Path(found_job.input_path).stem + '.zip'))
|
||||
with ZipFile(zip_filename, 'w') as zipObj:
|
||||
for f in os.listdir(output_dir):
|
||||
zipObj.write(filename=system_safe_path(os.path.join(output_dir, f)),
|
||||
arcname=os.path.basename(f))
|
||||
return send_file(zip_filename, mimetype="zip", as_attachment=True, )
|
||||
else:
|
||||
return f'Cannot find project files for job {job_id}', 500
|
||||
|
||||
|
||||
@server.get('/api/presets')
|
||||
def presets():
|
||||
presets_path = system_safe_path('config/presets.yaml')
|
||||
with open(presets_path) as f:
|
||||
presets = yaml.load(f, Loader=yaml.FullLoader)
|
||||
return presets
|
||||
|
||||
|
||||
@server.get('/api/full_status')
|
||||
def full_status():
|
||||
full_results = {'timestamp': datetime.now().isoformat(), 'servers': {}}
|
||||
|
||||
try:
|
||||
snapshot_results = snapshot()
|
||||
server_data = {'status': snapshot_results.get('status', {}), 'jobs': snapshot_results.get('jobs', {}),
|
||||
'is_online': True}
|
||||
full_results['servers'][server.config['HOSTNAME']] = server_data
|
||||
except Exception as e:
|
||||
logger.error(f"Exception fetching full status: {e}")
|
||||
|
||||
return full_results
|
||||
|
||||
|
||||
@server.get('/api/snapshot')
|
||||
def snapshot():
|
||||
server_status = status()
|
||||
server_jobs = [x.json() for x in RenderQueue.all_jobs()]
|
||||
server_data = {'status': server_status, 'jobs': server_jobs, 'timestamp': datetime.now().isoformat()}
|
||||
return server_data
|
||||
|
||||
|
||||
@server.get('/api/_detected_clients')
|
||||
def detected_clients():
|
||||
# todo: dev/debug only. Should not ship this - probably.
|
||||
return ZeroconfServer.found_clients()
|
||||
|
||||
|
||||
@server.post('/api/add_job')
|
||||
def add_job_handler():
|
||||
# initial handling of raw data
|
||||
try:
|
||||
if request.is_json:
|
||||
jobs_list = [request.json] if not isinstance(request.json, list) else request.json
|
||||
elif request.form.get('json', None):
|
||||
jobs_list = json.loads(request.form['json'])
|
||||
else:
|
||||
# Cleanup flat form data into nested structure
|
||||
form_dict = {k: v for k, v in dict(request.form).items() if v}
|
||||
args = {}
|
||||
arg_keys = [k for k in form_dict.keys() if '-arg_' in k]
|
||||
for server_hostname in arg_keys:
|
||||
if form_dict['renderer'] in server_hostname or 'AnyRenderer' in server_hostname:
|
||||
cleaned_key = server_hostname.split('-arg_')[-1]
|
||||
args[cleaned_key] = form_dict[server_hostname]
|
||||
form_dict.pop(server_hostname)
|
||||
args['raw'] = form_dict.get('raw_args', None)
|
||||
form_dict['args'] = args
|
||||
jobs_list = [form_dict]
|
||||
except Exception as e:
|
||||
err_msg = f"Error processing job data: {e}"
|
||||
logger.error(err_msg)
|
||||
return err_msg, 500
|
||||
|
||||
# start handling project files
|
||||
try:
|
||||
# handle uploaded files
|
||||
logger.debug(f"Incoming new job request: {jobs_list}")
|
||||
uploaded_project = request.files.get('file', None)
|
||||
project_url = jobs_list[0].get('url', None)
|
||||
local_path = jobs_list[0].get('local_path', None)
|
||||
renderer = jobs_list[0].get('renderer')
|
||||
|
||||
downloaded_file_url = None
|
||||
if uploaded_project and uploaded_project.filename:
|
||||
referred_name = os.path.basename(uploaded_project.filename)
|
||||
elif project_url:
|
||||
# download and save url - have to download first to know filename due to redirects
|
||||
logger.info(f"Attempting to download URL: {project_url}")
|
||||
try:
|
||||
downloaded_file_url, info = urlretrieve(project_url)
|
||||
referred_name = info.get_filename() or os.path.basename(project_url)
|
||||
except Exception as e:
|
||||
err_msg = f"Error downloading file: {e}"
|
||||
logger.error(err_msg)
|
||||
return err_msg, 406
|
||||
elif local_path and os.path.exists(local_path):
|
||||
referred_name = os.path.basename(local_path)
|
||||
else:
|
||||
return "Cannot find any valid project paths", 400
|
||||
|
||||
# prep local filepath
|
||||
cleaned_path_name = os.path.splitext(referred_name)[0].replace(' ', '_')
|
||||
job_dir = os.path.join(server.config['UPLOAD_FOLDER'], '-'.join(
|
||||
[datetime.now().strftime("%Y.%m.%d_%H.%M.%S"), renderer, cleaned_path_name]))
|
||||
os.makedirs(job_dir, exist_ok=True)
|
||||
upload_dir = os.path.join(job_dir, 'source')
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# move projects to their work directories
|
||||
loaded_project_local_path = None
|
||||
if uploaded_project and uploaded_project.filename:
|
||||
loaded_project_local_path = os.path.join(upload_dir, secure_filename(uploaded_project.filename))
|
||||
uploaded_project.save(loaded_project_local_path)
|
||||
logger.info(f"Transfer complete for {loaded_project_local_path.split(server.config['UPLOAD_FOLDER'])[-1]}")
|
||||
elif project_url:
|
||||
loaded_project_local_path = os.path.join(upload_dir, referred_name)
|
||||
shutil.move(downloaded_file_url, loaded_project_local_path)
|
||||
logger.info(f"Download complete for {loaded_project_local_path.split(server.config['UPLOAD_FOLDER'])[-1]}")
|
||||
elif local_path:
|
||||
loaded_project_local_path = os.path.join(upload_dir, referred_name)
|
||||
shutil.copy(local_path, loaded_project_local_path)
|
||||
logger.info(f"Import complete for {loaded_project_local_path.split(server.config['UPLOAD_FOLDER'])[-1]}")
|
||||
|
||||
# process uploaded zip files
|
||||
zip_path = loaded_project_local_path if loaded_project_local_path.lower().endswith('.zip') else None
|
||||
if zip_path:
|
||||
zip_path = loaded_project_local_path
|
||||
work_path = os.path.dirname(zip_path)
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, 'r') as myzip:
|
||||
myzip.extractall(os.path.dirname(zip_path))
|
||||
|
||||
project_files = [x for x in os.listdir(work_path) if os.path.isfile(os.path.join(work_path, x))]
|
||||
project_files = [x for x in project_files if '.zip' not in x]
|
||||
supported_exts = RenderWorkerFactory.class_for_name(renderer).engine.supported_extensions
|
||||
if supported_exts:
|
||||
project_files = [file for file in project_files if
|
||||
any(file.endswith(ext) for ext in supported_exts)]
|
||||
|
||||
if len(project_files) != 1: # we have to narrow down to 1 main project file, otherwise error
|
||||
return {'error': f'Cannot find valid project file in {os.path.basename(zip_path)}'}, 400
|
||||
|
||||
extracted_project_path = os.path.join(work_path, project_files[0])
|
||||
logger.info(f"Extracted zip file to {extracted_project_path}")
|
||||
loaded_project_local_path = extracted_project_path
|
||||
except (zipfile.BadZipFile, zipfile.LargeZipFile) as e:
|
||||
err_msg = f"Error processing zip file: {e}"
|
||||
logger.error(err_msg)
|
||||
return err_msg, 500
|
||||
|
||||
# create and add jobs to render queue
|
||||
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(system_safe_path(output_dir), exist_ok=True)
|
||||
|
||||
# get new output path in output_dir
|
||||
job_data['output_path'] = os.path.join(output_dir, os.path.basename(
|
||||
job_data.get('output_path', None) or loaded_project_local_path
|
||||
))
|
||||
|
||||
# create & configure jobs
|
||||
worker = RenderWorkerFactory.create_worker(renderer=job_data['renderer'],
|
||||
input_path=loaded_project_local_path,
|
||||
output_path=job_data["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)
|
||||
worker.priority = int(job_data.get('priority', worker.priority))
|
||||
worker.start_frame = int(job_data.get("start_frame", worker.start_frame))
|
||||
worker.end_frame = int(job_data.get("end_frame", worker.end_frame))
|
||||
|
||||
# determine if we can / should split the job
|
||||
if server.config.get('enable_split_jobs', False) and (worker.total_frames > 1) and not worker.parent:
|
||||
DistributedJobManager.split_into_subjobs(worker, job_data, zip_path or loaded_project_local_path)
|
||||
|
||||
RenderQueue.add_to_render_queue(worker, force_start=job_data.get('force_start', False))
|
||||
if not worker.parent:
|
||||
make_job_ready(worker.id)
|
||||
results.append(worker.json())
|
||||
except Exception as e:
|
||||
err_msg = f"Exception creating render job: {e}"
|
||||
logger.exception(err_msg)
|
||||
results.append({'error': err_msg})
|
||||
|
||||
# return any errors from results list
|
||||
for response in results:
|
||||
if response.get('error', None):
|
||||
return results, 400
|
||||
|
||||
# redirect to index if requested
|
||||
if request.args.get('redirect', False):
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
return results, 200
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unknown error adding job: {e}")
|
||||
return 'unknown error', 500
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/cancel')
|
||||
def cancel_job(job_id):
|
||||
if not request.args.get('confirm', False):
|
||||
return 'Confirmation required to cancel job', 400
|
||||
|
||||
if RenderQueue.cancel_job(RenderQueue.job_with_id(job_id)):
|
||||
if request.args.get('redirect', False):
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
return "Job cancelled"
|
||||
else:
|
||||
return "Unknown error", 500
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/delete', methods=['POST', 'GET'])
|
||||
def delete_job(job_id):
|
||||
try:
|
||||
if not request.args.get('confirm', False):
|
||||
return 'Confirmation required to delete job', 400
|
||||
|
||||
# Check if we can remove the 'output' directory
|
||||
found_job = RenderQueue.job_with_id(job_id)
|
||||
output_dir = os.path.dirname(found_job.output_path)
|
||||
if server.config['UPLOAD_FOLDER'] in output_dir and os.path.exists(output_dir):
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
# Remove any thumbnails
|
||||
for filename in os.listdir(server.config['THUMBS_FOLDER']):
|
||||
if job_id in filename:
|
||||
os.remove(os.path.join(server.config['THUMBS_FOLDER'], filename))
|
||||
|
||||
thumb_path = os.path.join(server.config['THUMBS_FOLDER'], found_job.id + '.mp4')
|
||||
if os.path.exists(thumb_path):
|
||||
os.remove(thumb_path)
|
||||
|
||||
# See if we own the project_dir (i.e. was it uploaded)
|
||||
project_dir = os.path.dirname(os.path.dirname(found_job.input_path))
|
||||
if server.config['UPLOAD_FOLDER'] in project_dir and os.path.exists(project_dir):
|
||||
# check to see if any other projects are sharing the same project file
|
||||
project_dir_files = [f for f in os.listdir(project_dir) if not f.startswith('.')]
|
||||
if len(project_dir_files) == 0 or (len(project_dir_files) == 1 and 'source' in project_dir_files[0]):
|
||||
logger.info(f"Removing project directory: {project_dir}")
|
||||
shutil.rmtree(project_dir)
|
||||
|
||||
RenderQueue.delete_job(found_job)
|
||||
if request.args.get('redirect', False):
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
return "Job deleted", 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting job: {e}")
|
||||
return f"Error deleting job: {e}", 500
|
||||
|
||||
|
||||
@server.get('/api/clear_history')
|
||||
def clear_history():
|
||||
RenderQueue.clear_history()
|
||||
return 'success'
|
||||
|
||||
|
||||
@server.route('/api/status')
|
||||
def status():
|
||||
renderer_data = {}
|
||||
for render_class in RenderWorkerFactory.supported_classes():
|
||||
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()),
|
||||
'is_available': RenderQueue.is_available_for_job(render_class.engine.name())
|
||||
}
|
||||
|
||||
# Get system info
|
||||
system_platform = platform.system().lower().replace('darwin', 'macos')
|
||||
system_platform_version = platform.mac_ver()[0] if system_platform == 'macos' else platform.release().lower()
|
||||
system_cpu = platform.machine().lower().replace('amd64', 'x64')
|
||||
|
||||
return {"timestamp": datetime.now().isoformat(),
|
||||
"system_platform": system_platform,
|
||||
"system_platform_version": system_platform_version,
|
||||
"system_cpu": system_cpu,
|
||||
"cpu_percent": psutil.cpu_percent(percpu=False),
|
||||
"cpu_percent_per_cpu": psutil.cpu_percent(percpu=True),
|
||||
"cpu_count": psutil.cpu_count(logical=False),
|
||||
"memory_total": psutil.virtual_memory().total,
|
||||
"memory_available": psutil.virtual_memory().available,
|
||||
"memory_percent": psutil.virtual_memory().percent,
|
||||
"job_counts": RenderQueue.job_counts(),
|
||||
"renderers": renderer_data,
|
||||
"hostname": server.config['HOSTNAME'],
|
||||
"port": server.config['PORT']
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
# Get all installed versions of engine
|
||||
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()}
|
||||
return renderer_data
|
||||
|
||||
|
||||
@server.post('/api/download_engine')
|
||||
def download_engine():
|
||||
download_result = EngineManager.download_engine(request.args.get('engine'),
|
||||
request.args.get('version'),
|
||||
request.args.get('system_os'),
|
||||
request.args.get('cpu'))
|
||||
return download_result if download_result else ("Error downloading requested engine", 500)
|
||||
|
||||
|
||||
@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'))
|
||||
return "Success" if delete_result else ("Error deleting requested engine", 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()
|
||||
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())
|
||||
|
||||
|
||||
def start_server(background_thread=False):
|
||||
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)
|
||||
|
||||
# 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')))
|
||||
os.makedirs(EngineManager.engines_path, exist_ok=True)
|
||||
|
||||
# Debug info
|
||||
logger.debug(f"Upload directory: {server.config['UPLOAD_FOLDER']}")
|
||||
logger.debug(f"Thumbs directory: {server.config['THUMBS_FOLDER']}")
|
||||
logger.debug(f"Engines directory: {EngineManager.engines_path}")
|
||||
|
||||
# disable most Flask logging
|
||||
flask_log = logging.getLogger('werkzeug')
|
||||
flask_log.setLevel(config.get('flask_log_level', 'ERROR').upper())
|
||||
|
||||
# Set up the RenderQueue object
|
||||
RenderQueue.start_queue()
|
||||
DistributedJobManager.start()
|
||||
|
||||
thread = threading.Thread(target=eval_loop, kwargs={'delay_sec': config.get('queue_eval_seconds', 1)}, daemon=True)
|
||||
thread.start()
|
||||
|
||||
logging.info(f"Starting Zordon Render Server - Hostname: '{server.config['HOSTNAME']}:'")
|
||||
ZeroconfServer.configure("_zordon._tcp.local.", server.config['HOSTNAME'], server.config['PORT'])
|
||||
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)
|
||||
finally:
|
||||
RenderQueue.save_state()
|
||||
ZeroconfServer.stop()
|
||||
399
src/client/dashboard_window.py
Normal file
@@ -0,0 +1,399 @@
|
||||
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.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.workers.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()
|
||||
452
src/client/new_job_window.py
Executable file
@@ -0,0 +1,452 @@
|
||||
#!/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.server_proxy import RenderServerProxy
|
||||
from src.workers.blender_worker import Blender
|
||||
from src.workers.ffmpeg_worker import FFMPEG
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
label_width = 9
|
||||
header_padding = 6
|
||||
|
||||
|
||||
# CheckListBox source - https://stackoverflow.com/questions/50398649/python-tkinter-tk-support-checklist-box
|
||||
class ChecklistBox(Frame):
|
||||
def __init__(self, parent, choices, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
|
||||
self.vars = []
|
||||
for choice in choices:
|
||||
var = StringVar(value="")
|
||||
self.vars.append(var)
|
||||
cb = Checkbutton(self, text=choice, onvalue=choice, offvalue="", anchor="w", width=20,
|
||||
relief="flat", highlightthickness=0, variable=var)
|
||||
cb.pack(side="top", fill="x", anchor="w")
|
||||
|
||||
def getCheckedItems(self):
|
||||
values = []
|
||||
for var in self.vars:
|
||||
value = var.get()
|
||||
if value:
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
def resetCheckedItems(self):
|
||||
values = []
|
||||
for var in self.vars:
|
||||
var.set(value='')
|
||||
return values
|
||||
|
||||
|
||||
class NewJobWindow(Frame):
|
||||
|
||||
def __init__(self, parent=None, clients=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.root = parent
|
||||
self.clients = clients or []
|
||||
self.server_proxy = RenderServerProxy(hostname=clients[0] if clients else None)
|
||||
self.chosen_file = None
|
||||
self.project_info = {}
|
||||
self.presets = {}
|
||||
self.renderer_info = {}
|
||||
self.priority = IntVar(value=2)
|
||||
|
||||
self.master.title("New Job")
|
||||
self.pack(fill=BOTH, expand=True)
|
||||
|
||||
# project frame
|
||||
job_frame = LabelFrame(self, text="Job Settings")
|
||||
job_frame.pack(fill=X, padx=5, pady=5)
|
||||
|
||||
# project frame
|
||||
project_frame = Frame(job_frame)
|
||||
project_frame.pack(fill=X)
|
||||
|
||||
project_label = Label(project_frame, text="Project", width=label_width)
|
||||
project_label.pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
self.project_button = Button(project_frame, text="no file selected", width=6, command=self.choose_file_button)
|
||||
self.project_button.pack(fill=X, padx=5, expand=True)
|
||||
|
||||
# client frame
|
||||
client_frame = Frame(job_frame)
|
||||
client_frame.pack(fill=X)
|
||||
|
||||
Label(client_frame, text="Client", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
self.client_combo = Combobox(client_frame, state="readonly")
|
||||
self.client_combo.pack(fill=X, padx=5, expand=True)
|
||||
self.client_combo.bind('<<ComboboxSelected>>', self.client_picked)
|
||||
self.client_combo['values'] = self.clients
|
||||
if self.clients:
|
||||
self.client_combo.current(0)
|
||||
|
||||
# renderer frame
|
||||
renderer_frame = Frame(job_frame)
|
||||
renderer_frame.pack(fill=X)
|
||||
|
||||
Label(renderer_frame, text="Renderer", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
self.renderer_combo = Combobox(renderer_frame, state="readonly")
|
||||
self.renderer_combo.pack(fill=X, padx=5, expand=True)
|
||||
self.renderer_combo.bind('<<ComboboxSelected>>', self.refresh_renderer_settings)
|
||||
|
||||
# priority frame
|
||||
priority_frame = Frame(job_frame)
|
||||
priority_frame.pack(fill=X)
|
||||
|
||||
Label(priority_frame, text="Priority", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
Radiobutton(priority_frame, text="1", value=1, variable=self.priority).pack(anchor=W, side=LEFT, padx=5)
|
||||
Radiobutton(priority_frame, text="2", value=2, variable=self.priority).pack(anchor=W, side=LEFT, padx=5)
|
||||
Radiobutton(priority_frame, text="3", value=3, variable=self.priority).pack(anchor=W, side=LEFT, padx=5)
|
||||
|
||||
# presets
|
||||
presets_frame = Frame(job_frame)
|
||||
presets_frame.pack(fill=X)
|
||||
|
||||
Label(presets_frame, text="Presets", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
self.presets_combo = Combobox(presets_frame, state="readonly")
|
||||
self.presets_combo.pack(side=LEFT, padx=5, pady=5, expand=True, fill=X)
|
||||
self.presets_combo.bind('<<ComboboxSelected>>', self.chose_preset)
|
||||
|
||||
# output frame
|
||||
output_frame = Frame(job_frame)
|
||||
output_frame.pack(fill=X)
|
||||
|
||||
Label(output_frame, text="Output", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
self.output_entry = Entry(output_frame)
|
||||
self.output_entry.pack(side=LEFT, padx=5, expand=True, fill=X)
|
||||
|
||||
self.output_format = Combobox(output_frame, state="readonly", values=['JPG', 'MOV', 'PNG'], width=9)
|
||||
self.output_format.pack(side=LEFT, padx=5, pady=5)
|
||||
self.output_format['state'] = DISABLED
|
||||
|
||||
# frame_range frame
|
||||
frame_range_frame = Frame(job_frame)
|
||||
frame_range_frame.pack(fill=X)
|
||||
|
||||
Label(frame_range_frame, text="Frames", width=label_width).pack(side=LEFT, padx=5, pady=5, expand=False)
|
||||
|
||||
self.start_frame_spinbox = Spinbox(frame_range_frame, from_=0, to=50000, width=5)
|
||||
self.start_frame_spinbox.pack(side=LEFT, expand=False, padx=5, pady=5)
|
||||
|
||||
Label(frame_range_frame, text="to").pack(side=LEFT, pady=5, expand=False)
|
||||
self.end_frame_spinbox = Spinbox(frame_range_frame, from_=0, to=50000, width=5)
|
||||
self.end_frame_spinbox.pack(side=LEFT, expand=False, padx=5, pady=5)
|
||||
|
||||
# Blender
|
||||
self.blender_frame = None
|
||||
self.blender_cameras_frame = None
|
||||
self.blender_engine = StringVar(value='CYCLES')
|
||||
self.blender_pack_textures = BooleanVar(value=False)
|
||||
self.blender_multiple_cameras = BooleanVar(value=False)
|
||||
self.blender_cameras_list = None
|
||||
|
||||
# Custom Args / Submit Button
|
||||
self.custom_args_frame = None
|
||||
self.custom_args_entry = None
|
||||
self.submit_frame = None
|
||||
|
||||
self.progress_frame = None
|
||||
self.progress_label = None
|
||||
self.progress_bar = None
|
||||
self.upload_status = None
|
||||
|
||||
self.fetch_server_data()
|
||||
|
||||
def client_picked(self, event=None):
|
||||
self.server_proxy.hostname = self.client_combo.get()
|
||||
self.fetch_server_data()
|
||||
|
||||
def fetch_server_data(self):
|
||||
self.renderer_info = self.server_proxy.request_data('renderer_info', timeout=3) or {}
|
||||
self.presets = self.server_proxy.request_data('presets', timeout=3) or {}
|
||||
|
||||
# update available renders
|
||||
self.renderer_combo['values'] = list(self.renderer_info.keys())
|
||||
if self.renderer_info.keys():
|
||||
self.renderer_combo.current(0)
|
||||
|
||||
self.refresh_renderer_settings()
|
||||
|
||||
def choose_file_button(self):
|
||||
self.chosen_file = filedialog.askopenfilename()
|
||||
button_text = os.path.basename(self.chosen_file) if self.chosen_file else "no file selected"
|
||||
self.project_button.configure(text=button_text)
|
||||
|
||||
# Update the output label
|
||||
self.output_entry.delete(0, END)
|
||||
if self.chosen_file:
|
||||
# Generate a default output name
|
||||
output_name = os.path.splitext(os.path.basename(self.chosen_file))[-1].strip('.')
|
||||
self.output_entry.insert(0, os.path.basename(output_name))
|
||||
|
||||
# Try to determine file type
|
||||
extension = os.path.splitext(self.chosen_file)[-1].strip('.') # not the best way to do this
|
||||
for renderer, renderer_info in self.renderer_info.items():
|
||||
supported = [x.lower().strip('.') for x in renderer_info.get('supported_extensions', [])]
|
||||
if extension.lower().strip('.') in supported:
|
||||
if renderer in self.renderer_combo['values']:
|
||||
self.renderer_combo.set(renderer)
|
||||
|
||||
self.refresh_renderer_settings()
|
||||
|
||||
def chose_preset(self, event=None):
|
||||
preset_name = self.presets_combo.get()
|
||||
renderer = self.renderer_combo.get()
|
||||
|
||||
presets_to_show = {key: value for key, value in self.presets.items() if value.get("renderer") == renderer}
|
||||
matching_dict = next((value for value in presets_to_show.values() if value.get("name") == preset_name), None)
|
||||
if matching_dict:
|
||||
self.custom_args_entry.delete(0, END)
|
||||
self.custom_args_entry.insert(0, matching_dict['args'])
|
||||
|
||||
def refresh_renderer_settings(self, event=None):
|
||||
renderer = self.renderer_combo.get()
|
||||
|
||||
# clear old settings
|
||||
if self.blender_frame:
|
||||
self.blender_frame.pack_forget()
|
||||
|
||||
if not self.chosen_file:
|
||||
return
|
||||
|
||||
if renderer == 'blender':
|
||||
self.project_info = Blender().get_scene_info(self.chosen_file)
|
||||
self.draw_blender_settings()
|
||||
elif renderer == 'ffmpeg':
|
||||
f = FFMPEG.get_frame_count(self.chosen_file)
|
||||
self.project_info['frame_end'] = f
|
||||
|
||||
# set frame start / end numbers fetched from fils
|
||||
if self.project_info.get('frame_start'):
|
||||
self.start_frame_spinbox.delete(0, 'end')
|
||||
self.start_frame_spinbox.insert(0, self.project_info['frame_start'])
|
||||
if self.project_info.get('frame_end'):
|
||||
self.end_frame_spinbox.delete(0, 'end')
|
||||
self.end_frame_spinbox.insert(0, self.project_info['frame_end'])
|
||||
|
||||
# redraw lower ui
|
||||
self.draw_custom_args()
|
||||
self.draw_submit_button()
|
||||
|
||||
# check supported export formats
|
||||
if self.renderer_info.get(renderer, {}).get('supported_export_formats', None):
|
||||
formats = self.renderer_info[renderer]['supported_export_formats']
|
||||
if formats and isinstance(formats[0], dict):
|
||||
formats = [x.get('name', str(x)) for x in formats]
|
||||
formats.sort()
|
||||
self.output_format['values'] = formats
|
||||
self.output_format['state'] = NORMAL
|
||||
self.output_format.current(0)
|
||||
else:
|
||||
self.output_format['values'] = []
|
||||
self.output_format['state'] = DISABLED
|
||||
|
||||
# update presets
|
||||
presets_to_show = {key: value for key, value in self.presets.items() if value.get("renderer") == renderer}
|
||||
self.presets_combo['values'] = [value['name'] for value in presets_to_show.values()]
|
||||
|
||||
def draw_custom_args(self):
|
||||
if hasattr(self, 'custom_args_frame') and self.custom_args_frame:
|
||||
self.custom_args_frame.forget()
|
||||
self.custom_args_frame = LabelFrame(self, text="Advanced")
|
||||
self.custom_args_frame.pack(side=TOP, fill=X, expand=False, padx=5, pady=5)
|
||||
Label(self.custom_args_frame, text="Custom Args", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
self.custom_args_entry = Entry(self.custom_args_frame)
|
||||
self.custom_args_entry.pack(side=TOP, padx=5, expand=True, fill=X)
|
||||
|
||||
def draw_submit_button(self):
|
||||
if hasattr(self, 'submit_frame') and self.submit_frame:
|
||||
self.submit_frame.forget()
|
||||
self.submit_frame = Frame(self)
|
||||
self.submit_frame.pack(fill=BOTH, expand=True)
|
||||
# Label(self.submit_frame, text="").pack(fill=BOTH, expand=True)
|
||||
submit_button = Button(self.submit_frame, text="Submit", command=self.submit_job)
|
||||
submit_button.pack(fill=Y, anchor="s", pady=header_padding)
|
||||
|
||||
def draw_progress_frame(self):
|
||||
if hasattr(self, 'progress_frame') and self.progress_frame:
|
||||
self.progress_frame.forget()
|
||||
self.progress_frame = LabelFrame(self, text="Job Submission")
|
||||
self.progress_frame.pack(side=TOP, fill=X, expand=False, padx=5, pady=5)
|
||||
self.progress_bar = Progressbar(self.progress_frame, length=300, mode="determinate")
|
||||
self.progress_bar.pack()
|
||||
self.progress_label = Label(self.progress_frame, text="Starting Up")
|
||||
self.progress_label.pack(pady=5, padx=5)
|
||||
|
||||
def draw_blender_settings(self):
|
||||
|
||||
# blender settings
|
||||
self.blender_frame = LabelFrame(self, text="Blender Settings")
|
||||
self.blender_frame.pack(fill=X, padx=5)
|
||||
|
||||
blender_engine_frame = Frame(self.blender_frame)
|
||||
blender_engine_frame.pack(fill=X)
|
||||
|
||||
Label(blender_engine_frame, text="Engine", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
Radiobutton(blender_engine_frame, text="Cycles", value="CYCLES", variable=self.blender_engine).pack(
|
||||
anchor=W, side=LEFT, padx=5)
|
||||
Radiobutton(blender_engine_frame, text="Eevee", value="BLENDER_EEVEE", variable=self.blender_engine).pack(
|
||||
anchor=W, side=LEFT, padx=5)
|
||||
|
||||
# options
|
||||
pack_frame = Frame(self.blender_frame)
|
||||
pack_frame.pack(fill=X)
|
||||
|
||||
Label(pack_frame, text="Options", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
Checkbutton(pack_frame, text="Pack Textures", variable=self.blender_pack_textures, onvalue=True, offvalue=False
|
||||
).pack(anchor=W, side=LEFT, padx=5)
|
||||
|
||||
# multi cams
|
||||
def draw_scene_cams(event=None):
|
||||
if self.project_info:
|
||||
show_cams_checkbutton['state'] = NORMAL
|
||||
if self.blender_multiple_cameras.get():
|
||||
self.blender_cameras_frame = Frame(self.blender_frame)
|
||||
self.blender_cameras_frame.pack(fill=X)
|
||||
|
||||
Label(self.blender_cameras_frame, text="Cameras", width=label_width).pack(side=LEFT, padx=5, pady=5)
|
||||
|
||||
choices = [f"{x['name']} - {int(float(x['lens']))}mm" for x in self.project_info['cameras']]
|
||||
choices.sort()
|
||||
self.blender_cameras_list = ChecklistBox(self.blender_cameras_frame, choices, relief="sunken")
|
||||
self.blender_cameras_list.pack(padx=5, fill=X)
|
||||
elif self.blender_cameras_frame:
|
||||
self.blender_cameras_frame.pack_forget()
|
||||
else:
|
||||
show_cams_checkbutton['state'] = DISABLED
|
||||
if self.blender_cameras_frame:
|
||||
self.blender_cameras_frame.pack_forget()
|
||||
|
||||
# multiple cameras checkbox
|
||||
camera_count = len(self.project_info.get('cameras', [])) if self.project_info else 0
|
||||
show_cams_checkbutton = Checkbutton(pack_frame, text=f'Multiple Cameras ({camera_count})', offvalue=False,
|
||||
onvalue=True,
|
||||
variable=self.blender_multiple_cameras, command=draw_scene_cams)
|
||||
show_cams_checkbutton.pack(side=LEFT, padx=5)
|
||||
show_cams_checkbutton['state'] = NORMAL if camera_count > 1 else DISABLED
|
||||
|
||||
def submit_job(self):
|
||||
|
||||
def submit_job_worker():
|
||||
|
||||
self.draw_progress_frame()
|
||||
self.progress_bar['value'] = 0
|
||||
self.progress_bar.configure(mode='determinate')
|
||||
self.progress_bar.start()
|
||||
self.progress_label.configure(text="Preparing files...")
|
||||
|
||||
# start the progress UI
|
||||
client = self.client_combo.get()
|
||||
|
||||
renderer = self.renderer_combo.get()
|
||||
job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(),
|
||||
'renderer': renderer,
|
||||
'client': client,
|
||||
'output_path': os.path.join(os.path.dirname(self.chosen_file), self.output_entry.get()),
|
||||
'args': {'raw': self.custom_args_entry.get()},
|
||||
'start_frame': self.start_frame_spinbox.get(),
|
||||
'end_frame': self.end_frame_spinbox.get(),
|
||||
'name': None}
|
||||
job_list = []
|
||||
|
||||
input_path = self.chosen_file
|
||||
|
||||
temp_files = []
|
||||
if renderer == 'blender':
|
||||
if self.blender_pack_textures.get():
|
||||
self.progress_label.configure(text="Packing Blender file...")
|
||||
new_path = Blender().pack_project_file(project_path=input_path, timeout=300)
|
||||
if new_path:
|
||||
logger.info(f"New Path is now {new_path}")
|
||||
input_path = new_path
|
||||
temp_files.append(new_path)
|
||||
else:
|
||||
err_msg = f'Failed to pack Blender file: {input_path}'
|
||||
messagebox.showinfo("Error", err_msg)
|
||||
return
|
||||
# add all Blender args
|
||||
job_json['args']['engine'] = self.blender_engine.get()
|
||||
job_json['args']['export_format'] = self.output_format.get()
|
||||
|
||||
# multiple camera rendering
|
||||
if self.blender_cameras_list and self.blender_multiple_cameras.get():
|
||||
selected_cameras = self.blender_cameras_list.getCheckedItems()
|
||||
for cam in selected_cameras:
|
||||
job_copy = copy.deepcopy(job_json)
|
||||
job_copy['args']['camera'] = cam.rsplit('-', 1)[0].strip()
|
||||
job_copy['name'] = pathlib.Path(input_path).stem.replace(' ', '_') + "-" + cam.replace(' ', '')
|
||||
job_list.append(job_copy)
|
||||
|
||||
# Submit to server
|
||||
job_list = job_list or [job_json]
|
||||
self.progress_label.configure(text="Posting to server...")
|
||||
self.progress_bar.stop()
|
||||
self.progress_bar.configure(mode='determinate')
|
||||
self.progress_bar.start()
|
||||
|
||||
def create_callback(encoder):
|
||||
encoder_len = encoder.len
|
||||
|
||||
def callback(monitor):
|
||||
percent = f"{monitor.bytes_read / encoder_len * 100:.0f}"
|
||||
self.progress_label.configure(text=f"Transferring to {client} - {percent}%")
|
||||
self.progress_bar['value'] = int(percent)
|
||||
return callback
|
||||
|
||||
result = self.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list,
|
||||
callback=create_callback)
|
||||
|
||||
self.progress_bar.stop()
|
||||
# clean up
|
||||
for temp in temp_files:
|
||||
os.remove(temp)
|
||||
|
||||
def finish_on_main():
|
||||
if result.ok:
|
||||
message = "Job successfully submitted to server."
|
||||
self.progress_label.configure(text=message)
|
||||
messagebox.showinfo("Success", message)
|
||||
logger.info(message)
|
||||
else:
|
||||
message = result.text or "Unknown error"
|
||||
self.progress_label.configure(text=message)
|
||||
logger.warning(message)
|
||||
messagebox.showinfo("Error", message)
|
||||
self.progress_label.configure(text="")
|
||||
self.progress_frame.forget()
|
||||
|
||||
self.root.after(0, finish_on_main)
|
||||
|
||||
# Start the job submit task as a bg thread
|
||||
bg_thread = threading.Thread(target=submit_job_worker)
|
||||
bg_thread.start()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
root = Tk()
|
||||
root.geometry("500x600+300+300")
|
||||
root.maxsize(width=1000, height=2000)
|
||||
root.minsize(width=600, height=600)
|
||||
app = NewJobWindow(root)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,18 +1,13 @@
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import requests
|
||||
from plyer import notification
|
||||
from pubsub import pub
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.render_queue import RenderQueue
|
||||
from src.server_proxy import RenderServerProxy
|
||||
from src.utilities.misc_helper import get_file_size_human
|
||||
from src.utilities.status_utils import RenderStatus, string_to_status
|
||||
from src.utilities.zeroconf_server import ZeroconfServer
|
||||
@@ -26,7 +21,7 @@ class DistributedJobManager:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def subscribe_to_listener(cls):
|
||||
def start(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.
|
||||
@@ -56,108 +51,21 @@ class DistributedJobManager:
|
||||
parent_id, hostname = render_job.parent.split('@')[0], render_job.parent.split('@')[-1]
|
||||
RenderServerProxy(hostname).notify_parent_of_status_change(parent_id=parent_id, subjob=render_job)
|
||||
|
||||
# handle cancelling all the children
|
||||
elif render_job.children and new_status in [RenderStatus.CANCELLED, RenderStatus.ERROR]:
|
||||
for child in render_job.children:
|
||||
child_id, hostname = child.split('@')
|
||||
RenderServerProxy(hostname).cancel_job(child_id, confirm=True)
|
||||
|
||||
# UI Notifications
|
||||
try:
|
||||
if new_status == RenderStatus.COMPLETED:
|
||||
logger.debug("Show render complete notification")
|
||||
notification.notify(
|
||||
title='Render Job Complete',
|
||||
message=f'{render_job.name} completed succesfully',
|
||||
timeout=10 # Display time in seconds
|
||||
)
|
||||
elif new_status == RenderStatus.ERROR:
|
||||
logger.debug("Show render error notification")
|
||||
notification.notify(
|
||||
title='Render Job Failed',
|
||||
message=f'{render_job.name} failed rendering',
|
||||
timeout=10 # Display time in seconds
|
||||
)
|
||||
elif new_status == RenderStatus.RUNNING:
|
||||
logger.debug("Show render started notification")
|
||||
notification.notify(
|
||||
title='Render Job Started',
|
||||
message=f'{render_job.name} started rendering',
|
||||
timeout=10 # Display time in seconds
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Unable to show UI notification: {e}")
|
||||
|
||||
# --------------------------------------------
|
||||
# Create Job
|
||||
# --------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def create_render_job(cls, job_data, loaded_project_local_path):
|
||||
"""
|
||||
Creates render jobs.
|
||||
|
||||
This method takes a list of job data, a local path to a loaded project, and a job directory. It creates a render
|
||||
job for each job data in the list and appends the result to a list. The list of results is then returned.
|
||||
|
||||
Args:
|
||||
job_data (dict): Job data.
|
||||
loaded_project_local_path (str): The local path to the loaded project.
|
||||
|
||||
Returns:
|
||||
worker: Created job worker
|
||||
"""
|
||||
|
||||
# get new output path in output_dir
|
||||
output_path = job_data.get('output_path')
|
||||
if not output_path:
|
||||
loaded_project_filename = os.path.basename(loaded_project_local_path)
|
||||
output_filename = os.path.splitext(loaded_project_filename)[0]
|
||||
else:
|
||||
output_filename = os.path.basename(output_path)
|
||||
|
||||
# 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 = 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', {}),
|
||||
parent=job_data.get('parent'),
|
||||
name=job_data.get('name'))
|
||||
worker.status = job_data.get("initial_status", worker.status) # todo: is this necessary?
|
||||
worker.priority = int(job_data.get('priority', worker.priority))
|
||||
worker.start_frame = int(job_data.get("start_frame", worker.start_frame))
|
||||
worker.end_frame = int(job_data.get("end_frame", worker.end_frame))
|
||||
worker.hostname = socket.gethostname()
|
||||
|
||||
# determine if we can / should split the job
|
||||
if job_data.get("enable_split_jobs", False) and (worker.total_frames > 1) and not worker.parent:
|
||||
cls.split_into_subjobs_async(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))
|
||||
|
||||
return worker
|
||||
|
||||
# --------------------------------------------
|
||||
# Handling Subjobs
|
||||
# --------------------------------------------
|
||||
elif render_job.children and new_status == RenderStatus.CANCELLED:
|
||||
# todo: handle cancelling all the children
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
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.
|
||||
|
||||
Args:
|
||||
local_job (BaseRenderWorker): The local parent job worker.
|
||||
subjob_data (dict): Subjob data sent from the remote server.
|
||||
Parameters:
|
||||
local_job (BaseRenderWorker): The local parent job worker.
|
||||
subjob_data (dict): subjob data sent from remote server.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
|
||||
subjob_status = string_to_status(subjob_data['status'])
|
||||
@@ -206,7 +114,7 @@ class DistributedJobManager:
|
||||
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.error(f"Error downloading files from remote server: {e}")
|
||||
logger.exception(f"Exception downloading files from remote server: {e}")
|
||||
local_job.children[child_key]['download_status'] = 'failed'
|
||||
return False
|
||||
|
||||
@@ -282,112 +190,86 @@ class DistributedJobManager:
|
||||
f"{', '.join(list(subjobs_not_downloaded().keys()))}")
|
||||
time.sleep(5)
|
||||
|
||||
# --------------------------------------------
|
||||
# Creating Subjobs
|
||||
# --------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def split_into_subjobs_async(cls, parent_worker, job_data, project_path, system_os=None):
|
||||
# todo: I don't love this
|
||||
parent_worker.status = RenderStatus.CONFIGURING
|
||||
cls.background_worker = threading.Thread(target=cls.split_into_subjobs, args=(parent_worker, job_data,
|
||||
project_path, system_os))
|
||||
cls.background_worker.start()
|
||||
|
||||
@classmethod
|
||||
def split_into_subjobs(cls, parent_worker, job_data, project_path, system_os=None, specific_servers=None):
|
||||
"""
|
||||
Splits a job into subjobs and distributes them among available servers.
|
||||
|
||||
This method checks the availability of servers, distributes the work among them, and creates subjobs on each
|
||||
server. If a server is the local host, it adjusts the frame range of the parent job instead of creating a
|
||||
subjob.
|
||||
|
||||
Args:
|
||||
parent_worker (Worker): The worker that is handling the job.
|
||||
job_data (dict): The data for the job to be split.
|
||||
project_path (str): The path to the project associated with the job.
|
||||
system_os (str, optional): The operating system of the servers. Default is any OS.
|
||||
specific_servers (list, optional): List of specific servers to split work between. Defaults to all found.
|
||||
"""
|
||||
def split_into_subjobs(cls, worker, job_data, project_path):
|
||||
|
||||
# Check availability
|
||||
parent_worker.status = RenderStatus.CONFIGURING
|
||||
available_servers = specific_servers if specific_servers else cls.find_available_servers(parent_worker.renderer, system_os)
|
||||
logger.debug(f"Splitting into subjobs - Available servers: {available_servers}")
|
||||
all_subjob_server_data = cls.distribute_server_work(parent_worker.start_frame, parent_worker.end_frame, available_servers)
|
||||
available_servers = cls.find_available_servers(worker.renderer)
|
||||
subjob_servers = cls.distribute_server_work(worker.start_frame, worker.end_frame, available_servers)
|
||||
local_hostname = socket.gethostname()
|
||||
|
||||
# Prep and submit these sub-jobs
|
||||
logger.info(f"Job {parent_worker.id} split plan: {all_subjob_server_data}")
|
||||
logger.info(f"Job {worker.id} split plan: {subjob_servers}")
|
||||
try:
|
||||
for subjob_data in all_subjob_server_data:
|
||||
subjob_hostname = subjob_data['hostname']
|
||||
if subjob_hostname != parent_worker.hostname:
|
||||
post_results = cls.__create_subjob(job_data, project_path, subjob_data, subjob_hostname,
|
||||
parent_worker)
|
||||
if not post_results.ok:
|
||||
ValueError(f"Failed to create subjob on {subjob_hostname}")
|
||||
|
||||
# save child info
|
||||
submission_results = post_results.json()[0]
|
||||
child_key = f"{submission_results['id']}@{subjob_hostname}"
|
||||
parent_worker.children[child_key] = submission_results
|
||||
for server_data in subjob_servers:
|
||||
server_hostname = server_data['hostname']
|
||||
if server_hostname != local_hostname:
|
||||
post_results = cls.__create_subjob(job_data, local_hostname, project_path, server_data,
|
||||
server_hostname, worker)
|
||||
if post_results.ok:
|
||||
server_data['submission_results'] = post_results.json()[0]
|
||||
else:
|
||||
logger.error(f"Failed to create subjob on {server_hostname}")
|
||||
break
|
||||
else:
|
||||
# truncate parent render_job
|
||||
parent_worker.start_frame = max(subjob_data['frame_range'][0], parent_worker.start_frame)
|
||||
parent_worker.end_frame = min(subjob_data['frame_range'][-1], parent_worker.end_frame)
|
||||
logger.info(f"Local job now rendering from {parent_worker.start_frame} to {parent_worker.end_frame}")
|
||||
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_servers):
|
||||
raise ValueError("Failed to create all subjobs") # look into recalculating job #s and use exising jobs
|
||||
|
||||
# start subjobs
|
||||
logger.debug(f"Created {len(all_subjob_server_data) - 1} subjobs successfully")
|
||||
parent_worker.name = f"{parent_worker.name}[{parent_worker.start_frame}-{parent_worker.end_frame}]"
|
||||
parent_worker.status = RenderStatus.NOT_STARTED # todo: this won't work with scheduled starts
|
||||
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.error(f"Failed to split job into subjobs: {e}")
|
||||
logger.debug(f"Cancelling {len(all_subjob_server_data) - 1} attempted subjobs")
|
||||
RenderServerProxy(parent_worker.hostname).cancel_job(parent_worker.id, confirm=True)
|
||||
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 __create_subjob(job_data, project_path, server_data, server_hostname, parent_worker):
|
||||
def __create_subjob(job_data, local_hostname, project_path, server_data, server_hostname, worker):
|
||||
subjob = job_data.copy()
|
||||
subjob['name'] = f"{parent_worker.name}[{server_data['frame_range'][0]}-{server_data['frame_range'][-1]}]"
|
||||
subjob['parent'] = f"{parent_worker.id}@{parent_worker.hostname}"
|
||||
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]
|
||||
subjob['engine_version'] = parent_worker.renderer_version
|
||||
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
|
||||
|
||||
# --------------------------------------------
|
||||
# Server Handling
|
||||
# --------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def distribute_server_work(start_frame, end_frame, available_servers, method='cpu_benchmark'):
|
||||
def distribute_server_work(start_frame, end_frame, available_servers, method='cpu_count'):
|
||||
"""
|
||||
Splits the frame range among available servers proportionally based on their performance (CPU count).
|
||||
|
||||
Args:
|
||||
start_frame (int): The start frame number of the animation to be rendered.
|
||||
end_frame (int): The end frame number of the animation to be rendered.
|
||||
available_servers (list): A list of available server dictionaries. Each server dictionary should include
|
||||
'hostname' and 'cpu_count' keys (see find_available_servers).
|
||||
method (str, optional): Specifies the distribution method. Possible values are 'cpu_benchmark', 'cpu_count'
|
||||
and 'evenly'.
|
||||
Defaults to 'cpu_benchmark'.
|
||||
:param start_frame: int, The start frame number of the animation to be rendered.
|
||||
:param end_frame: int, The end frame number of the animation to be rendered.
|
||||
:param available_servers: list, A list of available server dictionaries. Each server dictionary should include
|
||||
'hostname' and 'cpu_count' keys (see find_available_servers)
|
||||
:param method: str, Optional. Specifies the distribution method. Possible values are 'cpu_count' and 'equally'
|
||||
|
||||
Returns:
|
||||
list: A list of server dictionaries where each dictionary includes the frame range and total number of
|
||||
frames to be rendered by the server.
|
||||
|
||||
:return: A list of server dictionaries where each dictionary includes the frame range and total number of frames
|
||||
to be rendered by the server.
|
||||
"""
|
||||
|
||||
# Calculate respective frames for each server
|
||||
def divide_frames_by_cpu_count(frame_start, frame_end, servers):
|
||||
total_frames = frame_end - frame_start + 1
|
||||
total_cpus = sum(server['cpu_count'] for server in servers)
|
||||
total_performance = sum(server['cpu_count'] for server in servers)
|
||||
|
||||
frame_ranges = {}
|
||||
current_frame = frame_start
|
||||
@@ -398,47 +280,7 @@ class DistributedJobManager:
|
||||
# Give all remaining frames to the last server
|
||||
num_frames = total_frames - allocated_frames
|
||||
else:
|
||||
num_frames = round((server['cpu_count'] / total_cpus) * total_frames)
|
||||
allocated_frames += num_frames
|
||||
|
||||
frame_end_for_server = current_frame + num_frames - 1
|
||||
|
||||
if current_frame <= frame_end_for_server:
|
||||
frame_ranges[server['hostname']] = (current_frame, frame_end_for_server)
|
||||
current_frame = frame_end_for_server + 1
|
||||
|
||||
return frame_ranges
|
||||
|
||||
def divide_frames_by_benchmark(frame_start, frame_end, servers):
|
||||
|
||||
def fetch_benchmark(server):
|
||||
try:
|
||||
benchmark = requests.get(f'http://{server["hostname"]}:{ZeroconfServer.server_port}'
|
||||
f'/api/cpu_benchmark').text
|
||||
server['cpu_benchmark'] = benchmark
|
||||
logger.debug(f'Benchmark for {server["hostname"]}: {benchmark}')
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f'Error fetching benchmark for {server["hostname"]}: {e}')
|
||||
|
||||
# Number of threads to use (can adjust based on your needs or number of servers)
|
||||
threads = len(servers)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=threads) as executor:
|
||||
executor.map(fetch_benchmark, servers)
|
||||
|
||||
total_frames = frame_end - frame_start + 1
|
||||
total_performance = sum(int(server['cpu_benchmark']) for server in servers)
|
||||
|
||||
frame_ranges = {}
|
||||
current_frame = frame_start
|
||||
allocated_frames = 0
|
||||
|
||||
for i, server in enumerate(servers):
|
||||
if i == len(servers) - 1: # if it's the last server
|
||||
# Give all remaining frames to the last server
|
||||
num_frames = total_frames - allocated_frames
|
||||
else:
|
||||
num_frames = round((int(server['cpu_benchmark']) / total_performance) * total_frames)
|
||||
num_frames = round((server['cpu_count'] / total_performance) * total_frames)
|
||||
allocated_frames += num_frames
|
||||
|
||||
frame_end_for_server = current_frame + num_frames - 1
|
||||
@@ -467,18 +309,12 @@ class DistributedJobManager:
|
||||
|
||||
return frame_ranges
|
||||
|
||||
if len(available_servers) == 1:
|
||||
breakdown = {available_servers[0]['hostname']: (start_frame, end_frame)}
|
||||
if method == 'equally':
|
||||
breakdown = divide_frames_equally(start_frame, end_frame, available_servers)
|
||||
# elif method == 'benchmark_score': # todo: implement benchmark score
|
||||
# pass
|
||||
else:
|
||||
logger.debug(f'Splitting between {len(available_servers)} servers by {method} method')
|
||||
if method == 'evenly':
|
||||
breakdown = divide_frames_equally(start_frame, end_frame, available_servers)
|
||||
elif method == 'cpu_benchmark':
|
||||
breakdown = divide_frames_by_benchmark(start_frame, end_frame, available_servers)
|
||||
elif method == 'cpu_count':
|
||||
breakdown = divide_frames_by_cpu_count(start_frame, end_frame, available_servers)
|
||||
else:
|
||||
raise ValueError(f"Invalid distribution method: {method}")
|
||||
breakdown = divide_frames_by_cpu_count(start_frame, end_frame, available_servers)
|
||||
|
||||
server_breakdown = [server for server in available_servers if breakdown.get(server['hostname']) is not None]
|
||||
for server in server_breakdown:
|
||||
@@ -487,34 +323,17 @@ class DistributedJobManager:
|
||||
return server_breakdown
|
||||
|
||||
@staticmethod
|
||||
def find_available_servers(engine_name, system_os=None):
|
||||
def find_available_servers(renderer):
|
||||
"""
|
||||
Scan the Zeroconf network for currently available render servers supporting a specific engine.
|
||||
Scan the Zeroconf network for currently available render servers supporting a specific renderer.
|
||||
|
||||
:param engine_name: str, The engine type to search for
|
||||
:param system_os: str, Restrict results to servers running a specific OS
|
||||
:param renderer: str, The renderer type to search for
|
||||
:return: A list of dictionaries with each dict containing hostname and cpu_count of available servers
|
||||
"""
|
||||
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)
|
||||
for hostname in ZeroconfServer.found_clients():
|
||||
response = RenderServerProxy(hostname).get_status()
|
||||
if response and response.get('renderers', {}).get(renderer, {}).get('is_available', False):
|
||||
available_servers.append({'hostname': hostname, 'cpu_count': int(response['cpu_count'])})
|
||||
|
||||
return available_servers
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
ZeroconfServer.configure("_zordon._tcp.local.", 'testing', 8080)
|
||||
ZeroconfServer.start(listen_only=True)
|
||||
print("Starting Zeroconf...")
|
||||
time.sleep(2)
|
||||
available_servers = DistributedJobManager.find_available_servers('blender')
|
||||
print(f"AVAILABLE SERVERS ({len(available_servers)}): {available_servers}")
|
||||
# results = DistributedJobManager.distribute_server_work(1, 100, available_servers)
|
||||
# print(f"RESULTS: {results}")
|
||||
ZeroconfServer.stop()
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import glob
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from src.engines.core.base_engine import BaseRenderEngine
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class AERender(BaseRenderEngine):
|
||||
|
||||
file_extensions = ['aepx']
|
||||
|
||||
def version(self):
|
||||
version = None
|
||||
try:
|
||||
render_path = self.renderer_path()
|
||||
if render_path:
|
||||
ver_out = subprocess.run([render_path, '-version'], capture_output=True, text=True)
|
||||
version = ver_out.stdout.split(" ")[-1].strip()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get {self.name()} version: {e}')
|
||||
return version
|
||||
|
||||
@classmethod
|
||||
def default_renderer_path(cls):
|
||||
paths = glob.glob('/Applications/*After Effects*/aerender')
|
||||
if len(paths) > 1:
|
||||
logger.warning('Multiple After Effects installations detected')
|
||||
elif not paths:
|
||||
logger.error('After Effects installation not found')
|
||||
return paths[0]
|
||||
|
||||
def get_project_info(self, project_path, timeout=10):
|
||||
scene_info = {}
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
tree = ET.parse(project_path)
|
||||
root = tree.getroot()
|
||||
namespace = {'ae': 'http://www.adobe.com/products/aftereffects'}
|
||||
|
||||
comp_names = []
|
||||
for item in root.findall(".//ae:Item", namespace):
|
||||
if item.find("ae:Layr", namespace) is not None:
|
||||
for string in item.findall("./ae:string", namespace):
|
||||
comp_names.append(string.text)
|
||||
scene_info['comp_names'] = comp_names
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting file details for .aepx file: {e}')
|
||||
return scene_info
|
||||
|
||||
def run_javascript(self, script_path, project_path, timeout=None):
|
||||
# todo: implement
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_output_formats(cls):
|
||||
# todo: create implementation
|
||||
return []
|
||||
|
||||
def ui_options(self, project_info):
|
||||
from src.engines.aerender.aerender_ui import AERenderUI
|
||||
return AERenderUI.get_options(self, project_info)
|
||||
|
||||
@classmethod
|
||||
def worker_class(cls):
|
||||
from src.engines.aerender.aerender_worker import AERenderWorker
|
||||
return AERenderWorker
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
x = AERender().get_project_info('/Users/brett/ae_testing/project.aepx')
|
||||
print(x)
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
class AERenderUI:
|
||||
@staticmethod
|
||||
def get_options(instance, project_info):
|
||||
options = [
|
||||
{'name': 'comp', 'options': project_info.get('comp_names', [])}
|
||||
]
|
||||
return options
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from src.engines.core.base_worker import BaseRenderWorker, timecode_to_frames
|
||||
from src.engines.aerender.aerender_engine import AERender
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class AERenderWorker(BaseRenderWorker):
|
||||
|
||||
engine = AERender
|
||||
|
||||
def __init__(self, input_path, output_path, engine_path, args=None, parent=None, name=None):
|
||||
super(AERenderWorker, self).__init__(input_path=input_path, output_path=output_path, engine_path=engine_path,
|
||||
args=args, parent=parent, name=name)
|
||||
|
||||
# temp files for processing stdout
|
||||
self.__progress_history = []
|
||||
self.__temp_attributes = {}
|
||||
|
||||
def generate_worker_subprocess(self):
|
||||
|
||||
comp = self.args.get('comp', 'Comp 1')
|
||||
render_settings = self.args.get('render_settings', None)
|
||||
omsettings = self.args.get('omsettings', None)
|
||||
|
||||
command = [self.renderer_path, '-project', self.input_path, '-comp', f'"{comp}"']
|
||||
|
||||
if render_settings:
|
||||
command.extend(['-RStemplate', render_settings])
|
||||
|
||||
if omsettings:
|
||||
command.extend(['-OMtemplate', omsettings])
|
||||
|
||||
command.extend(['-s', self.start_frame,
|
||||
'-e', self.end_frame,
|
||||
'-output', self.output_path])
|
||||
return command
|
||||
|
||||
def _parse_stdout(self, line):
|
||||
|
||||
# print line
|
||||
if line.startswith('PROGRESS:'):
|
||||
# print 'progress'
|
||||
trimmed = line.replace('PROGRESS:', '').strip()
|
||||
if len(trimmed):
|
||||
self.__progress_history.append(line)
|
||||
if 'Seconds' in trimmed:
|
||||
self._update_progress(line)
|
||||
elif ': ' in trimmed:
|
||||
tmp = trimmed.split(': ')
|
||||
self.__temp_attributes[tmp[0].strip()] = tmp[1].strip()
|
||||
elif line.startswith('WARNING:'):
|
||||
trimmed = line.replace('WARNING:', '').strip()
|
||||
self.warnings.append(trimmed)
|
||||
logging.warning(trimmed)
|
||||
elif line.startswith('aerender ERROR') or 'ERROR:' in line:
|
||||
self.log_error(line)
|
||||
|
||||
def _update_progress(self, line):
|
||||
|
||||
if not self.total_frames:
|
||||
duration_string = self.__temp_attributes.get('Duration', None)
|
||||
frame_rate = self.__temp_attributes.get('Frame Rate', '0').split(' ')[0]
|
||||
self.total_frames = timecode_to_frames(duration_string.split('Duration:')[-1], float(frame_rate))
|
||||
|
||||
match = re.match(r'PROGRESS:.*\((?P<frame>\d+)\): (?P<time>\d+)', line).groupdict()
|
||||
self.current_frame = match['frame']
|
||||
|
||||
def average_frame_duration(self):
|
||||
|
||||
total_durations = 0
|
||||
|
||||
for line in self.__progress_history:
|
||||
match = re.match(r'PROGRESS:.*\((?P<frame>\d+)\): (?P<time>\d+)', line)
|
||||
if match:
|
||||
total_durations += int(match.group(2))
|
||||
|
||||
average = float(total_durations) / self.current_frame
|
||||
return average
|
||||
|
||||
def percent_complete(self):
|
||||
if self.total_frames:
|
||||
return (float(self.current_frame) / float(self.total_frames)) * 100
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(format='%(asctime)s - %(message)s', datefmt='%d-%b-%y %H:%M:%S', level=logging.DEBUG)
|
||||
|
||||
r = AERenderWorker(input_path='/Users/brett/ae_testing/project.aepx',
|
||||
output_path='/Users/brett/ae_testing/project.mp4',
|
||||
engine_path=AERenderWorker.engine.default_renderer_path(),
|
||||
args={'start_frame': 1, 'end_frame': 5})
|
||||
|
||||
r.start()
|
||||
while r.is_running():
|
||||
time.sleep(0.1)
|
||||
25
src/engines/aerender_engine.py
Normal file
@@ -0,0 +1,25 @@
|
||||
try:
|
||||
from .base_engine import *
|
||||
except ImportError:
|
||||
from base_engine import *
|
||||
|
||||
|
||||
class AERender(BaseRenderEngine):
|
||||
|
||||
supported_extensions = ['.aep']
|
||||
|
||||
def version(self):
|
||||
version = None
|
||||
try:
|
||||
render_path = self.renderer_path()
|
||||
if render_path:
|
||||
ver_out = subprocess.check_output([render_path, '-version'], timeout=SUBPROCESS_TIMEOUT)
|
||||
version = ver_out.decode('utf-8').split(" ")[-1].strip()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get {self.name()} version: {e}')
|
||||
return version
|
||||
|
||||
@classmethod
|
||||
def get_output_formats(cls):
|
||||
# todo: create implementation
|
||||
return []
|
||||
56
src/engines/base_engine.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger()
|
||||
SUBPROCESS_TIMEOUT = 5
|
||||
|
||||
|
||||
class BaseRenderEngine(object):
|
||||
|
||||
install_paths = []
|
||||
supported_extensions = []
|
||||
|
||||
def __init__(self, custom_path=None):
|
||||
self.custom_renderer_path = custom_path
|
||||
if not self.renderer_path():
|
||||
raise FileNotFoundError(f"Cannot find path to renderer for {self.name()} instance")
|
||||
|
||||
def renderer_path(self):
|
||||
return self.custom_renderer_path or self.default_renderer_path()
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
return cls.__name__.lower()
|
||||
|
||||
@classmethod
|
||||
def default_renderer_path(cls):
|
||||
path = None
|
||||
try: # Linux and macOS
|
||||
path = subprocess.check_output(['which', cls.name()], timeout=SUBPROCESS_TIMEOUT).decode('utf-8').strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
for p in cls.install_paths:
|
||||
if os.path.exists(p):
|
||||
path = p
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return path
|
||||
|
||||
def version(self):
|
||||
raise NotImplementedError("version not implemented")
|
||||
|
||||
def get_help(self):
|
||||
path = self.renderer_path()
|
||||
if not path:
|
||||
raise FileNotFoundError("renderer path not found")
|
||||
help_doc = subprocess.check_output([path, '-h'], stderr=subprocess.STDOUT,
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
return help_doc
|
||||
|
||||
@classmethod
|
||||
def get_output_formats(cls):
|
||||
raise NotImplementedError(f"get_output_formats not implemented for {cls.__name__}")
|
||||
|
||||
@classmethod
|
||||
def get_arguments(cls):
|
||||
pass
|
||||
@@ -1,147 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
|
||||
import requests
|
||||
|
||||
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/"
|
||||
url = "https://ftp.nluug.nl/pub/graphics/blender/release/" # much faster mirror for testing
|
||||
|
||||
logger = logging.getLogger()
|
||||
supported_formats = ['.zip', '.tar.xz', '.dmg']
|
||||
|
||||
|
||||
class BlenderDownloader(EngineDownloader):
|
||||
|
||||
engine = Blender
|
||||
|
||||
@staticmethod
|
||||
def __get_major_versions():
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
# Use regex to find all the <a> tags and extract the href attribute
|
||||
link_pattern = r'<a href="([^"]+)">Blender(\d+[^<]+)</a>'
|
||||
link_matches = re.findall(link_pattern, response.text)
|
||||
|
||||
major_versions = [link[-1].strip('/') for link in link_matches]
|
||||
major_versions.sort(reverse=True)
|
||||
return major_versions
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def __get_minor_versions(major_version, system_os=None, cpu=None):
|
||||
|
||||
try:
|
||||
base_url = url + 'Blender' + major_version
|
||||
response = requests.get(base_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
versions_pattern = \
|
||||
r'<a href="(?P<file>[^"]+)">blender-(?P<version>[\d\.]+)-(?P<system_os>\w+)-(?P<cpu>\w+).*</a>'
|
||||
versions_data = [match.groupdict() for match in re.finditer(versions_pattern, response.text)]
|
||||
|
||||
# Filter to just the supported formats
|
||||
versions_data = [item for item in versions_data if any(item["file"].endswith(ext) for ext in
|
||||
supported_formats)]
|
||||
|
||||
# Filter down OS and CPU
|
||||
system_os = system_os or current_system_os()
|
||||
cpu = cpu or current_system_cpu()
|
||||
versions_data = [x for x in versions_data if x['system_os'] == system_os]
|
||||
versions_data = [x for x in versions_data if x['cpu'] == cpu]
|
||||
|
||||
for v in versions_data:
|
||||
v['url'] = base_url + '/' + v['file']
|
||||
|
||||
versions_data = sorted(versions_data, key=lambda x: x['version'], reverse=True)
|
||||
return versions_data
|
||||
except requests.exceptions.HTTPError as e:
|
||||
logger.error(f"Invalid url: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def __find_LTS_versions():
|
||||
response = requests.get('https://www.blender.org/download/lts/')
|
||||
response.raise_for_status()
|
||||
|
||||
lts_pattern = r'https://www.blender.org/download/lts/(\d+-\d+)/'
|
||||
lts_matches = re.findall(lts_pattern, response.text)
|
||||
lts_versions = [ver.replace('-', '.') for ver in list(set(lts_matches))]
|
||||
lts_versions.sort(reverse=True)
|
||||
|
||||
return lts_versions
|
||||
|
||||
@classmethod
|
||||
def all_versions(cls, system_os=None, cpu=None):
|
||||
majors = cls.__get_major_versions()
|
||||
all_versions = []
|
||||
threads = []
|
||||
results = [[] for _ in majors]
|
||||
|
||||
def thread_function(major_version, index, system_os, cpu):
|
||||
results[index] = cls.__get_minor_versions(major_version, system_os, cpu)
|
||||
|
||||
for i, m in enumerate(majors):
|
||||
thread = threading.Thread(target=thread_function, args=(m, i, system_os, cpu))
|
||||
threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
# Wait for all threads to complete
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Extend all_versions with the results from each thread
|
||||
for result in results:
|
||||
all_versions.extend(result)
|
||||
|
||||
return all_versions
|
||||
|
||||
@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)
|
||||
return most_recent[0]
|
||||
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):
|
||||
system_os = system_os or current_system_os()
|
||||
cpu = cpu or current_system_cpu()
|
||||
|
||||
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]
|
||||
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")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
print(BlenderDownloader.find_most_recent_version())
|
||||
@@ -1,9 +0,0 @@
|
||||
|
||||
class BlenderUI:
|
||||
@staticmethod
|
||||
def get_options(instance):
|
||||
options = [
|
||||
{'name': 'engine', 'options': instance.supported_render_engines()},
|
||||
{'name': 'render_device', 'options': ['Any', 'GPU', 'CPU']},
|
||||
]
|
||||
return options
|
||||
@@ -1,17 +0,0 @@
|
||||
import bpy
|
||||
import json
|
||||
|
||||
# Ensure Cycles is available
|
||||
bpy.context.preferences.addons['cycles'].preferences.get_devices()
|
||||
|
||||
# Collect the devices information
|
||||
devices_info = []
|
||||
for device in bpy.context.preferences.addons['cycles'].preferences.devices:
|
||||
devices_info.append({
|
||||
"name": device.name,
|
||||
"type": device.type,
|
||||
"use": device.use
|
||||
})
|
||||
|
||||
# Print the devices information in JSON format
|
||||
print("GPU DATA:" + json.dumps(devices_info))
|
||||
@@ -1,8 +1,10 @@
|
||||
try:
|
||||
from .base_engine import *
|
||||
except ImportError:
|
||||
from base_engine import *
|
||||
import json
|
||||
import re
|
||||
|
||||
from src.engines.core.base_engine import *
|
||||
from src.utilities.misc_helper import system_safe_path
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -10,22 +12,8 @@ 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'}
|
||||
file_extensions = ['blend']
|
||||
|
||||
@staticmethod
|
||||
def downloader():
|
||||
from src.engines.blender.blender_downloader import BlenderDownloader
|
||||
return BlenderDownloader
|
||||
|
||||
@classmethod
|
||||
def worker_class(cls):
|
||||
from src.engines.blender.blender_worker import BlenderRenderWorker
|
||||
return BlenderRenderWorker
|
||||
|
||||
def ui_options(self, project_info):
|
||||
from src.engines.blender.blender_ui import BlenderUI
|
||||
return BlenderUI.get_options(self)
|
||||
|
||||
def version(self):
|
||||
version = None
|
||||
@@ -53,27 +41,26 @@ class Blender(BaseRenderEngine):
|
||||
else:
|
||||
raise FileNotFoundError(f'Project file not found: {project_path}')
|
||||
|
||||
def run_python_script(self, script_path, project_path=None, timeout=None):
|
||||
|
||||
if project_path and not os.path.exists(project_path):
|
||||
def run_python_script(self, project_path, script_path, timeout=None):
|
||||
if os.path.exists(project_path) and os.path.exists(script_path):
|
||||
try:
|
||||
return subprocess.run([self.renderer_path(), '-b', project_path, '--python', script_path],
|
||||
capture_output=True, timeout=timeout)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running python script in blender: {e}")
|
||||
pass
|
||||
elif not os.path.exists(project_path):
|
||||
raise FileNotFoundError(f'Project file not found: {project_path}')
|
||||
elif not os.path.exists(script_path):
|
||||
raise FileNotFoundError(f'Python script not found: {script_path}')
|
||||
raise Exception("Uncaught exception")
|
||||
|
||||
try:
|
||||
command = [self.renderer_path(), '-b', '--python', script_path]
|
||||
if project_path:
|
||||
command.insert(2, project_path)
|
||||
return subprocess.run(command, capture_output=True, timeout=timeout)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error running python script in blender: {e}")
|
||||
|
||||
def get_project_info(self, project_path, timeout=10):
|
||||
def get_scene_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')
|
||||
results = self.run_python_script(project_path=project_path, script_path=system_safe_path(script_path),
|
||||
timeout=timeout)
|
||||
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),
|
||||
'scripts', 'blender', 'get_file_info.py')
|
||||
results = self.run_python_script(project_path, system_safe_path(script_path), timeout=timeout)
|
||||
result_text = results.stdout.decode()
|
||||
for line in result_text.splitlines():
|
||||
if line.startswith('SCENE_DATA:'):
|
||||
@@ -90,9 +77,9 @@ class Blender(BaseRenderEngine):
|
||||
# Credit to L0Lock for pack script - https://blender.stackexchange.com/a/243935
|
||||
try:
|
||||
logger.info(f"Starting to pack Blender file: {project_path}")
|
||||
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'pack_project.py')
|
||||
results = self.run_python_script(project_path=project_path, script_path=system_safe_path(script_path),
|
||||
timeout=timeout)
|
||||
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),
|
||||
'scripts', 'blender', 'pack_project.py')
|
||||
results = self.run_python_script(project_path, system_safe_path(script_path), timeout=timeout)
|
||||
|
||||
result_text = results.stdout.decode()
|
||||
dir_name = os.path.dirname(project_path)
|
||||
@@ -112,7 +99,7 @@ class Blender(BaseRenderEngine):
|
||||
logger.error(f'Error packing .blend file: {e}')
|
||||
return None
|
||||
|
||||
def get_arguments(self): # possibly deprecate
|
||||
def get_arguments(self):
|
||||
help_text = subprocess.check_output([self.renderer_path(), '-h']).decode('utf-8')
|
||||
lines = help_text.splitlines()
|
||||
|
||||
@@ -144,20 +131,11 @@ class Blender(BaseRenderEngine):
|
||||
|
||||
return options
|
||||
|
||||
def system_info(self):
|
||||
return {'render_devices': self.get_render_devices()}
|
||||
|
||||
def get_render_devices(self):
|
||||
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_system_info.py')
|
||||
results = self.run_python_script(script_path=script_path)
|
||||
output = results.stdout.decode()
|
||||
match = re.search(r"GPU DATA:(\[[\s\S]*\])", output)
|
||||
if match:
|
||||
gpu_data_json = match.group(1)
|
||||
gpus_info = json.loads(gpu_data_json)
|
||||
return gpus_info
|
||||
else:
|
||||
logger.error("GPU data not found in the output.")
|
||||
def get_detected_gpus(self):
|
||||
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)
|
||||
return gpu_names
|
||||
|
||||
def supported_render_engines(self):
|
||||
engine_output = subprocess.run([self.renderer_path(), '-E', 'help'], timeout=SUBPROCESS_TIMEOUT,
|
||||
@@ -165,11 +143,7 @@ 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_render_devices()
|
||||
x = Blender.get_detected_gpus()
|
||||
print(x)
|
||||
@@ -1,161 +0,0 @@
|
||||
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:
|
||||
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)} '
|
||||
f'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}")
|
||||
@@ -1,82 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger()
|
||||
SUBPROCESS_TIMEOUT = 5
|
||||
|
||||
|
||||
class BaseRenderEngine(object):
|
||||
|
||||
install_paths = []
|
||||
file_extensions = []
|
||||
|
||||
def __init__(self, custom_path=None):
|
||||
self.custom_renderer_path = custom_path
|
||||
if not self.renderer_path() or not os.path.exists(self.renderer_path()):
|
||||
raise FileNotFoundError(f"Cannot find path ({self.renderer_path()}) for renderer '{self.name()}'")
|
||||
|
||||
if not os.access(self.renderer_path(), os.X_OK):
|
||||
logger.warning(f"Path is not executable. Setting permissions to 755 for {self.renderer_path()}")
|
||||
os.chmod(self.renderer_path(), 0o755)
|
||||
|
||||
def renderer_path(self):
|
||||
return self.custom_renderer_path or self.default_renderer_path()
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
return str(cls.__name__).lower()
|
||||
|
||||
@classmethod
|
||||
def default_renderer_path(cls):
|
||||
path = None
|
||||
try: # Linux and macOS
|
||||
path = subprocess.check_output(['which', cls.name()], timeout=SUBPROCESS_TIMEOUT).decode('utf-8').strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
for p in cls.install_paths:
|
||||
if os.path.exists(p):
|
||||
path = p
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return path
|
||||
|
||||
def version(self):
|
||||
raise NotImplementedError("version not implemented")
|
||||
|
||||
@staticmethod
|
||||
def downloader(): # override when subclassing if using a downloader class
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def worker_class(cls): # override when subclassing to link worker class
|
||||
raise NotImplementedError(f"Worker class not implemented for engine {cls.name()}")
|
||||
|
||||
def ui_options(self, project_info): # 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")
|
||||
help_doc = subprocess.run([path, '-h'], capture_output=True, text=True).stdout.strip()
|
||||
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__}")
|
||||
|
||||
@classmethod
|
||||
def supported_extensions(cls):
|
||||
return cls.file_extensions
|
||||
|
||||
def get_arguments(self):
|
||||
pass
|
||||
|
||||
def system_info(self):
|
||||
pass
|
||||
|
||||
def perform_presubmission_tasks(self, project_path):
|
||||
return project_path
|
||||
102
src/engines/downloaders/blender_downloader.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from .downloader_core import download_and_extract_app
|
||||
|
||||
# url = "https://download.blender.org/release/"
|
||||
url = "https://ftp.nluug.nl/pub/graphics/blender/release/" # much faster mirror for testing
|
||||
|
||||
logger = logging.getLogger()
|
||||
supported_formats = ['.zip', '.tar.xz', '.dmg']
|
||||
|
||||
|
||||
class BlenderDownloader:
|
||||
|
||||
@staticmethod
|
||||
def get_major_versions():
|
||||
try:
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Use regex to find all the <a> tags and extract the href attribute
|
||||
link_pattern = r'<a href="([^"]+)">Blender(\d+[^<]+)</a>'
|
||||
link_matches = re.findall(link_pattern, response.text)
|
||||
|
||||
major_versions = [link[-1].strip('/') for link in link_matches]
|
||||
major_versions.sort(reverse=True)
|
||||
return major_versions
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_minor_versions(major_version, system_os=None, cpu=None):
|
||||
|
||||
base_url = url + 'Blender' + major_version
|
||||
|
||||
response = requests.get(base_url)
|
||||
response.raise_for_status()
|
||||
|
||||
versions_pattern = r'<a href="(?P<file>[^"]+)">blender-(?P<version>[\d\.]+)-(?P<system_os>\w+)-(?P<cpu>\w+).*</a>'
|
||||
versions_data = [match.groupdict() for match in re.finditer(versions_pattern, response.text)]
|
||||
|
||||
# Filter to just the supported formats
|
||||
versions_data = [item for item in versions_data if any(item["file"].endswith(ext) for ext in supported_formats)]
|
||||
|
||||
if system_os:
|
||||
versions_data = [x for x in versions_data if x['system_os'] == system_os]
|
||||
if cpu:
|
||||
versions_data = [x for x in versions_data if x['cpu'] == cpu]
|
||||
|
||||
for v in versions_data:
|
||||
v['url'] = base_url + '/' + v['file']
|
||||
|
||||
versions_data = sorted(versions_data, key=lambda x: x['version'], reverse=True)
|
||||
return versions_data
|
||||
|
||||
@staticmethod
|
||||
def find_LTS_versions():
|
||||
response = requests.get('https://www.blender.org/download/lts/')
|
||||
response.raise_for_status()
|
||||
|
||||
lts_pattern = r'https://www.blender.org/download/lts/(\d+-\d+)/'
|
||||
lts_matches = re.findall(lts_pattern, response.text)
|
||||
lts_versions = [ver.replace('-', '.') for ver in list(set(lts_matches))]
|
||||
lts_versions.sort(reverse=True)
|
||||
|
||||
return lts_versions
|
||||
|
||||
@classmethod
|
||||
def find_most_recent_version(cls, system_os, cpu, 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, system_os, cpu)[0]
|
||||
return most_recent
|
||||
except IndexError:
|
||||
logger.error("Cannot find a most recent version")
|
||||
|
||||
@classmethod
|
||||
def download_engine(cls, version, download_location, system_os=None, cpu=None):
|
||||
system_os = system_os or platform.system().lower().replace('darwin', 'macos')
|
||||
cpu = cpu or platform.machine().lower().replace('amd64', 'x64')
|
||||
|
||||
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]
|
||||
# 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)
|
||||
except IndexError:
|
||||
logger.error("Cannot find requested engine")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
print(BlenderDownloader.get_major_versions())
|
||||
|
||||
138
src/engines/downloaders/downloader_core.py
Normal file
@@ -0,0 +1,138 @@
|
||||
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):
|
||||
|
||||
# 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)
|
||||
|
||||
# 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}")
|
||||
|
||||
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}")
|
||||
112
src/engines/downloaders/ffmpeg_downloader.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from .downloader_core import download_and_extract_app
|
||||
|
||||
logger = logging.getLogger()
|
||||
supported_formats = ['.zip', '.tar.xz', '.dmg']
|
||||
|
||||
|
||||
class FFMPEGDownloader:
|
||||
|
||||
# macOS FFMPEG mirror maintained by Evermeet - https://evermeet.cx/ffmpeg/
|
||||
macos_url = "https://evermeet.cx/pub/ffmpeg/"
|
||||
|
||||
# Linux FFMPEG mirror maintained by John van Sickle - https://johnvansickle.com/ffmpeg/
|
||||
linux_url = "https://johnvansickle.com/ffmpeg/"
|
||||
|
||||
# macOS FFMPEG mirror maintained by GyanD - https://www.gyan.dev/ffmpeg/builds/
|
||||
windows_download_url = "https://github.com/GyanD/codexffmpeg/releases/download/"
|
||||
windows_api_url = "https://api.github.com/repos/GyanD/codexffmpeg/releases"
|
||||
|
||||
@classmethod
|
||||
def get_macos_versions(cls):
|
||||
response = requests.get(cls.macos_url)
|
||||
response.raise_for_status()
|
||||
|
||||
link_pattern = r'>(.*\.zip)[^\.]'
|
||||
link_matches = re.findall(link_pattern, response.text)
|
||||
|
||||
return [link.split('-')[-1].split('.zip')[0] for link in link_matches]
|
||||
|
||||
@classmethod
|
||||
def get_linux_versions(cls):
|
||||
|
||||
# Link 1 / 2 - Current Version
|
||||
response = requests.get(cls.linux_url)
|
||||
response.raise_for_status()
|
||||
current_release = re.findall(r'release: ([\w\.]+)', response.text)[0]
|
||||
|
||||
# Link 2 / 2 - Previous Versions
|
||||
response = requests.get(os.path.join(cls.linux_url, 'old-releases'))
|
||||
response.raise_for_status()
|
||||
releases = list(set(re.findall(r'href="ffmpeg-([\w\.]+)-.*">ffmpeg', response.text)))
|
||||
releases.sort(reverse=True)
|
||||
releases.insert(0, current_release)
|
||||
return releases
|
||||
|
||||
@classmethod
|
||||
def get_windows_versions(cls):
|
||||
response = requests.get(cls.windows_api_url)
|
||||
response.raise_for_status()
|
||||
|
||||
versions = []
|
||||
all_git_releases = response.json()
|
||||
for item in all_git_releases:
|
||||
if re.match(r'^[0-9.]+$', item['tag_name']):
|
||||
versions.append(item['tag_name'])
|
||||
return versions
|
||||
|
||||
@classmethod
|
||||
def find_most_recent_version(cls, system_os, cpu, lts_only=False):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def download_engine(cls, version, download_location, system_os=None, cpu=None):
|
||||
system_os = system_os or platform.system().lower().replace('darwin', 'macos')
|
||||
cpu = cpu or platform.machine().lower().replace('amd64', 'x64')
|
||||
|
||||
# Verify requested version is available
|
||||
remote_url = None
|
||||
versions_per_os = {'linux': cls.get_linux_versions, 'macos': cls.get_macos_versions, 'windows': cls.get_windows_versions}
|
||||
if not versions_per_os.get(system_os):
|
||||
logger.error(f"Cannot find version list for {system_os}")
|
||||
return
|
||||
if version not in versions_per_os[system_os]():
|
||||
logger.error(f"Cannot find FFMPEG version {version} for {system_os}")
|
||||
|
||||
# Platform specific naming cleanup
|
||||
if system_os == 'macos':
|
||||
remote_url = os.path.join(cls.macos_url, f"ffmpeg-{version}.zip")
|
||||
download_location = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}') # override location to match linux
|
||||
elif system_os == 'linux':
|
||||
release_dir = 'releases' if version == cls.get_linux_versions()[0] else 'old-releases'
|
||||
remote_url = os.path.join(cls.linux_url, release_dir, f'ffmpeg-{version}-{cpu}-static.tar.xz')
|
||||
elif system_os == 'windows':
|
||||
remote_url = f"{cls.windows_download_url.strip('/')}/{version}/ffmpeg-{version}-full_build.zip"
|
||||
|
||||
# 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)
|
||||
|
||||
# naming cleanup to match existing naming convention
|
||||
if system_os == 'linux':
|
||||
os.rename(os.path.join(download_location, f'ffmpeg-{version}-{cpu}-static'),
|
||||
os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}'))
|
||||
elif system_os == 'windows':
|
||||
os.rename(os.path.join(download_location, f'ffmpeg-{version}-full_build'),
|
||||
os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}'))
|
||||
|
||||
except IndexError:
|
||||
logger.error("Cannot download requested engine")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
# print(FFMPEGDownloader.download_engine('6.0', '/Users/brett/zordon-uploads/engines/'))
|
||||
print(FFMPEGDownloader.download_engine(version='6.0', download_location='/Users/brett/zordon-uploads/engines/'))
|
||||
@@ -1,303 +1,155 @@
|
||||
import logging
|
||||
import os
|
||||
import logging
|
||||
import platform
|
||||
import shutil
|
||||
import threading
|
||||
import concurrent.futures
|
||||
from .downloaders.blender_downloader import BlenderDownloader
|
||||
from .downloaders.ffmpeg_downloader import FFMPEGDownloader
|
||||
from ..utilities.misc_helper import system_safe_path
|
||||
|
||||
from src.engines.blender.blender_engine import Blender
|
||||
from src.engines.ffmpeg.ffmpeg_engine import FFMPEG
|
||||
from src.engines.aerender.aerender_engine import AERender
|
||||
from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu
|
||||
try:
|
||||
from .blender_engine import Blender
|
||||
except ImportError:
|
||||
from blender_engine import Blender
|
||||
try:
|
||||
from .ffmpeg_engine import FFMPEG
|
||||
except ImportError:
|
||||
from ffmpeg_engine import FFMPEG
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
class EngineManager:
|
||||
|
||||
engines_path = None
|
||||
download_tasks = []
|
||||
|
||||
@staticmethod
|
||||
def supported_engines():
|
||||
return [Blender, FFMPEG, AERender]
|
||||
engines_path = "~/zordon-uploads/engines"
|
||||
|
||||
@classmethod
|
||||
def engine_with_name(cls, engine_name):
|
||||
for obj in cls.supported_engines():
|
||||
if obj.name().lower() == engine_name.lower():
|
||||
return obj
|
||||
def supported_engines(cls):
|
||||
return [Blender, FFMPEG]
|
||||
|
||||
@classmethod
|
||||
def get_engines(cls, filter_name=None):
|
||||
|
||||
if not cls.engines_path:
|
||||
raise FileNotFoundError("Engine path is not set")
|
||||
|
||||
# Parse downloaded engine directory
|
||||
def all_engines(cls):
|
||||
results = []
|
||||
# Parse downloaded engine directory
|
||||
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))]
|
||||
keys = ["engine", "version", "system_os", "cpu"] # Define keys for result dictionary
|
||||
|
||||
for directory in all_directories:
|
||||
# Split directory name into segments
|
||||
# Split the input string by dashes to get segments
|
||||
segments = directory.split('-')
|
||||
# Create a dictionary mapping keys to corresponding segments
|
||||
|
||||
# Create a dictionary with named keys
|
||||
keys = ["engine", "version", "system_os", "cpu"]
|
||||
result_dict = {keys[i]: segments[i] for i in range(min(len(keys), len(segments)))}
|
||||
result_dict['type'] = 'managed'
|
||||
|
||||
# Initialize binary_name with engine name
|
||||
# Figure out the binary name for the path
|
||||
binary_name = result_dict['engine'].lower()
|
||||
# Determine the correct binary name based on the engine and system_os
|
||||
for eng in cls.supported_engines():
|
||||
if eng.name().lower() == result_dict['engine']:
|
||||
binary_name = eng.binary_names.get(result_dict['system_os'], binary_name)
|
||||
|
||||
# Find path to binary
|
||||
path = None
|
||||
for root, _, files in os.walk(system_safe_path(os.path.join(cls.engines_path, directory))):
|
||||
if binary_name in files:
|
||||
path = os.path.join(root, binary_name)
|
||||
break
|
||||
|
||||
# Find the path to the binary file
|
||||
path = next(
|
||||
(os.path.join(root, binary_name) for root, _, files in
|
||||
os.walk(system_safe_path(os.path.join(cls.engines_path, directory))) if binary_name in files),
|
||||
None
|
||||
)
|
||||
|
||||
result_dict['path'] = path
|
||||
# Add the result dictionary to results if it matches the filter_name or if no filter is applied
|
||||
if not filter_name or filter_name == result_dict['engine']:
|
||||
results.append(result_dict)
|
||||
except FileNotFoundError as e:
|
||||
logger.warning(f"Cannot find local engines download directory: {e}")
|
||||
results.append(result_dict)
|
||||
except FileNotFoundError:
|
||||
logger.warning("Cannot find local engines download directory")
|
||||
|
||||
# add system installs to this list - use bg thread because it can be slow
|
||||
def fetch_engine_details(eng):
|
||||
return {
|
||||
'engine': eng.name(),
|
||||
'version': eng().version(),
|
||||
'system_os': current_system_os(),
|
||||
'cpu': current_system_cpu(),
|
||||
'path': eng.default_renderer_path(),
|
||||
'type': 'system'
|
||||
}
|
||||
|
||||
if not filter_name:
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
futures = {
|
||||
executor.submit(fetch_engine_details, eng): eng.name()
|
||||
for eng in cls.supported_engines()
|
||||
if eng.default_renderer_path()
|
||||
}
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
results.append(result)
|
||||
else:
|
||||
results.append(fetch_engine_details(cls.engine_with_name(filter_name)))
|
||||
# add system installs to this list
|
||||
for eng in cls.supported_engines():
|
||||
if eng.default_renderer_path():
|
||||
results.append({'engine': eng.name(), 'version': eng().version(),
|
||||
'system_os': cls.system_os(),
|
||||
'cpu': cls.system_cpu(),
|
||||
'path': eng.default_renderer_path(), 'type': 'system'})
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def all_versions_for_engine(cls, engine_name):
|
||||
versions = cls.get_engines(filter_name=engine_name)
|
||||
sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True)
|
||||
return sorted_versions
|
||||
def all_versions_for_engine(cls, engine):
|
||||
return [x for x in cls.all_engines() if x['engine'] == engine]
|
||||
|
||||
@classmethod
|
||||
def newest_engine_version(cls, engine, system_os=None, cpu=None):
|
||||
system_os = system_os or current_system_os()
|
||||
cpu = cpu or current_system_cpu()
|
||||
system_os = system_os or cls.system_os()
|
||||
cpu = cpu or cls.system_cpu()
|
||||
|
||||
try:
|
||||
filtered = [x for x in cls.all_versions_for_engine(engine) if x['system_os'] == system_os and
|
||||
x['cpu'] == cpu]
|
||||
return filtered[0]
|
||||
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]
|
||||
except IndexError:
|
||||
logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def is_version_downloaded(cls, engine, version, system_os=None, cpu=None):
|
||||
system_os = system_os or current_system_os()
|
||||
cpu = cpu or current_system_cpu()
|
||||
def has_engine_version(cls, engine, version, system_os=None, cpu=None):
|
||||
system_os = system_os or cls.system_os()
|
||||
cpu = cpu or cls.system_cpu()
|
||||
|
||||
filtered = [x for x in cls.get_engines(filter_name=engine) if x['system_os'] == system_os and
|
||||
x['cpu'] == cpu and x['version'] == version]
|
||||
filtered = [x for x in cls.all_engines() if
|
||||
x['engine'] == engine and x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version]
|
||||
return filtered[0] if filtered else False
|
||||
|
||||
@classmethod
|
||||
def version_is_available_to_download(cls, engine, version, system_os=None, cpu=None):
|
||||
try:
|
||||
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:
|
||||
logger.debug(f"Exception in version_is_available_to_download: {e}")
|
||||
return None
|
||||
@staticmethod
|
||||
def system_os():
|
||||
return platform.system().lower().replace('darwin', 'macos')
|
||||
|
||||
@staticmethod
|
||||
def system_cpu():
|
||||
return platform.machine().lower().replace('amd64', 'x64')
|
||||
|
||||
@classmethod
|
||||
def find_most_recent_version(cls, engine=None, system_os=None, cpu=None, lts_only=False):
|
||||
try:
|
||||
downloader = cls.engine_with_name(engine).downloader()
|
||||
return downloader.find_most_recent_version(system_os=system_os, cpu=cpu)
|
||||
except Exception as e:
|
||||
logger.debug(f"Exception in find_most_recent_version: {e}")
|
||||
return None
|
||||
def download_engine(cls, engine, version, system_os=None, cpu=None):
|
||||
existing_download = cls.has_engine_version(engine, version, system_os, cpu)
|
||||
if existing_download:
|
||||
logger.info(f"Requested download of {engine} {version}, but local copy already exists")
|
||||
return existing_download
|
||||
|
||||
@classmethod
|
||||
def get_existing_download_task(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]
|
||||
logger.info(f"Requesting download of {engine} {version}")
|
||||
downloader_classes = {
|
||||
"blender": BlenderDownloader,
|
||||
"ffmpeg": FFMPEGDownloader,
|
||||
# Add more engine types and corresponding downloader classes as needed
|
||||
}
|
||||
|
||||
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 None
|
||||
|
||||
@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.get_existing_download_task(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 it's done downloading
|
||||
# Check if the provided engine type is valid
|
||||
if engine not in downloader_classes:
|
||||
logger.error("No valid engine found")
|
||||
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")
|
||||
|
||||
thread = EngineDownloadWorker(engine, version, system_os, cpu)
|
||||
cls.download_tasks.append(thread)
|
||||
thread.start()
|
||||
# Get the appropriate downloader class based on the engine type
|
||||
downloader_classes[engine].download_engine(version, download_location=cls.engines_path,
|
||||
system_os=system_os, cpu=cpu)
|
||||
|
||||
# Check that engine was properly downloaded
|
||||
found_engine = cls.has_engine_version(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 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)
|
||||
found = cls.has_engine_version(engine, version, system_os, cpu)
|
||||
if found:
|
||||
dir_path = os.path.dirname(found['path'])
|
||||
shutil.rmtree(dir_path, ignore_errors=True)
|
||||
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_class):
|
||||
logger.debug(f"Checking for updates to {engine_class.name()}")
|
||||
latest_version = engine_class.downloader().find_most_recent_version()
|
||||
if latest_version:
|
||||
logger.debug(f"Latest version of {engine_class.name()} available: {latest_version.get('version')}")
|
||||
if not cls.is_version_downloaded(engine_class.name(), latest_version.get('version')):
|
||||
logger.info(f"Downloading latest version of {engine_class.name()}...")
|
||||
cls.download_engine(engine=engine_class.name(), version=latest_version['version'], background=True)
|
||||
else:
|
||||
logger.warning(f"Unable to get check for updates for {engine.name()}")
|
||||
|
||||
logger.info(f"Checking for updates for render engines...")
|
||||
threads = []
|
||||
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 = cls.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 and engine_version != 'latest':
|
||||
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 = cls.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.")
|
||||
else:
|
||||
logger.debug(f"Using latest engine version ({all_versions[0]['version']})")
|
||||
engine_path = all_versions[0]['path']
|
||||
|
||||
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):
|
||||
_, 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)
|
||||
|
||||
|
||||
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.engines_path = "/Users/brettwilliams/zordon-uploads/engines/"
|
||||
# print(EngineManager.is_version_downloaded("ffmpeg", "6.0"))
|
||||
print(EngineManager.get_engines())
|
||||
EngineManager.delete_engine_download('blender', '3.2.1', 'macos', 'a')
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class FFMPEGDownloader(EngineDownloader):
|
||||
|
||||
engine = FFMPEG
|
||||
|
||||
# macOS FFMPEG mirror maintained by Evermeet - https://evermeet.cx/ffmpeg/
|
||||
macos_url = "https://evermeet.cx/pub/ffmpeg/"
|
||||
|
||||
# Linux FFMPEG mirror maintained by John van Sickle - https://johnvansickle.com/ffmpeg/
|
||||
linux_url = "https://johnvansickle.com/ffmpeg/"
|
||||
|
||||
# macOS FFMPEG mirror maintained by GyanD - https://www.gyan.dev/ffmpeg/builds/
|
||||
windows_download_url = "https://github.com/GyanD/codexffmpeg/releases/download/"
|
||||
windows_api_url = "https://api.github.com/repos/GyanD/codexffmpeg/releases"
|
||||
|
||||
# used to cache renderer versions in case they need to be accessed frequently
|
||||
version_cache = {}
|
||||
|
||||
@classmethod
|
||||
def __get_macos_versions(cls, use_cache=True):
|
||||
|
||||
# cache the versions locally
|
||||
version_cache = cls.version_cache.get('macos')
|
||||
if version_cache and use_cache:
|
||||
return version_cache
|
||||
|
||||
response = requests.get(cls.macos_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
link_pattern = r'>(.*\.zip)[^\.]'
|
||||
link_matches = re.findall(link_pattern, response.text)
|
||||
|
||||
releases = [link.split('-')[-1].split('.zip')[0] for link in link_matches]
|
||||
cls.version_cache['macos'] = releases
|
||||
return releases
|
||||
|
||||
@classmethod
|
||||
def __get_linux_versions(cls, use_cache=True):
|
||||
|
||||
# cache the versions locally
|
||||
version_cache = cls.version_cache.get('linux')
|
||||
if version_cache and use_cache:
|
||||
return version_cache
|
||||
|
||||
# Link 1 / 2 - Current Version
|
||||
response = requests.get(cls.linux_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
current_release = re.findall(r'release: ([\w\.]+)', response.text)[0]
|
||||
|
||||
# Link 2 / 2 - Previous Versions
|
||||
response = requests.get(os.path.join(cls.linux_url, 'old-releases'), timeout=5)
|
||||
response.raise_for_status()
|
||||
releases = list(set(re.findall(r'href="ffmpeg-([\w\.]+)-.*">ffmpeg', response.text)))
|
||||
releases.sort(reverse=True)
|
||||
releases.insert(0, current_release)
|
||||
|
||||
# Add to cache
|
||||
cls.version_cache['linux'] = releases
|
||||
return releases
|
||||
|
||||
@classmethod
|
||||
def __get_windows_versions(cls, use_cache=True):
|
||||
|
||||
version_cache = cls.version_cache.get('windows')
|
||||
if version_cache and use_cache:
|
||||
return version_cache
|
||||
|
||||
response = requests.get(cls.windows_api_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
releases = []
|
||||
all_git_releases = response.json()
|
||||
for item in all_git_releases:
|
||||
if re.match(r'^[0-9.]+$', item['tag_name']):
|
||||
releases.append(item['tag_name'])
|
||||
|
||||
cls.version_cache['linux'] = releases
|
||||
return releases
|
||||
|
||||
@classmethod
|
||||
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,
|
||||
'windows': cls.__get_windows_versions}
|
||||
if not versions_per_os.get(system_os):
|
||||
logger.error(f"Cannot find version list for {system_os}")
|
||||
return
|
||||
|
||||
results = []
|
||||
all_versions = versions_per_os[system_os]()
|
||||
for version in all_versions:
|
||||
remote_url = cls.__get_remote_url_for_version(version=version, system_os=system_os, cpu=cpu)
|
||||
results.append({'cpu': cpu, 'file': os.path.basename(remote_url), 'system_os': system_os, 'url': remote_url,
|
||||
'version': version})
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def __get_remote_url_for_version(cls, version, system_os, cpu):
|
||||
# Platform specific naming cleanup
|
||||
remote_url = None
|
||||
if system_os == 'macos':
|
||||
remote_url = os.path.join(cls.macos_url, f"ffmpeg-{version}.zip")
|
||||
elif system_os == 'linux':
|
||||
cpu = cpu.replace('x64', 'amd64') # change cpu to match repo naming convention
|
||||
latest_release = (version == cls.__get_linux_versions(use_cache=True)[0])
|
||||
release_dir = 'releases' if latest_release else 'old-releases'
|
||||
release_filename = f'ffmpeg-release-{cpu}-static.tar.xz' if latest_release else \
|
||||
f'ffmpeg-{version}-{cpu}-static.tar.xz'
|
||||
remote_url = os.path.join(cls.linux_url, release_dir, release_filename)
|
||||
elif system_os == 'windows':
|
||||
remote_url = f"{cls.windows_download_url.strip('/')}/{version}/ffmpeg-{version}-full_build.zip"
|
||||
else:
|
||||
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]
|
||||
if not found_version:
|
||||
logger.error(f"Cannot find FFMPEG version {version} for {system_os} and {cpu}")
|
||||
return
|
||||
|
||||
# Platform specific naming cleanup
|
||||
remote_url = cls.__get_remote_url_for_version(version=version, system_os=system_os, cpu=cpu)
|
||||
if system_os == 'macos': # override location to match linux
|
||||
download_location = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}')
|
||||
|
||||
# Download and extract
|
||||
try:
|
||||
logger.info(f"Requesting download of ffmpeg-{version}-{system_os}-{cpu}")
|
||||
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}')
|
||||
if system_os == 'linux':
|
||||
initial_cpu = cpu.replace('x64', 'amd64') # change cpu to match repo naming convention
|
||||
os.rename(os.path.join(download_location, f'ffmpeg-{version}-{initial_cpu}-static'), output_path)
|
||||
elif system_os == 'windows':
|
||||
os.rename(os.path.join(download_location, f'ffmpeg-{version}-full_build'), output_path)
|
||||
return output_path
|
||||
except (IndexError, FileNotFoundError) as e:
|
||||
logger.error(f"Cannot download requested engine: {e}")
|
||||
except OSError as e:
|
||||
logger.error(f"OS error while processing engine download: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
# print(FFMPEGDownloader.download_engine('6.0', '/Users/brett/zordon-uploads/engines/'))
|
||||
# print(FFMPEGDownloader.find_most_recent_version(system_os='linux'))
|
||||
print(FFMPEGDownloader.download_engine(version='6.0', download_location='/Users/brett/zordon-uploads/engines/',
|
||||
system_os='linux', cpu='x64'))
|
||||
@@ -1,5 +0,0 @@
|
||||
class FFMPEGUI:
|
||||
@staticmethod
|
||||
def get_options(instance, project_info):
|
||||
options = []
|
||||
return options
|
||||
@@ -1,90 +1,30 @@
|
||||
import json
|
||||
try:
|
||||
from .base_engine import *
|
||||
except ImportError:
|
||||
from base_engine import *
|
||||
import re
|
||||
|
||||
from src.engines.core.base_engine import *
|
||||
|
||||
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def worker_class(cls):
|
||||
from src.engines.ffmpeg.ffmpeg_worker import FFMPEGRenderWorker
|
||||
return FFMPEGRenderWorker
|
||||
|
||||
def ui_options(self, project_info):
|
||||
from src.engines.ffmpeg.ffmpeg_ui import FFMPEGUI
|
||||
return FFMPEGUI.get_options(self, project_info)
|
||||
|
||||
@classmethod
|
||||
def supported_extensions(cls):
|
||||
if not cls.file_extensions:
|
||||
help_text = (subprocess.check_output([cls().renderer_path(), '-h', 'full'], stderr=subprocess.STDOUT)
|
||||
.decode('utf-8'))
|
||||
found = re.findall(r'extensions that .* is allowed to access \(default "(.*)"', help_text)
|
||||
found_extensions = set()
|
||||
for match in found:
|
||||
found_extensions.update(match.split(','))
|
||||
cls.file_extensions = list(found_extensions)
|
||||
return cls.file_extensions
|
||||
|
||||
def version(self):
|
||||
version = None
|
||||
try:
|
||||
ver_out = subprocess.check_output([self.renderer_path(), '-version'],
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
match = re.match(r".*version\s*([\w.*]+)\W*", ver_out)
|
||||
match = re.match(".*version\s*(\S+)\s*Copyright", ver_out)
|
||||
if match:
|
||||
version = match.groups()[0]
|
||||
except Exception as e:
|
||||
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
|
||||
]
|
||||
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT, text=True)
|
||||
video_info = json.loads(output)
|
||||
|
||||
# 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')
|
||||
pattern = r'(?P<type>[VASFXBD.]{6})\s+(?P<name>\S{2,})\s+(?P<description>.*)'
|
||||
pattern = '(?P<type>[VASFXBD.]{6})\s+(?P<name>\S{2,})\s+(?P<description>.*)'
|
||||
encoders = [m.groupdict() for m in re.finditer(pattern, raw_stdout)]
|
||||
return encoders
|
||||
|
||||
@@ -95,8 +35,8 @@ class FFMPEG(BaseRenderEngine):
|
||||
def get_all_formats(self):
|
||||
try:
|
||||
formats_raw = subprocess.check_output([self.renderer_path(), '-formats'], stderr=subprocess.DEVNULL,
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
pattern = r'(?P<type>[DE]{1,2})\s+(?P<id>\S{2,})\s+(?P<name>.*)'
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
pattern = '(?P<type>[DE]{1,2})\s+(?P<id>\S{2,})\s+(?P<name>.*)\r'
|
||||
all_formats = [m.groupdict() for m in re.finditer(pattern, formats_raw)]
|
||||
return all_formats
|
||||
except Exception as e:
|
||||
@@ -116,21 +56,19 @@ class FFMPEG(BaseRenderEngine):
|
||||
return found_extensions
|
||||
|
||||
def get_output_formats(self):
|
||||
return [x['id'] for x in self.get_all_formats() if 'E' in x['type'].upper()]
|
||||
return [x 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',
|
||||
'-f', 'null', '-'], stderr=subprocess.STDOUT,
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
'-f', 'null', '-'], stderr=subprocess.STDOUT,
|
||||
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
|
||||
match = re.findall(r'frame=\s*(\d+)', raw_stdout)
|
||||
if match:
|
||||
frame_number = int(match[-1])
|
||||
return frame_number
|
||||
return -1
|
||||
|
||||
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 = {}
|
||||
@@ -157,4 +95,4 @@ class FFMPEG(BaseRenderEngine):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(FFMPEG().get_all_formats())
|
||||
print(FFMPEG().get_all_formats())
|
||||
@@ -2,7 +2,6 @@ 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'})
|
||||
@@ -13,14 +12,14 @@ 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,
|
||||
'is_active': scene.camera.name_full == cam_obj.name_full}
|
||||
'sensor_width': cam_obj.sensor_width}
|
||||
cameras.append(cam)
|
||||
|
||||
scene = bpy.data.scenes[0]
|
||||
data = {'cameras': cameras,
|
||||
'engine': scene.render.engine,
|
||||
'start_frame': scene.frame_start,
|
||||
'end_frame': scene.frame_end,
|
||||
'frame_start': scene.frame_start,
|
||||
'frame_end': scene.frame_end,
|
||||
'resolution_x': scene.render.resolution_x,
|
||||
'resolution_y': scene.render.resolution_y,
|
||||
'resolution_percentage': scene.render.resolution_percentage,
|
||||
78
src/init.py
@@ -1,78 +0,0 @@
|
||||
''' 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 src.api.api_server import start_server
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.render_queue import RenderQueue
|
||||
from src.ui.main_window import MainWindow
|
||||
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.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Load Config YAML
|
||||
Config.setup_config_dir()
|
||||
Config.load_config(system_safe_path(os.path.join(Config.config_dir(), 'config.yaml')))
|
||||
EngineManager.engines_path = system_safe_path(
|
||||
os.path.join(os.path.join(os.path.expanduser(Config.upload_folder),
|
||||
'engines')))
|
||||
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()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Unhandled exception: {e}")
|
||||
return_code = 1
|
||||
finally:
|
||||
RenderQueue.prepare_for_shutdown()
|
||||
return sys.exit(return_code)
|
||||
|
||||
|
||||
class BufferingHandler(logging.Handler, QObject):
|
||||
new_record = pyqtSignal(str)
|
||||
|
||||
def __init__(self, capacity=100):
|
||||
logging.Handler.__init__(self)
|
||||
QObject.__init__(self)
|
||||
self.buffer = deque(maxlen=capacity) # Define a buffer with a fixed capacity
|
||||
|
||||
def emit(self, record):
|
||||
msg = self.format(record)
|
||||
self.buffer.append(msg) # Add message to the buffer
|
||||
self.new_record.emit(msg) # Emit signal
|
||||
|
||||
def get_buffer(self):
|
||||
return list(self.buffer) # Return a copy of the buffer
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
@@ -7,7 +6,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.utilities.status_utils import RenderStatus
|
||||
from src.engines.engine_manager import EngineManager
|
||||
from src.engines.core.base_worker import Base
|
||||
from src.workers.base_worker import Base
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -19,8 +18,10 @@ class JobNotFoundError(Exception):
|
||||
|
||||
|
||||
class RenderQueue:
|
||||
engine = None
|
||||
session = None
|
||||
engine = create_engine('sqlite:///database.db')
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
job_queue = []
|
||||
maximum_renderer_instances = {'blender': 1, 'aerender': 1, 'ffmpeg': 4}
|
||||
last_saved_counts = {}
|
||||
@@ -28,11 +29,19 @@ class RenderQueue:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def start_queue(cls):
|
||||
cls.load_state()
|
||||
|
||||
@classmethod
|
||||
def job_status_change(cls, job_id, status):
|
||||
logger.debug(f"Job status changed: {job_id} -> {status}")
|
||||
|
||||
@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))
|
||||
cls.job_queue.append(render_job)
|
||||
if force_start and render_job.status in (RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED):
|
||||
if force_start:
|
||||
cls.start_job(render_job)
|
||||
cls.session.add(render_job)
|
||||
cls.save_state()
|
||||
@@ -74,12 +83,8 @@ class RenderQueue:
|
||||
cls.save_state()
|
||||
|
||||
@classmethod
|
||||
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
|
||||
def load_state(cls):
|
||||
from src.workers.base_worker import BaseRenderWorker
|
||||
cls.job_queue = cls.session.query(BaseRenderWorker).all()
|
||||
|
||||
@classmethod
|
||||
@@ -88,15 +93,17 @@ class RenderQueue:
|
||||
|
||||
@classmethod
|
||||
def prepare_for_shutdown(cls):
|
||||
logger.debug("Closing session")
|
||||
running_jobs = cls.jobs_with_status(RenderStatus.RUNNING) # cancel all running jobs
|
||||
[cls.cancel_job(job) for job in running_jobs]
|
||||
for job in running_jobs:
|
||||
cls.cancel_job(job)
|
||||
cls.save_state()
|
||||
cls.session.close()
|
||||
|
||||
@classmethod
|
||||
def is_available_for_job(cls, renderer, priority=2):
|
||||
|
||||
if not EngineManager.all_versions_for_engine(renderer):
|
||||
return False
|
||||
|
||||
instances = cls.renderer_instances()
|
||||
higher_priority_jobs = [x for x in cls.running_jobs() if x.priority < priority]
|
||||
max_allowed_instances = cls.maximum_renderer_instances.get(renderer, 1)
|
||||
|
||||
159
src/server_proxy.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
from requests_toolbelt.multipart import MultipartEncoder, MultipartEncoderMonitor
|
||||
|
||||
from src.utilities.status_utils import RenderStatus
|
||||
|
||||
status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green',
|
||||
RenderStatus.NOT_STARTED: "yellow", RenderStatus.SCHEDULED: 'purple',
|
||||
RenderStatus.RUNNING: 'cyan', RenderStatus.WAITING_FOR_SUBJOBS: 'blue'}
|
||||
|
||||
categories = [RenderStatus.RUNNING, RenderStatus.WAITING_FOR_SUBJOBS, RenderStatus.ERROR, RenderStatus.NOT_STARTED,
|
||||
RenderStatus.SCHEDULED, RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.UNDEFINED]
|
||||
|
||||
logger = logging.getLogger()
|
||||
OFFLINE_MAX = 2
|
||||
|
||||
|
||||
class RenderServerProxy:
|
||||
|
||||
def __init__(self, hostname, server_port="8080"):
|
||||
self.hostname = hostname
|
||||
self.port = server_port
|
||||
self.fetched_status_data = None
|
||||
self.__jobs_cache_token = None
|
||||
self.__jobs_cache = []
|
||||
self.__update_in_background = False
|
||||
self.__background_thread = None
|
||||
self.__offline_flags = 0
|
||||
self.update_cadence = 5
|
||||
|
||||
def connect(self):
|
||||
status = self.request_data('status')
|
||||
return status
|
||||
|
||||
def is_online(self):
|
||||
if self.__update_in_background:
|
||||
return self.__offline_flags < OFFLINE_MAX
|
||||
else:
|
||||
return self.connect() is not None
|
||||
|
||||
def status(self):
|
||||
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"
|
||||
|
||||
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
|
||||
return req.json()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f"JSON decode error: {e}")
|
||||
except requests.ReadTimeout as e:
|
||||
logger.warning(f"Timed out: {e}")
|
||||
self.__offline_flags = self.__offline_flags + 1
|
||||
except requests.ConnectionError as e:
|
||||
logger.warning(f"Connection error: {e}")
|
||||
self.__offline_flags = self.__offline_flags + 1
|
||||
except Exception as e:
|
||||
logger.exception(f"Uncaught exception: {e}")
|
||||
return None
|
||||
|
||||
def request(self, payload, timeout=5):
|
||||
return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout)
|
||||
|
||||
def start_background_update(self):
|
||||
self.__update_in_background = True
|
||||
|
||||
def thread_worker():
|
||||
while self.__update_in_background:
|
||||
self.__update_job_cache()
|
||||
time.sleep(self.update_cadence)
|
||||
|
||||
self.__background_thread = threading.Thread(target=thread_worker)
|
||||
self.__background_thread.daemon = True
|
||||
self.__background_thread.start()
|
||||
|
||||
def stop_background_update(self):
|
||||
self.__update_in_background = False
|
||||
|
||||
def get_job_info(self, job_id, timeout=5):
|
||||
return self.request_data(f'job/{job_id}', timeout=timeout)
|
||||
|
||||
def get_all_jobs(self, timeout=5, ignore_token=False):
|
||||
if not self.__update_in_background or ignore_token:
|
||||
self.__update_job_cache(timeout, ignore_token)
|
||||
return self.__jobs_cache.copy() if self.__jobs_cache else None
|
||||
|
||||
def __update_job_cache(self, timeout=5, ignore_token=False):
|
||||
url = f'jobs?token={self.__jobs_cache_token}' if self.__jobs_cache_token and not ignore_token else 'jobs'
|
||||
status_result = self.request_data(url, timeout=timeout)
|
||||
if status_result is not None:
|
||||
sorted_jobs = []
|
||||
for status_category in categories:
|
||||
found_jobs = [x for x in status_result['jobs'] if x['status'] == status_category.value]
|
||||
if found_jobs:
|
||||
sorted_jobs.extend(found_jobs)
|
||||
self.__jobs_cache = sorted_jobs
|
||||
self.__jobs_cache_token = status_result['token']
|
||||
|
||||
def get_data(self, timeout=5):
|
||||
all_data = self.request_data('full_status', timeout=timeout)
|
||||
return all_data
|
||||
|
||||
def cancel_job(self, job_id, confirm=False):
|
||||
return self.request_data(f'job/{job_id}/cancel?confirm={confirm}')
|
||||
|
||||
def get_status(self):
|
||||
return self.request_data('status')
|
||||
|
||||
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',
|
||||
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():
|
||||
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),
|
||||
headers={'Content-Type': 'application/json'})
|
||||
|
||||
# Prepare the form data
|
||||
encoder = MultipartEncoder({
|
||||
'file': (os.path.basename(file_path), open(file_path, 'rb'), 'application/octet-stream'),
|
||||
'json': (None, json.dumps(job_list), 'application/json'),
|
||||
})
|
||||
|
||||
# Create a monitor that will track the upload progress
|
||||
if callback:
|
||||
monitor = MultipartEncoderMonitor(encoder, callback(encoder))
|
||||
else:
|
||||
monitor = MultipartEncoderMonitor(encoder)
|
||||
|
||||
# Send the request
|
||||
headers = {'Content-Type': monitor.content_type}
|
||||
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"
|
||||
return self.download_file(url, filename=save_path)
|
||||
|
||||
@staticmethod
|
||||
def download_file(url, filename):
|
||||
with requests.get(url, stream=True) as r:
|
||||
r.raise_for_status()
|
||||
with open(filename, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
return filename
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
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.notes_group = None
|
||||
self.frame_rate_input = None
|
||||
self.resolution_x_input = None
|
||||
self.renderer_group = None
|
||||
self.output_settings_group = None
|
||||
self.resolution_y_input = None
|
||||
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()
|
||||
self.renderer_version_combo.addItem('latest')
|
||||
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(response_type='full')
|
||||
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.renderer_version_combo.addItem('latest')
|
||||
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
|
||||
# start / end frames
|
||||
self.start_frame_input.setValue(self.project_info.get('start_frame', 0))
|
||||
self.end_frame_input.setValue(self.project_info.get('end_frame', 0))
|
||||
self.start_frame_input.setEnabled(bool(self.project_info.get('start_frame')))
|
||||
self.end_frame_input.setEnabled(bool(self.project_info.get('start_frame')))
|
||||
|
||||
# resolution
|
||||
self.resolution_x_input.setValue(self.project_info.get('resolution_x', 1920))
|
||||
self.resolution_y_input.setValue(self.project_info.get('resolution_y', 1080))
|
||||
self.resolution_x_input.setEnabled(bool(self.project_info.get('resolution_x')))
|
||||
self.resolution_y_input.setEnabled(bool(self.project_info.get('resolution_y')))
|
||||
|
||||
# frame rate
|
||||
self.frame_rate_input.setValue(self.project_info.get('fps', 24))
|
||||
self.frame_rate_input.setEnabled(bool(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(self.project_info)
|
||||
for option in self.current_engine_options:
|
||||
h_layout = QHBoxLayout()
|
||||
label = QLabel(option['name'].replace('_', ' ').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:
|
||||
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(),
|
||||
'engine_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 = None
|
||||
try:
|
||||
result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list,
|
||||
callback=create_callback)
|
||||
except Exception as e:
|
||||
pass
|
||||
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()
|
||||
@@ -1,60 +0,0 @@
|
||||
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)
|
||||
@@ -1,167 +0,0 @@
|
||||
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, launch_url
|
||||
|
||||
|
||||
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_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()]
|
||||
launch_url(engine_info['path'])
|
||||
|
||||
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)
|
||||
@@ -1,30 +0,0 @@
|
||||
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)
|
||||
@@ -1,30 +0,0 @@
|
||||
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)
|
||||
@@ -1,574 +0,0 @@
|
||||
''' app/ui/main_window.py '''
|
||||
import datetime
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import PIL
|
||||
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.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
|
||||
from src.utilities.misc_helper import launch_url
|
||||
|
||||
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.job_list_view = None
|
||||
self.server_info_ram = None
|
||||
self.server_info_cpu = None
|
||||
self.server_info_os = None
|
||||
self.server_info_hostname = None
|
||||
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(300)
|
||||
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:
|
||||
default_image_path = "error.png"
|
||||
before_fetch_hostname = self.current_server_proxy.hostname
|
||||
|
||||
response = self.current_server_proxy.request(f'job/{job_id}/thumbnail?size=big')
|
||||
if response.ok:
|
||||
try:
|
||||
with io.BytesIO(response.content) as image_data_stream:
|
||||
image = Image.open(image_data_stream)
|
||||
if self.current_server_proxy.hostname == before_fetch_hostname and job_id == \
|
||||
self.selected_job_ids()[0]:
|
||||
self.load_image_data(image)
|
||||
return
|
||||
except PIL.UnidentifiedImageError:
|
||||
default_image_path = response.text
|
||||
else:
|
||||
default_image_path = default_image_path or response.text
|
||||
|
||||
self.load_image_path(os.path.join(resources_dir(), default_image_path))
|
||||
|
||||
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):
|
||||
try:
|
||||
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
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
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(), 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(
|
||||
"Console", f"{resources_directory}/Console.png", self.open_console_window)
|
||||
self.topbar.add_button(
|
||||
"Engines", f"{resources_directory}/SoftwareInstaller.png", self.engine_browser)
|
||||
self.topbar.add_separator()
|
||||
self.topbar.add_button(
|
||||
"Stop Job", f"{resources_directory}/StopSign.png", self.stop_job)
|
||||
self.topbar.add_button(
|
||||
"Delete Job", f"{resources_directory}/Trash.png", self.delete_job)
|
||||
self.topbar.add_button(
|
||||
"Render Log", f"{resources_directory}/Document.png", self.job_logs)
|
||||
self.topbar.add_button(
|
||||
"Download", f"{resources_directory}/Download.png", self.download_files)
|
||||
self.topbar.add_button(
|
||||
"Open Files", f"{resources_directory}/SearchFolder.png", self.open_files)
|
||||
self.topbar.add_button(
|
||||
"New Job", f"{resources_directory}/AddProduct.png", self.new_job)
|
||||
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.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'])
|
||||
launch_url(path)
|
||||
|
||||
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()
|
||||
@@ -1,23 +0,0 @@
|
||||
''' 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
|
||||
@@ -1,40 +0,0 @@
|
||||
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)
|
||||
@@ -1,68 +0,0 @@
|
||||
''' 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(), 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',
|
||||
'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...")
|
||||
@@ -1,49 +0,0 @@
|
||||
''' 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)
|
||||
@@ -1,29 +0,0 @@
|
||||
''' 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)
|
||||
@@ -1,78 +0,0 @@
|
||||
import concurrent.futures
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
def cpu_workload(n):
|
||||
# Simple arithmetic operation for workload
|
||||
while n > 0:
|
||||
n -= 1
|
||||
return n
|
||||
|
||||
|
||||
def cpu_benchmark(duration_seconds=10):
|
||||
# Determine the number of available CPU cores
|
||||
num_cores = os.cpu_count()
|
||||
|
||||
# Calculate workload per core, assuming a large number for the workload
|
||||
workload_per_core = 10000000
|
||||
|
||||
# Record start time
|
||||
start_time = time.time()
|
||||
|
||||
# Use ProcessPoolExecutor to utilize all CPU cores
|
||||
with concurrent.futures.ProcessPoolExecutor() as executor:
|
||||
# Launching tasks for each core
|
||||
futures = [executor.submit(cpu_workload, workload_per_core) for _ in range(num_cores)]
|
||||
|
||||
# Wait for all futures to complete, with a timeout to limit the benchmark duration
|
||||
concurrent.futures.wait(futures, timeout=duration_seconds)
|
||||
|
||||
# Record end time
|
||||
end_time = time.time()
|
||||
|
||||
# Calculate the total number of operations (workload) done by all cores
|
||||
total_operations = workload_per_core * num_cores
|
||||
# Calculate the total time taken
|
||||
total_time = end_time - start_time
|
||||
# Calculate operations per second as the score
|
||||
score = total_operations / total_time
|
||||
score = score * 0.0001
|
||||
|
||||
return int(score)
|
||||
|
||||
|
||||
def disk_io_benchmark(file_size_mb=100, filename='benchmark_test_file'):
|
||||
write_speed = None
|
||||
read_speed = None
|
||||
|
||||
# Measure write speed
|
||||
start_time = time.time()
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(os.urandom(file_size_mb * 1024 * 1024)) # Write random bytes to file
|
||||
end_time = time.time()
|
||||
write_time = end_time - start_time
|
||||
write_speed = file_size_mb / write_time
|
||||
|
||||
# Measure read speed
|
||||
start_time = time.time()
|
||||
with open(filename, 'rb') as f:
|
||||
content = f.read()
|
||||
end_time = time.time()
|
||||
read_time = end_time - start_time
|
||||
read_speed = file_size_mb / read_time
|
||||
|
||||
# Cleanup
|
||||
os.remove(filename)
|
||||
|
||||
logger.debug(f"Disk Write Speed: {write_speed:.2f} MB/s")
|
||||
logger.debug(f"Disk Read Speed: {read_speed:.2f} MB/s")
|
||||
return write_speed, read_speed
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(cpu_benchmark())
|
||||
print(disk_io_benchmark())
|
||||
@@ -1,74 +0,0 @@
|
||||
import os
|
||||
import yaml
|
||||
from src.utilities.misc_helper import current_system_os, copy_directory_contents
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
def config_dir(cls):
|
||||
# Set up the config path
|
||||
if current_system_os() == 'macos':
|
||||
local_config_path = os.path.expanduser('~/Library/Application Support/Zordon')
|
||||
elif current_system_os() == 'windows':
|
||||
local_config_path = os.path.join(os.environ['APPDATA'], 'Zordon')
|
||||
else:
|
||||
local_config_path = os.path.expanduser('~/.config/Zordon')
|
||||
return local_config_path
|
||||
|
||||
@classmethod
|
||||
def setup_config_dir(cls):
|
||||
# Set up the config path
|
||||
local_config_dir = cls.config_dir()
|
||||
if os.path.exists(local_config_dir):
|
||||
return
|
||||
|
||||
try:
|
||||
# Create the local configuration directory
|
||||
os.makedirs(local_config_dir)
|
||||
|
||||
# Determine the template path
|
||||
resource_environment_path = os.environ.get('RESOURCEPATH')
|
||||
if resource_environment_path:
|
||||
template_path = os.path.join(resource_environment_path, 'config')
|
||||
else:
|
||||
template_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'config')
|
||||
|
||||
# Copy contents from the template to the local configuration directory
|
||||
copy_directory_contents(template_path, local_config_dir)
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred while setting up the config directory: {e}")
|
||||
raise
|
||||
@@ -1,13 +1,12 @@
|
||||
import subprocess
|
||||
from src.engines.ffmpeg.ffmpeg_engine import FFMPEG
|
||||
from src.engines.ffmpeg_engine import FFMPEG
|
||||
|
||||
|
||||
def image_sequence_to_video(source_glob_pattern, output_path, framerate=24, encoder="prores_ks", profile=4,
|
||||
start_frame=1):
|
||||
subprocess.run([FFMPEG.default_renderer_path(), "-framerate", str(framerate), "-start_number",
|
||||
str(start_frame), "-i", f"{source_glob_pattern}", "-c:v", encoder, "-profile:v", str(profile),
|
||||
'-pix_fmt', 'yuva444p10le', output_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
check=True)
|
||||
subprocess.run([FFMPEG.default_renderer_path(), "-framerate", str(framerate), "-start_number", str(start_frame), "-i",
|
||||
f"{source_glob_pattern}", "-c:v", encoder, "-profile:v", str(profile), '-pix_fmt', 'yuva444p10le',
|
||||
output_path], check=True)
|
||||
|
||||
|
||||
def save_first_frame(source_path, dest_path, max_width=1280):
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import string
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
@@ -11,27 +8,14 @@ logger = logging.getLogger()
|
||||
|
||||
|
||||
def launch_url(url):
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if shutil.which('xdg-open'):
|
||||
opener = 'xdg-open'
|
||||
elif shutil.which('open'):
|
||||
opener = 'open'
|
||||
elif shutil.which('cmd'):
|
||||
opener = 'start'
|
||||
if subprocess.run(['which', 'xdg-open'], capture_output=True).returncode == 0:
|
||||
subprocess.run(['xdg-open', url]) # linux
|
||||
elif subprocess.run(['which', 'open'], capture_output=True).returncode == 0:
|
||||
subprocess.run(['open', url]) # macos
|
||||
elif subprocess.run(['which', 'start'], capture_output=True).returncode == 0:
|
||||
subprocess.run(['start', url]) # windows - need to validate this works
|
||||
else:
|
||||
error_message = f"No valid launchers found to launch URL: {url}"
|
||||
logger.error(error_message)
|
||||
raise OSError(error_message)
|
||||
|
||||
try:
|
||||
if opener == 'start':
|
||||
# For Windows, use 'cmd /c start'
|
||||
subprocess.run(['cmd', '/c', 'start', url], shell=False)
|
||||
else:
|
||||
subprocess.run([opener, url])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to launch URL: {url}. Error: {e}")
|
||||
logger.error(f"No valid launchers found to launch url: {url}")
|
||||
|
||||
|
||||
def file_exists_in_mounts(filepath):
|
||||
@@ -49,9 +33,9 @@ def file_exists_in_mounts(filepath):
|
||||
path = os.path.normpath(path)
|
||||
components = []
|
||||
while True:
|
||||
path, comp = os.path.split(path)
|
||||
if comp:
|
||||
components.append(comp)
|
||||
path, component = os.path.split(path)
|
||||
if component:
|
||||
components.append(component)
|
||||
else:
|
||||
if path:
|
||||
components.append(path)
|
||||
@@ -77,17 +61,20 @@ def file_exists_in_mounts(filepath):
|
||||
|
||||
def get_time_elapsed(start_time=None, end_time=None):
|
||||
|
||||
from string import Template
|
||||
|
||||
class DeltaTemplate(Template):
|
||||
delimiter = "%"
|
||||
|
||||
def strfdelta(tdelta, fmt='%H:%M:%S'):
|
||||
days = tdelta.days
|
||||
d = {"D": tdelta.days}
|
||||
hours, rem = divmod(tdelta.seconds, 3600)
|
||||
minutes, seconds = divmod(rem, 60)
|
||||
|
||||
# Using f-strings for formatting
|
||||
formatted_str = fmt.replace('%D', f'{days}')
|
||||
formatted_str = formatted_str.replace('%H', f'{hours:02d}')
|
||||
formatted_str = formatted_str.replace('%M', f'{minutes:02d}')
|
||||
formatted_str = formatted_str.replace('%S', f'{seconds:02d}')
|
||||
return formatted_str
|
||||
d["H"] = '{:02d}'.format(hours)
|
||||
d["M"] = '{:02d}'.format(minutes)
|
||||
d["S"] = '{:02d}'.format(seconds)
|
||||
t = DeltaTemplate(fmt)
|
||||
return t.substitute(**d)
|
||||
|
||||
# calculate elapsed time
|
||||
elapsed_time = None
|
||||
@@ -105,7 +92,7 @@ def get_time_elapsed(start_time=None, end_time=None):
|
||||
def get_file_size_human(file_path):
|
||||
size_in_bytes = os.path.getsize(file_path)
|
||||
|
||||
# Convert size to a human-readable format
|
||||
# Convert size to a human readable format
|
||||
if size_in_bytes < 1024:
|
||||
return f"{size_in_bytes} B"
|
||||
elif size_in_bytes < 1024 ** 2:
|
||||
@@ -123,63 +110,3 @@ def system_safe_path(path):
|
||||
if platform.system().lower() == "windows":
|
||||
return os.path.normpath(path)
|
||||
return path.replace("\\", "/")
|
||||
|
||||
|
||||
def current_system_os():
|
||||
return platform.system().lower().replace('darwin', 'macos')
|
||||
|
||||
|
||||
def current_system_os_version():
|
||||
return platform.mac_ver()[0] if current_system_os() == 'macos' else platform.release().lower()
|
||||
|
||||
|
||||
def current_system_cpu():
|
||||
# convert all x86 64 to "x64"
|
||||
return platform.machine().lower().replace('amd64', 'x64').replace('x86_64', 'x64')
|
||||
|
||||
|
||||
def resources_dir():
|
||||
resource_environment_path = os.environ.get('RESOURCEPATH', None)
|
||||
if resource_environment_path: # running inside resource bundle
|
||||
return os.path.join(resource_environment_path, 'resources')
|
||||
else:
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'resources')
|
||||
|
||||
|
||||
def copy_directory_contents(src_dir, dst_dir):
|
||||
"""
|
||||
Copy the contents of the source directory (src_dir) to the destination directory (dst_dir).
|
||||
"""
|
||||
for item in os.listdir(src_dir):
|
||||
src_path = os.path.join(src_dir, item)
|
||||
dst_path = os.path.join(dst_dir, item)
|
||||
if os.path.isdir(src_path):
|
||||
shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copy2(src_path, dst_path)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def num_to_alphanumeric(num):
|
||||
# List of possible alphanumeric characters
|
||||
characters = string.ascii_letters + string.digits
|
||||
|
||||
# Make sure number is positive
|
||||
num = abs(num)
|
||||
|
||||
# Convert number to alphanumeric
|
||||
result = ""
|
||||
while num > 0:
|
||||
num, remainder = divmod(num, len(characters))
|
||||
result += characters[remainder]
|
||||
|
||||
return result[::-1] # Reverse the result to get the correct alphanumeric string
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
import socket
|
||||
|
||||
from pubsub import pub
|
||||
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange, NonUniqueNameException
|
||||
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -22,15 +21,9 @@ class ZeroconfServer:
|
||||
cls.service_type = service_type
|
||||
cls.server_name = server_name
|
||||
cls.server_port = server_port
|
||||
try: # Stop any previously running instances
|
||||
socket.gethostbyname(socket.gethostname())
|
||||
except socket.gaierror:
|
||||
cls.stop()
|
||||
|
||||
@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()
|
||||
@@ -42,22 +35,19 @@ class ZeroconfServer:
|
||||
|
||||
@classmethod
|
||||
def _register_service(cls):
|
||||
try:
|
||||
cls.server_ip = socket.gethostbyname(socket.gethostname())
|
||||
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}")
|
||||
except (NonUniqueNameException, socket.gaierror) as e:
|
||||
logger.error(f"Error establishing zeroconf: {e}")
|
||||
cls.service_info = info
|
||||
cls.zeroconf.register_service(info)
|
||||
logger.info(f"Registered zeroconf service: {cls.service_info.name}")
|
||||
|
||||
@classmethod
|
||||
def _unregister_service(cls):
|
||||
@@ -74,32 +64,16 @@ class ZeroconfServer:
|
||||
@classmethod
|
||||
def _on_service_discovered(cls, zeroconf, service_type, name, state_change):
|
||||
info = zeroconf.get_service_info(service_type, name)
|
||||
hostname = name.split(f'.{cls.service_type}')[0]
|
||||
logger.debug(f"Zeroconf: {hostname} {state_change}")
|
||||
logger.debug(f"Zeroconf: {name} {state_change}")
|
||||
if service_type == cls.service_type:
|
||||
if state_change == ServiceStateChange.Added or state_change == ServiceStateChange.Updated:
|
||||
cls.client_cache[hostname] = info
|
||||
cls.client_cache[name] = info
|
||||
else:
|
||||
cls.client_cache.pop(hostname)
|
||||
pub.sendMessage('zeroconf_state_change', hostname=hostname, state_change=state_change)
|
||||
cls.client_cache.pop(name)
|
||||
|
||||
@classmethod
|
||||
def found_hostnames(cls):
|
||||
local_hostname = socket.gethostname()
|
||||
|
||||
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(cls.client_cache.keys(), key=sort_key)
|
||||
return sorted_hostnames
|
||||
|
||||
@classmethod
|
||||
def get_hostname_properties(cls, hostname):
|
||||
server_info = cls.client_cache.get(hostname).properties
|
||||
decoded_server_info = {key.decode('utf-8'): value.decode('utf-8') for key, value in server_info.items()}
|
||||
return decoded_server_info
|
||||
def found_clients(cls):
|
||||
return [x.split(f'.{cls.service_type}')[0] for x in cls.client_cache.keys()]
|
||||
|
||||
|
||||
# Example usage:
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
src/web/static/images/desktop.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 995 B After Width: | Height: | Size: 995 B |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
BIN
src/web/static/images/logo.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
64
src/web/static/js/job_table.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const grid = new gridjs.Grid({
|
||||
columns: [
|
||||
{ data: (row) => row.id,
|
||||
name: 'Thumbnail',
|
||||
formatter: (cell) => gridjs.html(`<img src="/api/job/${cell}/thumbnail?video_ok" style='width: 200px; min-width: 120px;'>`),
|
||||
sort: {enabled: false}
|
||||
},
|
||||
{ id: 'name',
|
||||
name: 'Name',
|
||||
data: (row) => row.name,
|
||||
formatter: (name, row) => gridjs.html(`<a href="/ui/job/${row.cells[0].data}/full_details">${name}</a>`)
|
||||
},
|
||||
{ id: 'renderer', data: (row) => `${row.renderer}-${row.renderer_version}`, name: 'Renderer' },
|
||||
{ id: 'priority', name: 'Priority' },
|
||||
{ id: 'status',
|
||||
name: 'Status',
|
||||
data: (row) => row,
|
||||
formatter: (cell, row) => gridjs.html(`
|
||||
<span class="tag ${(cell.status == 'running') ? 'is-hidden' : ''} ${(cell.status == 'cancelled') ?
|
||||
'is-warning' : (cell.status == 'error') ? 'is-danger' : (cell.status == 'not_started') ?
|
||||
'is-light' : 'is-primary'}">${cell.status}</span>
|
||||
<progress class="progress is-primary ${(cell.status != 'running') ? 'is-hidden': ''}"
|
||||
value="${(parseFloat(cell.percent_complete) * 100.0)}" max="100">${cell.status}</progress>
|
||||
`)},
|
||||
{ id: 'time_elapsed', name: 'Time Elapsed' },
|
||||
{ data: (row) => row.total_frames ?? 'N/A', name: 'Frame Count' },
|
||||
{ id: 'client', name: 'Client'},
|
||||
{ data: (row) => row.last_output ?? 'N/A',
|
||||
name: 'Last Output',
|
||||
formatter: (output, row) => gridjs.html(`<a href="/api/job/${row.cells[0].data}/logs">${output}</a>`)
|
||||
},
|
||||
{ data: (row) => row,
|
||||
name: 'Commands',
|
||||
formatter: (cell, row) => gridjs.html(`
|
||||
<div class="field has-addons" style='white-space: nowrap; display: inline-block;'>
|
||||
<button class="button is-info" onclick="window.location.href='/ui/job/${row.cells[0].data}/full_details';">
|
||||
<span class="icon"><i class="fa-solid fa-info"></i></span>
|
||||
</button>
|
||||
<button class="button is-link" onclick="window.location.href='/api/job/${row.cells[0].data}/logs';">
|
||||
<span class="icon"><i class="fa-regular fa-file-lines"></i></span>
|
||||
</button>
|
||||
<button class="button is-warning is-active ${(cell.status != 'running') ? 'is-hidden': ''}" onclick="window.location.href='/api/job/${row.cells[0].data}/cancel?confirm=True&redirect=True';">
|
||||
<span class="icon"><i class="fa-solid fa-x"></i></span>
|
||||
</button>
|
||||
<button class="button is-success ${(cell.status != 'completed') ? 'is-hidden': ''}" onclick="window.location.href='/api/job/${row.cells[0].data}/download_all';">
|
||||
<span class="icon"><i class="fa-solid fa-download"></i></span>
|
||||
<span>${cell.file_count}</span>
|
||||
</button>
|
||||
<button class="button is-danger" onclick="window.location.href='/api/job/${row.cells[0].data}/delete?confirm=True&redirect=True'">
|
||||
<span class="icon"><i class="fa-regular fa-trash-can"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
`),
|
||||
sort: false
|
||||
},
|
||||
{ id: 'owner', name: 'Owner' }
|
||||
],
|
||||
autoWidth: true,
|
||||
server: {
|
||||
url: '/api/jobs',
|
||||
then: results => results['jobs'],
|
||||
},
|
||||
sort: true,
|
||||
}).render(document.getElementById('table'));
|
||||
44
src/web/static/js/modals.js
Normal file
@@ -0,0 +1,44 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Functions to open and close a modal
|
||||
function openModal($el) {
|
||||
$el.classList.add('is-active');
|
||||
}
|
||||
|
||||
function closeModal($el) {
|
||||
$el.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function closeAllModals() {
|
||||
(document.querySelectorAll('.modal') || []).forEach(($modal) => {
|
||||
closeModal($modal);
|
||||
});
|
||||
}
|
||||
|
||||
// Add a click event on buttons to open a specific modal
|
||||
(document.querySelectorAll('.js-modal-trigger') || []).forEach(($trigger) => {
|
||||
const modal = $trigger.dataset.target;
|
||||
const $target = document.getElementById(modal);
|
||||
|
||||
$trigger.addEventListener('click', () => {
|
||||
openModal($target);
|
||||
});
|
||||
});
|
||||
|
||||
// Add a click event on various child elements to close the parent modal
|
||||
(document.querySelectorAll('.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button') || []).forEach(($close) => {
|
||||
const $target = $close.closest('.modal');
|
||||
|
||||
$close.addEventListener('click', () => {
|
||||
closeModal($target);
|
||||
});
|
||||
});
|
||||
|
||||
// Add a keyboard event to close all modals
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const e = event || window.event;
|
||||
|
||||
if (e.keyCode === 27) { // Escape key
|
||||
closeAllModals();
|
||||
}
|
||||
});
|
||||
});
|
||||
48
src/web/templates/details.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container" style="text-align:center; width: 100%">
|
||||
<br>
|
||||
{% if media_url: %}
|
||||
<video width="1280" height="720" controls>
|
||||
<source src="{{media_url}}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{% elif job_status == 'Running': %}
|
||||
<div style="width: 100%; height: 720px; position: relative; background: black; text-align: center; color: white;">
|
||||
<img src="/static/images/gears.png" style="vertical-align: middle; width: auto; height: auto; position:absolute; margin: auto; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<span style="height: auto; position:absolute; margin: auto; top: 58%; left: 0; right: 0; color: white; width: 60%">
|
||||
<progress class="progress is-primary" value="{{job.worker_data()['percent_complete'] * 100}}" max="100" style="margin-top: 6px;" id="progress-bar">Rendering</progress>
|
||||
Rendering in Progress - <span id="percent-complete">{{(job.worker_data()['percent_complete'] * 100) | int}}%</span>
|
||||
<br>Time Elapsed: <span id="time-elapsed">{{job.worker_data()['time_elapsed']}}</span>
|
||||
</span>
|
||||
<script>
|
||||
var startingStatus = '{{job.status.value}}';
|
||||
function update_job() {
|
||||
$.getJSON('/api/job/{{job.id}}', function(data) {
|
||||
document.getElementById('progress-bar').value = (data.percent_complete * 100);
|
||||
document.getElementById('percent-complete').innerHTML = (data.percent_complete * 100).toFixed(0) + '%';
|
||||
document.getElementById('time-elapsed').innerHTML = data.time_elapsed;
|
||||
if (data.status != startingStatus){
|
||||
clearInterval(renderingTimer);
|
||||
window.location.reload(true);
|
||||
};
|
||||
});
|
||||
}
|
||||
if (startingStatus == 'running'){
|
||||
var renderingTimer = setInterval(update_job, 1000);
|
||||
};
|
||||
</script>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="width: 100%; height: 720px; position: relative; background: black;">
|
||||
<img src="/static/images/{{job_status}}.png" style="vertical-align: middle; width: auto; height: auto; position:absolute; margin: auto; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<span style="height: auto; position:absolute; margin: auto; top: 58%; left: 0; right: 0; color: white;">
|
||||
{{job_status}}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<br>
|
||||
{{detail_table|safe}}
|
||||
</div>
|
||||
{% endblock %}
|
||||
8
src/web/templates/index.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container is-fluid" style="padding-top: 20px;">
|
||||
<div id="table" class="table"></div>
|
||||
</div>
|
||||
<script src="/static/js/job_table.js"></script>
|
||||
{% endblock %}
|
||||
236
src/web/templates/layout.html
Normal file
@@ -0,0 +1,236 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Zordon Dashboard</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gridjs/dist/gridjs.umd.js"></script>
|
||||
<link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
|
||||
<script src="https://kit.fontawesome.com/698705d14d.js" crossorigin="anonymous"></script>
|
||||
<script type="text/javascript" src="/static/js/modals.js"></script>
|
||||
</head>
|
||||
<body onload="rendererChanged(document.getElementById('renderer'))">
|
||||
|
||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img src="/static/images/logo.png">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="navbarBasicExample" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="/">
|
||||
Home
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<div class="navbar-item">
|
||||
<button class="button is-primary js-modal-trigger" data-target="add-job-modal">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
</span>
|
||||
<span>Submit Job</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% block body %}
|
||||
{% endblock %}
|
||||
|
||||
<div id="add-job-modal" class="modal">
|
||||
<!-- Start Add Form -->
|
||||
<form id="submit_job" action="/api/add_job?redirect=True" method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Submit New Job</p>
|
||||
<button class="delete" aria-label="close" type="button"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<!-- File Uploader -->
|
||||
|
||||
<label class="label">Upload File</label>
|
||||
<div id="file-uploader" class="file has-name is-fullwidth">
|
||||
<label class="file-label">
|
||||
<input class="file-input is-small" type="file" name="file">
|
||||
<span class="file-cta">
|
||||
<span class="file-icon">
|
||||
<i class="fas fa-upload"></i>
|
||||
</span>
|
||||
<span class="file-label">
|
||||
Choose a file…
|
||||
</span>
|
||||
</span>
|
||||
<span class="file-name">
|
||||
No File Uploaded
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<br>
|
||||
<script>
|
||||
const fileInput = document.querySelector('#file-uploader input[type=file]');
|
||||
fileInput.onchange = () => {
|
||||
if (fileInput.files.length > 0) {
|
||||
const fileName = document.querySelector('#file-uploader .file-name');
|
||||
fileName.textContent = fileInput.files[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
const presets = {
|
||||
{% for preset in preset_list: %}
|
||||
{{preset}}: {
|
||||
name: '{{preset_list[preset]['name']}}',
|
||||
renderer: '{{preset_list[preset]['renderer']}}',
|
||||
args: '{{preset_list[preset]['args']}}',
|
||||
},
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
function rendererChanged(ddl1) {
|
||||
|
||||
var renderers = {
|
||||
{% for renderer in renderer_info: %}
|
||||
{% if renderer_info[renderer]['supported_export_formats']: %}
|
||||
{{renderer}}: [
|
||||
{% for format in renderer_info[renderer]['supported_export_formats']: %}
|
||||
'{{format}}',
|
||||
{% endfor %}
|
||||
],
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
var selectedRenderer = ddl1.value;
|
||||
|
||||
var ddl3 = document.getElementById('preset_list');
|
||||
ddl3.options.length = 0;
|
||||
createOption(ddl3, '-Presets-', '');
|
||||
for (var preset_name in presets) {
|
||||
if (presets[preset_name]['renderer'] == selectedRenderer) {
|
||||
createOption(ddl3, presets[preset_name]['name'], preset_name);
|
||||
};
|
||||
};
|
||||
document.getElementById('raw_args').value = "";
|
||||
|
||||
var ddl2 = document.getElementById('export_format');
|
||||
ddl2.options.length = 0;
|
||||
var options = renderers[selectedRenderer];
|
||||
for (i = 0; i < options.length; i++) {
|
||||
createOption(ddl2, options[i], options[i]);
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(ddl, text, value) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.text = text;
|
||||
ddl.options.add(opt);
|
||||
}
|
||||
|
||||
function addPresetTextToInput(presetfield, textfield) {
|
||||
var p = presets[presetfield.value];
|
||||
textfield.value = p['args'];
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- Renderer & Priority -->
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<label class="label">Renderer</label>
|
||||
<span class="select">
|
||||
<select id="renderer" name="renderer" onchange="rendererChanged(this)">
|
||||
{% for renderer in renderer_info: %}
|
||||
<option name="renderer" value="{{renderer}}">{{renderer}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control">
|
||||
<label class="label">Client</label>
|
||||
<span class="select">
|
||||
<select name="client">
|
||||
<option name="client" value="">First Available</option>
|
||||
{% for client in render_clients: %}
|
||||
<option name="client" value="{{client}}">{{client}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control">
|
||||
<label class="label">Priority</label>
|
||||
<span class="select">
|
||||
<select name="priority">
|
||||
<option name="priority" value="1">1</option>
|
||||
<option name="priority" value="2" selected="selected">2</option>
|
||||
<option name="priority" value="3">3</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Output Path -->
|
||||
<label class="label">Output</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input class="input is-small" type="text" placeholder="Output Name" name="output_path" value="output.mp4">
|
||||
</div>
|
||||
<p class="control">
|
||||
<span class="select is-small">
|
||||
<select id="export_format" name="export_format">
|
||||
<option value="ar">option</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Resolution -->
|
||||
<!-- <label class="label">Resolution</label>-->
|
||||
<!-- <div class="field is-grouped">-->
|
||||
<!-- <p class="control">-->
|
||||
<!-- <input class="input" type="text" placeholder="auto" maxlength="5" size="8" name="AnyRenderer-arg_x_resolution">-->
|
||||
<!-- </p>-->
|
||||
<!-- <label class="label"> x </label>-->
|
||||
<!-- <p class="control">-->
|
||||
<!-- <input class="input" type="text" placeholder="auto" maxlength="5" size="8" name="AnyRenderer-arg_y_resolution">-->
|
||||
<!-- </p>-->
|
||||
<!-- <label class="label"> @ </label>-->
|
||||
<!-- <p class="control">-->
|
||||
<!-- <input class="input" type="text" placeholder="auto" maxlength="3" size="5" name="AnyRenderer-arg_frame_rate">-->
|
||||
<!-- </p>-->
|
||||
<!-- <label class="label"> fps </label>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<label class="label">Command Line Arguments</label>
|
||||
<div class="field has-addons">
|
||||
<p class="control">
|
||||
<span class="select is-small">
|
||||
<select id="preset_list" onchange="addPresetTextToInput(this, document.getElementById('raw_args'))">
|
||||
<option value="preset-placeholder">presets</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control is-expanded">
|
||||
<input class="input is-small" type="text" placeholder="Args" id="raw_args" name="raw_args">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- End Add Form -->
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<input class="button is-link" type="submit"/>
|
||||
<button class="button" type="button">Cancel</button>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
62
src/web/templates/upload.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<html>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
|
||||
<script>
|
||||
$(function() {
|
||||
$('#renderer').change(function() {
|
||||
$('.render_settings').hide();
|
||||
$('#' + $(this).val()).show();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
<body>
|
||||
<h3>Upload a file</h3>
|
||||
|
||||
<div>
|
||||
<form action="/add_job" method="POST"
|
||||
enctype="multipart/form-data">
|
||||
<div>
|
||||
<input type="file" name="file"/><br>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="origin" name="origin" value="html">
|
||||
|
||||
<div id="client">
|
||||
Render Client:
|
||||
<select name="client">
|
||||
{% for client in render_clients %}
|
||||
<option value="{{client}}">{{client}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="priority">
|
||||
Priority:
|
||||
<select name="priority">
|
||||
<option value="1">1</option>
|
||||
<option value="2" selected>2</option>
|
||||
<option value="3">3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="renderer">Renderer:</label>
|
||||
<select id="renderer" name="renderer">
|
||||
{% for renderer in supported_renderers %}
|
||||
<option value="{{renderer}}">{{renderer}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="blender" class="render_settings" style="display:none">
|
||||
Engine:
|
||||
<select name="blender+engine">
|
||||
<option value="CYCLES">Cycles</option>
|
||||
<option value="BLENDER_EEVEE">Eevee</option>
|
||||
</select>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<input type="submit"/>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||