mirror of
https://github.com/blw1138/Zordon.git
synced 2026-06-09 13:39:24 -05:00
Compare commits
7 Commits
24eb7b5616
...
44e6b4332f
| Author | SHA1 | Date | |
|---|---|---|---|
| 44e6b4332f | |||
| c38213fb58 | |||
| 3486feaaf4 | |||
| 141843c916 | |||
| 472c7968b3 | |||
| b8b71d1e16 | |||
| f0be78adcc |
@@ -3,36 +3,102 @@ name: Create Executables
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
- types: [created]
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
pyinstaller-build-windows:
|
||||
runs-on: windows-latest
|
||||
build:
|
||||
name: Build executables (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
artifact_suffix: windows
|
||||
- os: ubuntu-latest
|
||||
artifact_suffix: linux
|
||||
- os: macos-latest
|
||||
artifact_suffix: macos
|
||||
|
||||
steps:
|
||||
- name: Create Executables (Windows)
|
||||
uses: sayyid5416/pyinstaller@v1
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python_ver: '3.11'
|
||||
spec: 'client.spec'
|
||||
requirements: 'requirements.txt'
|
||||
upload_exe_with_name: 'Zordon'
|
||||
pyinstaller-build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create Executables (Linux)
|
||||
uses: sayyid5416/pyinstaller@v1
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libxcb-cursor0 libxcb-xinerama0
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel setuptools
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install pyinstaller pyinstaller_versionfile
|
||||
|
||||
- name: Build client
|
||||
run: pyinstaller --clean client.spec
|
||||
|
||||
- name: Build server
|
||||
run: pyinstaller --clean server.spec
|
||||
|
||||
- name: Package build outputs
|
||||
shell: bash
|
||||
env:
|
||||
ARTIFACT_SUFFIX: ${{ matrix.artifact_suffix }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
suffix = os.environ['ARTIFACT_SUFFIX']
|
||||
dist_dir = Path('dist')
|
||||
asset_dir = Path('release-assets')
|
||||
asset_dir.mkdir(exist_ok=True)
|
||||
|
||||
for app_name in ('Zordon-client', 'Zordon-server'):
|
||||
source = next(
|
||||
(
|
||||
path for path in (
|
||||
dist_dir / f'{app_name}.exe',
|
||||
dist_dir / f'{app_name}.app',
|
||||
dist_dir / app_name,
|
||||
)
|
||||
if path.exists()
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if source is None:
|
||||
raise FileNotFoundError(f'Could not find PyInstaller output for {app_name}')
|
||||
|
||||
zip_path = asset_dir / f'{app_name}-{suffix}.zip'
|
||||
with zipfile.ZipFile(zip_path, 'w', compression=zipfile.ZIP_DEFLATED) as zip_file:
|
||||
if source.is_dir():
|
||||
for file_path in source.rglob('*'):
|
||||
if file_path.is_file():
|
||||
zip_file.write(file_path, source.name / file_path.relative_to(source))
|
||||
else:
|
||||
zip_file.write(source, source.name)
|
||||
PY
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
python_ver: '3.11'
|
||||
spec: 'client.spec'
|
||||
requirements: 'requirements.txt'
|
||||
upload_exe_with_name: 'Zordon'
|
||||
pyinstaller-build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Create Executables (macOS)
|
||||
uses: sayyid5416/pyinstaller@v1
|
||||
with:
|
||||
python_ver: '3.11'
|
||||
spec: 'client.spec'
|
||||
requirements: 'requirements.txt'
|
||||
upload_exe_with_name: 'Zordon'
|
||||
name: Zordon-build-${{ matrix.artifact_suffix }}
|
||||
path: release-assets/*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach assets to release
|
||||
if: github.event_name == 'release'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release upload "${{ github.event.release.tag_name }}" release-assets/*.zip --clobber
|
||||
|
||||
@@ -83,18 +83,24 @@ The system works by:
|
||||
Jobs can be submitted via the desktop UI or programmatically via the API:
|
||||
|
||||
- **Via UI**: Use the desktop interface to upload project files, specify render settings, and queue jobs.
|
||||
- **Via API**: Send POST requests to `/api/jobs` with job configuration in JSON format.
|
||||
- **Via API**: Send `POST` requests to `/api/jobs` with job configuration in JSON format.
|
||||
|
||||
Example API request:
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/jobs \
|
||||
curl -X POST http://localhost:8080/api/jobs \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"engine": "blender",
|
||||
"project_path": "/path/to/project.blend",
|
||||
"output_path": "/path/to/output",
|
||||
"frames": "1-100",
|
||||
"settings": {"resolution": "1920x1080"}
|
||||
"name": "example-render",
|
||||
"engine_name": "blender",
|
||||
"local_path": "/path/to/project.blend",
|
||||
"output_path": "example-output",
|
||||
"start_frame": 1,
|
||||
"end_frame": 100,
|
||||
"args": {
|
||||
"export_format": "PNG",
|
||||
"resolution": [1920, 1080]
|
||||
},
|
||||
"enable_split_jobs": false
|
||||
}'
|
||||
```
|
||||
|
||||
@@ -103,9 +109,27 @@ curl -X POST http://localhost:5000/api/jobs \
|
||||
- **UI**: View job status, progress, logs, and worker availability in real-time.
|
||||
- **API Endpoints**:
|
||||
- `GET /api/jobs`: List all jobs
|
||||
- `GET /api/jobs/{id}`: Get job details
|
||||
- `DELETE /api/jobs/{id}`: Cancel a job
|
||||
- `GET /api/workers`: List connected workers
|
||||
- `POST /api/jobs`: Submit a new job
|
||||
- `GET /api/jobs/<job_id>`: Get job details
|
||||
- `POST /api/jobs/<job_id>/cancel`: Cancel a job
|
||||
- `POST /api/jobs/<job_id>/delete`: Delete a job
|
||||
- `GET /api/status`: Get server and queue status
|
||||
- `GET /api/engines`: List engine information
|
||||
|
||||
For the full endpoint reference, see [`docs/api.html`](docs/api.html) or
|
||||
[`docs/API.md`](docs/API.md).
|
||||
|
||||
#### Blender Add-on
|
||||
|
||||
Zordon includes a Blender add-on for submitting the current `.blend` file
|
||||
directly from Blender.
|
||||
|
||||
- Source: [`addons/blender/zordon_blender`](addons/blender/zordon_blender)
|
||||
- Installable zip: [`addons/blender/zordon_blender.zip`](addons/blender/zordon_blender.zip)
|
||||
|
||||
After installing the add-on, use `Properties > Render > Zordon` to discover or
|
||||
choose a server, test the connection, upload the current file, and submit
|
||||
camera-specific jobs from the active Blender scene.
|
||||
|
||||
#### Worker Management
|
||||
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ def main():
|
||||
|
||||
new_job = {"name": job_name, "engine_name": args.engine}
|
||||
try:
|
||||
response = found_proxy.post_job_to_server(file_path, new_job)
|
||||
response = found_proxy.create_job(file_path, new_job)
|
||||
except Exception as e:
|
||||
print(f"Error creating job: {e}")
|
||||
exit(1)
|
||||
@@ -113,7 +113,7 @@ def main():
|
||||
while percent_complete < 1.0:
|
||||
# add checks for errors
|
||||
time.sleep(1)
|
||||
running_job_data = found_proxy.get_job_info(job_id)
|
||||
running_job_data = found_proxy.get_job(job_id)
|
||||
percent_complete = running_job_data['percent_complete']
|
||||
sys.stdout.write("\x1b[1A") # Move up 1
|
||||
sys.stdout.write("\x1b[0J") # Clear from cursor to end of screen (optional)
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,153 @@
|
||||
# Zordon Blender Add-on
|
||||
|
||||
Submit the current Blender file to a Zordon render server from Blender's Render
|
||||
properties panel.
|
||||
|
||||
## Features
|
||||
|
||||
- Submit the current `.blend` file directly to `POST /api/jobs`.
|
||||
- Choose a configured Zordon server from Blender.
|
||||
- Test the server connection before submitting.
|
||||
- Save the current file before upload.
|
||||
- Default job and output names to the `.blend` filename.
|
||||
- Use the current scene frame range, render resolution, FPS, and image format.
|
||||
- Select one or more cameras for submission.
|
||||
- Submit multiple selected cameras as camera-specific child jobs.
|
||||
|
||||
## Install
|
||||
|
||||
1. In Blender, open `Edit > Preferences > Add-ons`.
|
||||
2. Click `Install...`.
|
||||
3. Select `addons/blender/zordon_blender.zip`.
|
||||
4. Enable `Zordon Render Submitter`.
|
||||
|
||||
## Configure Servers
|
||||
|
||||
Use `Discover Servers` to find Zordon servers advertised with Zeroconf.
|
||||
Discovered servers are merged into the configured server list.
|
||||
|
||||
You can also open the add-on preferences and edit `Servers` manually.
|
||||
|
||||
Use a comma-separated list:
|
||||
|
||||
```text
|
||||
localhost:8080, render-node.local:8080
|
||||
```
|
||||
|
||||
The selected server appears in `Properties > Render > Zordon`.
|
||||
|
||||
If Blender is running on the same machine as Zordon, `localhost:8080` should
|
||||
work. If Zordon is running on another machine, use that machine's hostname or IP
|
||||
address.
|
||||
|
||||
Start the Zordon server before testing the connection:
|
||||
|
||||
```bash
|
||||
python server.py
|
||||
```
|
||||
|
||||
Discovery first tries Python's `zeroconf` package if it is available inside
|
||||
Blender. If it is not available, the add-on falls back to the macOS `dns-sd`
|
||||
command when present.
|
||||
|
||||
## Submit A Job
|
||||
|
||||
1. Open or save a `.blend` file.
|
||||
2. Choose a Zordon server in `Properties > Render > Zordon`.
|
||||
3. Click `Test Connection`.
|
||||
4. Set the job name, output name, and notes if needed.
|
||||
5. Choose one or more cameras. At least one camera is always required.
|
||||
6. Click `Submit Current File`.
|
||||
|
||||
The addon uploads the current `.blend` to `POST /api/jobs` as multipart form
|
||||
data. It uses the current scene frame range, render resolution, FPS, and image
|
||||
format.
|
||||
|
||||
Job name and output name default to the current `.blend` filename.
|
||||
|
||||
If one camera is selected, the job renders that camera. If multiple cameras are
|
||||
selected, the addon submits camera-specific child jobs so each camera renders as
|
||||
its own Zordon job.
|
||||
|
||||
Use `All` to select every camera or `Active Only` to render just the active
|
||||
scene camera.
|
||||
|
||||
## Camera Behavior
|
||||
|
||||
At least one camera must be selected. The add-on prevents unchecking the final
|
||||
selected camera.
|
||||
|
||||
When one camera is selected, the add-on sends a single job with:
|
||||
|
||||
```json
|
||||
{
|
||||
"args": {
|
||||
"camera": "Camera"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When multiple cameras are selected, the add-on sends `child_jobs`. Each child job
|
||||
gets its own camera argument and output name suffix, such as:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "scene_Camera-001",
|
||||
"output_path": "scene_Camera-001",
|
||||
"args": {
|
||||
"camera": "Camera.001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Could Not Reach Zordon
|
||||
|
||||
Make sure the Zordon server is running and reachable from Blender.
|
||||
|
||||
Check from a terminal:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/heartbeat
|
||||
```
|
||||
|
||||
If the server is remote, replace `localhost` with the server hostname or IP.
|
||||
|
||||
### No Cameras Found
|
||||
|
||||
Add at least one Blender camera to the scene before submitting.
|
||||
|
||||
### Output Files Already Exist
|
||||
|
||||
Multiple camera jobs should use camera-specific output names. If they collide,
|
||||
make sure the Zordon server includes the output-path fix that preserves child
|
||||
job `output_path` values.
|
||||
|
||||
## Packaging
|
||||
|
||||
The add-on source lives at:
|
||||
|
||||
```text
|
||||
addons/blender/zordon_blender/
|
||||
```
|
||||
|
||||
The installable archive is:
|
||||
|
||||
```text
|
||||
addons/blender/zordon_blender.zip
|
||||
```
|
||||
|
||||
Rebuild the archive from `addons/blender`:
|
||||
|
||||
```bash
|
||||
python3 -m zipfile -c zordon_blender.zip zordon_blender
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The add-on is dependency-free and uses Blender's bundled Python standard
|
||||
library.
|
||||
- Only `http://` Zordon servers are currently supported.
|
||||
- `Save Before Submit` is enabled by default so the uploaded file matches the
|
||||
current Blender state.
|
||||
@@ -0,0 +1,621 @@
|
||||
bl_info = {
|
||||
'name': 'Zordon Render Submitter',
|
||||
'author': 'Zordon',
|
||||
'version': (0, 1, 0),
|
||||
'blender': (3, 6, 0),
|
||||
'location': 'Properties > Render > Zordon',
|
||||
'description': 'Submit the current Blender file to a Zordon render server.',
|
||||
'category': 'Render',
|
||||
}
|
||||
|
||||
import getpass
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
import bpy
|
||||
from bpy.props import BoolProperty, EnumProperty, IntProperty, StringProperty
|
||||
from bpy.types import AddonPreferences, Operator, Panel, PropertyGroup
|
||||
|
||||
|
||||
DEFAULT_SERVER = 'localhost:8080'
|
||||
DEFAULT_TIMEOUT = 10
|
||||
UPLOAD_TIMEOUT = 300
|
||||
ZORDON_SERVICE_TYPE = '_zordon._tcp.local.'
|
||||
|
||||
|
||||
def _addon_preferences(context):
|
||||
return context.preferences.addons[__name__].preferences
|
||||
|
||||
|
||||
def _configured_server_items(self, context):
|
||||
preferences = _addon_preferences(context)
|
||||
raw_servers = [server.strip() for server in preferences.servers.split(',')]
|
||||
servers = [server for server in raw_servers if server]
|
||||
if not servers:
|
||||
servers = [DEFAULT_SERVER]
|
||||
return [(server, server, '', index) for index, server in enumerate(servers)]
|
||||
|
||||
|
||||
def _normalize_server_url(server, fallback_port):
|
||||
if not server:
|
||||
server = DEFAULT_SERVER
|
||||
|
||||
server = server.strip()
|
||||
if not server.startswith(('http://', 'https://')):
|
||||
server = f'http://{server}'
|
||||
|
||||
parsed = urlparse(server)
|
||||
hostname = parsed.hostname or 'localhost'
|
||||
scheme = parsed.scheme or 'http'
|
||||
port = parsed.port or fallback_port or 8080
|
||||
return scheme, hostname, int(port)
|
||||
|
||||
|
||||
def _api_url(server, port, path):
|
||||
scheme, hostname, parsed_port = _normalize_server_url(server, port)
|
||||
return f'{scheme}://{hostname}:{parsed_port}{path}'
|
||||
|
||||
|
||||
def _get_text(server, port, path, timeout=DEFAULT_TIMEOUT):
|
||||
request = Request(_api_url(server, port, path), method='GET')
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
return response.status, response.read().decode('utf-8')
|
||||
|
||||
|
||||
def _server_list_from_preferences(preferences):
|
||||
return [server.strip() for server in preferences.servers.split(',') if server.strip()]
|
||||
|
||||
|
||||
def _save_server_list(preferences, servers):
|
||||
unique_servers = []
|
||||
for server in servers:
|
||||
if server and server not in unique_servers:
|
||||
unique_servers.append(server)
|
||||
preferences.servers = ', '.join(unique_servers)
|
||||
|
||||
|
||||
def _discover_servers_with_zeroconf(timeout=3.0):
|
||||
try:
|
||||
from zeroconf import ServiceBrowser, Zeroconf
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
discovered = []
|
||||
zeroconf = Zeroconf()
|
||||
|
||||
class Listener:
|
||||
def add_service(self, zc, service_type, name):
|
||||
info = zc.get_service_info(service_type, name, timeout=1000)
|
||||
if info and info.server and info.port:
|
||||
discovered.append(f'{info.server.rstrip(".")}:{info.port}')
|
||||
|
||||
def update_service(self, zc, service_type, name):
|
||||
self.add_service(zc, service_type, name)
|
||||
|
||||
def remove_service(self, zc, service_type, name):
|
||||
pass
|
||||
|
||||
try:
|
||||
ServiceBrowser(zeroconf, ZORDON_SERVICE_TYPE, Listener())
|
||||
time.sleep(timeout)
|
||||
finally:
|
||||
zeroconf.close()
|
||||
|
||||
return discovered
|
||||
|
||||
|
||||
def _discover_service_names_with_dns_sd(timeout=2.0):
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
['dns-sd', '-B', '_zordon', '_tcp', 'local'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
return []
|
||||
|
||||
try:
|
||||
stdout, _ = process.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.terminate()
|
||||
stdout, _ = process.communicate(timeout=1)
|
||||
|
||||
service_names = []
|
||||
for line in stdout.splitlines():
|
||||
if ' Add ' not in line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 7:
|
||||
service_name = ' '.join(parts[6:])
|
||||
if service_name and service_name not in service_names:
|
||||
service_names.append(service_name)
|
||||
return service_names
|
||||
|
||||
|
||||
def _resolve_service_with_dns_sd(service_name, timeout=2.0):
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
['dns-sd', '-L', service_name, '_zordon', '_tcp', 'local'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
try:
|
||||
stdout, _ = process.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.terminate()
|
||||
stdout, _ = process.communicate(timeout=1)
|
||||
|
||||
for line in stdout.splitlines():
|
||||
if ' can be reached at ' not in line:
|
||||
continue
|
||||
match = re.search(r'can be reached at\s+([^:\s]+):(\d+)', line)
|
||||
if match:
|
||||
hostname, port = match.groups()
|
||||
return f'{hostname.rstrip(".")}:{port}'
|
||||
return None
|
||||
|
||||
|
||||
def _discover_servers_with_dns_sd():
|
||||
servers = []
|
||||
for service_name in _discover_service_names_with_dns_sd():
|
||||
server = _resolve_service_with_dns_sd(service_name)
|
||||
if server and server not in servers:
|
||||
servers.append(server)
|
||||
return servers
|
||||
|
||||
|
||||
def _discover_zordon_servers():
|
||||
discovered = _discover_servers_with_zeroconf()
|
||||
if discovered:
|
||||
return discovered
|
||||
return _discover_servers_with_dns_sd()
|
||||
|
||||
|
||||
def _send_multipart_job(server, port, job_data, file_path):
|
||||
scheme, hostname, parsed_port = _normalize_server_url(server, port)
|
||||
if scheme != 'http':
|
||||
raise ValueError('Only http:// Zordon servers are currently supported.')
|
||||
|
||||
boundary = f'----ZordonBlender{int(time.time() * 1000)}'
|
||||
json_payload = json.dumps(job_data).encode('utf-8')
|
||||
file_name = os.path.basename(file_path)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
parts_before_file = [
|
||||
f'--{boundary}\r\n',
|
||||
'Content-Disposition: form-data; name="json"\r\n',
|
||||
'Content-Type: application/json\r\n\r\n',
|
||||
json_payload.decode('utf-8'),
|
||||
'\r\n',
|
||||
f'--{boundary}\r\n',
|
||||
f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n',
|
||||
'Content-Type: application/octet-stream\r\n\r\n',
|
||||
]
|
||||
preamble = ''.join(parts_before_file).encode('utf-8')
|
||||
closing = f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||
content_length = len(preamble) + file_size + len(closing)
|
||||
|
||||
connection = http.client.HTTPConnection(hostname, parsed_port, timeout=UPLOAD_TIMEOUT)
|
||||
try:
|
||||
connection.putrequest('POST', '/api/jobs')
|
||||
connection.putheader('Content-Type', f'multipart/form-data; boundary={boundary}')
|
||||
connection.putheader('Content-Length', str(content_length))
|
||||
connection.endheaders()
|
||||
connection.send(preamble)
|
||||
|
||||
with open(file_path, 'rb') as upload_file:
|
||||
while True:
|
||||
chunk = upload_file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
connection.send(chunk)
|
||||
|
||||
connection.send(closing)
|
||||
response = connection.getresponse()
|
||||
response_body = response.read().decode('utf-8', errors='replace')
|
||||
return response.status, response_body
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _current_file_path():
|
||||
return bpy.data.filepath
|
||||
|
||||
|
||||
def _default_job_name():
|
||||
file_path = _current_file_path()
|
||||
if file_path:
|
||||
return os.path.splitext(os.path.basename(file_path))[0]
|
||||
return 'Blender Render'
|
||||
|
||||
|
||||
def _camera_objects():
|
||||
return [obj for obj in bpy.data.objects if obj.type == 'CAMERA']
|
||||
|
||||
|
||||
def _default_selected_camera_names(scene):
|
||||
cameras = _camera_objects()
|
||||
if not cameras:
|
||||
return []
|
||||
if scene.camera:
|
||||
return [scene.camera.name]
|
||||
if len(cameras) == 1:
|
||||
return [cameras[0].name]
|
||||
return []
|
||||
|
||||
|
||||
def _selected_camera_names(props, scene=None):
|
||||
if props.selected_cameras:
|
||||
try:
|
||||
names = json.loads(props.selected_cameras)
|
||||
except json.JSONDecodeError:
|
||||
names = []
|
||||
existing_names = {camera.name for camera in _camera_objects()}
|
||||
selected_names = [name for name in names if name in existing_names]
|
||||
if selected_names:
|
||||
return selected_names
|
||||
if scene:
|
||||
return _default_selected_camera_names(scene)
|
||||
return []
|
||||
|
||||
|
||||
def _set_selected_camera_names(props, camera_names):
|
||||
props.selected_cameras = json.dumps(list(camera_names))
|
||||
|
||||
|
||||
def _active_camera_names(scene):
|
||||
if scene.camera:
|
||||
return [scene.camera.name]
|
||||
return _default_selected_camera_names(scene)
|
||||
|
||||
|
||||
def _scene_export_format(scene):
|
||||
file_format = scene.render.image_settings.file_format
|
||||
return file_format or 'PNG'
|
||||
|
||||
|
||||
class ZORDON_AddonPreferences(AddonPreferences):
|
||||
bl_idname = __name__
|
||||
|
||||
servers: StringProperty(
|
||||
name='Servers',
|
||||
description='Comma-separated Zordon servers. Example: studio-render.local:8080, localhost:8080',
|
||||
default=DEFAULT_SERVER,
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, 'servers')
|
||||
layout.operator('zordon.discover_servers', icon='VIEWZOOM')
|
||||
|
||||
|
||||
class ZORDON_SubmissionProperties(PropertyGroup):
|
||||
server: EnumProperty(
|
||||
name='Server',
|
||||
description='Zordon server to submit this file to',
|
||||
items=_configured_server_items,
|
||||
)
|
||||
port: IntProperty(
|
||||
name='Fallback Port',
|
||||
description='Port used when the selected server does not include one',
|
||||
default=8080,
|
||||
min=1,
|
||||
max=65535,
|
||||
)
|
||||
job_name: StringProperty(
|
||||
name='Job Name',
|
||||
description='Name shown in Zordon',
|
||||
default='',
|
||||
)
|
||||
output_path: StringProperty(
|
||||
name='Output Name',
|
||||
description='Output name or path used by the Zordon job',
|
||||
default='',
|
||||
)
|
||||
notes: StringProperty(
|
||||
name='Notes',
|
||||
description='Optional notes for the Zordon job',
|
||||
default='',
|
||||
)
|
||||
save_before_submit: BoolProperty(
|
||||
name='Save Before Submit',
|
||||
description='Save the current .blend before uploading it',
|
||||
default=True,
|
||||
)
|
||||
use_scene_frame_range: BoolProperty(
|
||||
name='Use Scene Frame Range',
|
||||
description='Submit the current scene frame start and end',
|
||||
default=True,
|
||||
)
|
||||
selected_cameras: StringProperty(
|
||||
name='Selected Cameras',
|
||||
description='JSON encoded list of selected camera names',
|
||||
default='',
|
||||
options={'HIDDEN'},
|
||||
)
|
||||
|
||||
|
||||
class ZORDON_OT_DiscoverServers(Operator):
|
||||
bl_idname = 'zordon.discover_servers'
|
||||
bl_label = 'Discover Servers'
|
||||
bl_description = 'Find Zordon servers advertised with Zeroconf'
|
||||
|
||||
def execute(self, context):
|
||||
preferences = _addon_preferences(context)
|
||||
existing_servers = _server_list_from_preferences(preferences)
|
||||
discovered_servers = _discover_zordon_servers()
|
||||
if not discovered_servers:
|
||||
self.report({'WARNING'}, 'No Zordon servers found.')
|
||||
return {'CANCELLED'}
|
||||
|
||||
merged_servers = existing_servers + discovered_servers
|
||||
_save_server_list(preferences, merged_servers)
|
||||
|
||||
props = getattr(context.scene, 'zordon_submission', None)
|
||||
if props:
|
||||
try:
|
||||
props.server = discovered_servers[0]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
self.report({'INFO'}, f'Found {len(discovered_servers)} Zordon server(s).')
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ZORDON_OT_TestConnection(Operator):
|
||||
bl_idname = 'zordon.test_connection'
|
||||
bl_label = 'Test Connection'
|
||||
bl_description = 'Check whether the selected Zordon server is reachable'
|
||||
|
||||
def execute(self, context):
|
||||
props = context.scene.zordon_submission
|
||||
try:
|
||||
status, body = _get_text(props.server, props.port, '/api/heartbeat')
|
||||
except (HTTPError, URLError, TimeoutError, OSError) as error:
|
||||
self.report({'ERROR'}, f'Could not reach Zordon: {error}')
|
||||
return {'CANCELLED'}
|
||||
|
||||
if status == 200:
|
||||
self.report({'INFO'}, f'Connected to Zordon: {body}')
|
||||
return {'FINISHED'}
|
||||
|
||||
self.report({'ERROR'}, f'Unexpected response from Zordon: HTTP {status}')
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
class ZORDON_OT_RefreshCameras(Operator):
|
||||
bl_idname = 'zordon.refresh_cameras'
|
||||
bl_label = 'Refresh Cameras'
|
||||
bl_description = 'Refresh the camera checklist from the current Blender scene'
|
||||
|
||||
def execute(self, context):
|
||||
_set_selected_camera_names(
|
||||
context.scene.zordon_submission,
|
||||
_default_selected_camera_names(context.scene),
|
||||
)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ZORDON_OT_ToggleCamera(Operator):
|
||||
bl_idname = 'zordon.toggle_camera'
|
||||
bl_label = 'Toggle Camera'
|
||||
bl_description = 'Toggle whether this camera is submitted to Zordon'
|
||||
|
||||
camera_name: StringProperty(name='Camera Name')
|
||||
|
||||
def execute(self, context):
|
||||
props = context.scene.zordon_submission
|
||||
selected = set(_selected_camera_names(props, context.scene))
|
||||
if self.camera_name in selected:
|
||||
if len(selected) == 1:
|
||||
self.report({'WARNING'}, 'At least one camera must be selected.')
|
||||
return {'CANCELLED'}
|
||||
selected.remove(self.camera_name)
|
||||
else:
|
||||
selected.add(self.camera_name)
|
||||
_set_selected_camera_names(props, sorted(selected))
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ZORDON_OT_SelectAllCameras(Operator):
|
||||
bl_idname = 'zordon.select_all_cameras'
|
||||
bl_label = 'All'
|
||||
bl_description = 'Select all cameras for submission'
|
||||
|
||||
def execute(self, context):
|
||||
props = context.scene.zordon_submission
|
||||
_set_selected_camera_names(props, [camera.name for camera in _camera_objects()])
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ZORDON_OT_SelectActiveCamera(Operator):
|
||||
bl_idname = 'zordon.select_active_camera'
|
||||
bl_label = 'Active Only'
|
||||
bl_description = 'Select only the active scene camera'
|
||||
|
||||
def execute(self, context):
|
||||
active_cameras = _active_camera_names(context.scene)
|
||||
if not active_cameras:
|
||||
self.report({'WARNING'}, 'No cameras found.')
|
||||
return {'CANCELLED'}
|
||||
_set_selected_camera_names(context.scene.zordon_submission, active_cameras)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
class ZORDON_OT_SubmitCurrentFile(Operator):
|
||||
bl_idname = 'zordon.submit_current_file'
|
||||
bl_label = 'Submit Current File'
|
||||
bl_description = 'Upload the current Blender file to the selected Zordon server'
|
||||
|
||||
def execute(self, context):
|
||||
props = context.scene.zordon_submission
|
||||
scene = context.scene
|
||||
file_path = _current_file_path()
|
||||
|
||||
if not file_path:
|
||||
self.report({'ERROR'}, 'Save this Blender file before submitting it to Zordon.')
|
||||
return {'CANCELLED'}
|
||||
|
||||
if props.save_before_submit:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=file_path)
|
||||
|
||||
job_name = props.job_name.strip() or _default_job_name()
|
||||
output_path = props.output_path.strip() or job_name
|
||||
selected_cameras = _selected_camera_names(props, scene)
|
||||
if not selected_cameras:
|
||||
self.report({'ERROR'}, 'Add at least one camera before submitting to Zordon.')
|
||||
return {'CANCELLED'}
|
||||
resolution = (
|
||||
int(scene.render.resolution_x),
|
||||
int(scene.render.resolution_y),
|
||||
)
|
||||
|
||||
job_data = {
|
||||
'owner': f'{getpass.getuser()}@{socket.gethostname()}',
|
||||
'engine_name': 'blender',
|
||||
'engine_version': 'latest',
|
||||
'name': job_name,
|
||||
'output_path': output_path,
|
||||
'start_frame': int(scene.frame_start) if props.use_scene_frame_range else int(scene.frame_current),
|
||||
'end_frame': int(scene.frame_end) if props.use_scene_frame_range else int(scene.frame_current),
|
||||
'priority': 1,
|
||||
'notes': props.notes,
|
||||
'enable_split_jobs': False,
|
||||
'split_jobs_same_os': False,
|
||||
'args': {
|
||||
'raw': '',
|
||||
'export_format': _scene_export_format(scene),
|
||||
'resolution': resolution,
|
||||
'fps': scene.render.fps,
|
||||
},
|
||||
}
|
||||
|
||||
if len(selected_cameras) == 1:
|
||||
job_data['args']['camera'] = selected_cameras[0]
|
||||
elif len(selected_cameras) > 1:
|
||||
child_jobs = []
|
||||
for camera_name in selected_cameras:
|
||||
camera_suffix = camera_name.replace(' ', '-')
|
||||
child_args = dict(job_data['args'])
|
||||
child_args['camera'] = camera_name
|
||||
child_jobs.append({
|
||||
'name': f'{job_name}_{camera_suffix}',
|
||||
'output_path': f'{output_path}_{camera_suffix}',
|
||||
'args': child_args,
|
||||
})
|
||||
job_data['child_jobs'] = child_jobs
|
||||
|
||||
try:
|
||||
status, body = _send_multipart_job(props.server, props.port, job_data, file_path)
|
||||
except (ValueError, TimeoutError, OSError) as error:
|
||||
self.report({'ERROR'}, f'Error submitting to Zordon: {error}')
|
||||
return {'CANCELLED'}
|
||||
|
||||
if 200 <= status < 300:
|
||||
self.report({'INFO'}, f'Submitted "{job_name}" to Zordon.')
|
||||
return {'FINISHED'}
|
||||
|
||||
self.report({'ERROR'}, f'Zordon returned HTTP {status}: {body}')
|
||||
return {'CANCELLED'}
|
||||
|
||||
|
||||
class ZORDON_PT_SubmitPanel(Panel):
|
||||
bl_label = 'Zordon'
|
||||
bl_idname = 'ZORDON_PT_submit_panel'
|
||||
bl_space_type = 'PROPERTIES'
|
||||
bl_region_type = 'WINDOW'
|
||||
bl_context = 'render'
|
||||
|
||||
def draw(self, context):
|
||||
props = context.scene.zordon_submission
|
||||
layout = self.layout
|
||||
default_name = _default_job_name()
|
||||
cameras = _camera_objects()
|
||||
selected_cameras = set(_selected_camera_names(props, context.scene))
|
||||
|
||||
layout.prop(props, 'server')
|
||||
layout.prop(props, 'port')
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.operator('zordon.discover_servers', icon='VIEWZOOM')
|
||||
row.operator('zordon.test_connection', icon='LINKED')
|
||||
|
||||
layout.separator()
|
||||
if not props.job_name:
|
||||
layout.label(text=f'Job Name Default: {default_name}')
|
||||
layout.prop(props, 'job_name')
|
||||
if not props.output_path:
|
||||
layout.label(text=f'Output Name Default: {default_name}')
|
||||
layout.prop(props, 'output_path')
|
||||
layout.prop(props, 'notes')
|
||||
layout.prop(props, 'save_before_submit')
|
||||
layout.prop(props, 'use_scene_frame_range')
|
||||
|
||||
if props.use_scene_frame_range:
|
||||
layout.label(text=f'Frames: {context.scene.frame_start} - {context.scene.frame_end}')
|
||||
else:
|
||||
layout.label(text=f'Frame: {context.scene.frame_current}')
|
||||
|
||||
layout.label(text=f'Format: {_scene_export_format(context.scene)}')
|
||||
|
||||
layout.separator()
|
||||
camera_header = layout.row(align=True)
|
||||
camera_header.label(text='Cameras')
|
||||
camera_header.operator('zordon.refresh_cameras', text='', icon='FILE_REFRESH')
|
||||
camera_header.operator('zordon.select_all_cameras', text='All')
|
||||
camera_header.operator('zordon.select_active_camera', text='Active Only')
|
||||
|
||||
if cameras:
|
||||
for camera in cameras:
|
||||
icon = 'CHECKBOX_HLT' if camera.name in selected_cameras else 'CHECKBOX_DEHLT'
|
||||
label = f'{camera.name} - {camera.data.lens:g}mm'
|
||||
row = layout.row()
|
||||
op = row.operator('zordon.toggle_camera', text=label, icon=icon, depress=camera.name in selected_cameras)
|
||||
op.camera_name = camera.name
|
||||
else:
|
||||
layout.label(text='No cameras found')
|
||||
|
||||
layout.operator('zordon.submit_current_file', icon='RENDER_ANIMATION')
|
||||
|
||||
|
||||
classes = (
|
||||
ZORDON_AddonPreferences,
|
||||
ZORDON_SubmissionProperties,
|
||||
ZORDON_OT_DiscoverServers,
|
||||
ZORDON_OT_TestConnection,
|
||||
ZORDON_OT_RefreshCameras,
|
||||
ZORDON_OT_ToggleCamera,
|
||||
ZORDON_OT_SelectAllCameras,
|
||||
ZORDON_OT_SelectActiveCamera,
|
||||
ZORDON_OT_SubmitCurrentFile,
|
||||
ZORDON_PT_SubmitPanel,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
bpy.types.Scene.zordon_submission = bpy.props.PointerProperty(type=ZORDON_SubmissionProperties)
|
||||
|
||||
|
||||
def unregister():
|
||||
del bpy.types.Scene.zordon_submission
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
register()
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
# Zordon API Reference
|
||||
|
||||
Zordon exposes a Flask API from `src/api/api_server.py`. The server is started by
|
||||
`start_api_server()` and listens on `Config.port_number` with all application
|
||||
routes mounted under `/api`.
|
||||
|
||||
The in-repo client wrapper is `src/api/server_proxy.py`. Most UI and distributed
|
||||
rendering code should prefer `RenderServerProxy` instead of constructing request
|
||||
URLs directly.
|
||||
|
||||
## Versioning
|
||||
|
||||
- Current API version: `0.1`
|
||||
- `RenderServerProxy.request()` sends `X-API-Version` with the current
|
||||
`API_VERSION`, but the server does not currently validate this header.
|
||||
|
||||
## Response Conventions
|
||||
|
||||
- JSON endpoints return Flask-serialized dictionaries or lists.
|
||||
- File endpoints return `send_file()` responses.
|
||||
- Most error responses are plain text with HTTP `400`, `404`, `500`, or `503`.
|
||||
- `JobNotFoundError` is mapped to HTTP `400`.
|
||||
- Unhandled exceptions are mapped to HTTP `500` with a plain-text message.
|
||||
|
||||
## Jobs
|
||||
|
||||
### `GET /api/jobs`
|
||||
|
||||
Returns all render jobs and a cache token.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobs": [
|
||||
{
|
||||
"id": "job-id",
|
||||
"name": "job name",
|
||||
"status": "running"
|
||||
}
|
||||
],
|
||||
"token": "cache-token"
|
||||
}
|
||||
```
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_jobs()`
|
||||
- `src/ui/main_window.py`
|
||||
|
||||
### `GET /api/jobs/long_poll`
|
||||
|
||||
Long-polls the job list until the supplied cache token changes or 30 seconds
|
||||
elapse.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `token` | No | Cache token returned by `/api/jobs`. |
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` with the same shape as `/api/jobs` when jobs changed.
|
||||
- `204` with an empty body when no changes arrive before timeout.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_jobs()` through the background cache updater.
|
||||
|
||||
### `GET /api/jobs/status/<status_val>`
|
||||
|
||||
Returns jobs matching a render status.
|
||||
|
||||
Path parameters:
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `status_val` | Status string converted by `string_to_status()`. |
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` with a list of job JSON objects when matches exist.
|
||||
- `400` when no jobs match the requested status.
|
||||
|
||||
Review note: this route is not currently wrapped by `RenderServerProxy` and no
|
||||
in-repo callers were found.
|
||||
|
||||
### `GET /api/jobs/<job_id>`
|
||||
|
||||
Returns one job as JSON.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_job()`
|
||||
- `add_job.py`
|
||||
- `src/ui/main_window.py`
|
||||
- `src/distributed_job_manager.py`
|
||||
- `tests/job_creation_tests.py`
|
||||
|
||||
### `GET /api/jobs/<job_id>/logs`
|
||||
|
||||
Returns the job log file as `text/plain`.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `src/ui/main_window.py` opens this URL directly.
|
||||
|
||||
### `GET /api/jobs/<job_id>/files`
|
||||
|
||||
Returns a list of output filenames for the job.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_job_files()`
|
||||
- `src/utilities/server_helper.py`
|
||||
|
||||
### `GET /api/jobs/<job_id>/download`
|
||||
|
||||
Downloads one output file.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `filename` | Yes | Case-insensitive filename from the job file list. |
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` with the requested file as an attachment.
|
||||
- `400` when `filename` is missing.
|
||||
- `404` when the file is not found.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.download_job_file()`
|
||||
- `src/utilities/server_helper.py`
|
||||
|
||||
### `GET /api/jobs/<job_id>/download_all`
|
||||
|
||||
Creates a temporary zip of the job output directory and downloads it.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.download_all_job_files()`
|
||||
- `src/ui/main_window.py`
|
||||
- `src/utilities/server_helper.py`
|
||||
|
||||
### `GET /api/jobs/<job_id>/thumbnail`
|
||||
|
||||
Returns a generated preview image or video for a job.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `size` | No | `big` selects the larger preview path. Currently parsed but not applied. |
|
||||
| `video_ok` | No | If truthy and a video preview exists, video can be returned. |
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` with `image/jpeg` or `video/mp4`.
|
||||
- `404` when no thumbnail is available.
|
||||
- `500` on preview generation errors.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `src/ui/main_window.py`
|
||||
|
||||
Review note: `size=big` is parsed into `big_thumb` but not used.
|
||||
|
||||
## Job Lifecycle
|
||||
|
||||
### `POST /api/jobs`
|
||||
|
||||
Adds one or more render jobs.
|
||||
|
||||
Request formats:
|
||||
|
||||
- JSON request body.
|
||||
- Multipart form with a `json` field and optional `file` upload.
|
||||
|
||||
Common job fields include:
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `name` | Display name for the render job. |
|
||||
| `renderer` | Render engine name such as `blender` or `ffmpeg`. |
|
||||
| `start_frame` | First frame to render. |
|
||||
| `end_frame` | Last frame to render. |
|
||||
| `args` | Engine-specific render arguments. |
|
||||
| `enable_split_jobs` | Whether distributed subjobs may be created. |
|
||||
| `child_jobs` | Optional subjob definitions. |
|
||||
| `local_path` | Local file path used when posting to localhost. |
|
||||
|
||||
Responses:
|
||||
|
||||
- `200` with created job data.
|
||||
- `400` for invalid or missing job data.
|
||||
- `500` for unexpected processing or creation errors.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.create_job()`
|
||||
- `add_job.py`
|
||||
- `src/ui/add_job_window.py`
|
||||
- `src/distributed_job_manager.py`
|
||||
- `tests/job_creation_tests.py`
|
||||
|
||||
### `POST /api/jobs/<job_id>/cancel`
|
||||
|
||||
Cancels a job.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `confirm` | Yes | Must be truthy or the request is rejected. |
|
||||
| `redirect` | No | If truthy, redirects to `index`. |
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.cancel_job()`
|
||||
- `src/ui/main_window.py`
|
||||
- `src/distributed_job_manager.py`
|
||||
|
||||
### `POST /api/jobs/<job_id>/delete`
|
||||
|
||||
Deletes a job, stops it first, deletes previews, and removes owned upload/output
|
||||
directories when safe.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `confirm` | Yes | Must be truthy or the request is rejected. |
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.delete_job()`
|
||||
- `src/ui/main_window.py`
|
||||
|
||||
### `POST /api/jobs/<job_id>/subjob_update`
|
||||
|
||||
Notifies a parent job that a child/subjob changed state.
|
||||
|
||||
Request body:
|
||||
|
||||
- JSON representation of a subjob.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.send_subjob_update_notification()`
|
||||
- `src/distributed_job_manager.py`
|
||||
|
||||
## Status and Environment
|
||||
|
||||
### `GET /api/heartbeat`
|
||||
|
||||
Returns the current timestamp as plain text. Used for fast connectivity checks.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.check_connection()`
|
||||
|
||||
### `GET /api/status`
|
||||
|
||||
Returns local system and queue status.
|
||||
|
||||
Response includes:
|
||||
|
||||
- timestamp
|
||||
- operating system and version
|
||||
- CPU brand, count, and current utilization
|
||||
- memory totals and current utilization
|
||||
- job counts
|
||||
- hostname and port
|
||||
- app and API versions
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_status()`
|
||||
|
||||
### `GET /api/presets`
|
||||
|
||||
Returns `config/presets.yaml`.
|
||||
|
||||
Review note: no in-repo callers were found.
|
||||
|
||||
### `GET /api/cpu_benchmark`
|
||||
|
||||
Runs a CPU benchmark for 10 seconds and returns the score as plain text.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `src/utilities/server_helper.py`
|
||||
|
||||
### `GET /api/disk_benchmark`
|
||||
|
||||
Runs a disk I/O benchmark and returns write/read speeds.
|
||||
|
||||
Review note: no in-repo callers were found.
|
||||
|
||||
## Engines
|
||||
|
||||
### `GET /api/engines/for_filename`
|
||||
|
||||
Returns the engine name suitable for a project filename.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `filename` | Yes | Project filename or path. The client currently sends only the basename. |
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_engine_for_filename()`
|
||||
- `src/ui/add_job_window.py`
|
||||
|
||||
### `GET /api/engines`
|
||||
|
||||
Returns installed engine data keyed by engine name.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `response_type` | No | `standard` or `full`; defaults to `standard`. |
|
||||
|
||||
`full` responses also include supported extensions, supported export formats,
|
||||
system info, and UI options.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_engines()`
|
||||
- `src/ui/settings_window.py`
|
||||
- `src/ui/engine_browser.py`
|
||||
|
||||
### `GET /api/engines/names`
|
||||
|
||||
Returns installed engine names as a list without instantiating engine classes.
|
||||
Use this for lightweight selection UIs that only need engine names.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_engine_names()`
|
||||
- `src/ui/add_job_window.py`
|
||||
|
||||
### `GET /api/engines/<engine_name>`
|
||||
|
||||
Returns installed version data for a single engine.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `response_type` | No | `standard` or `full`; defaults to `standard`. |
|
||||
|
||||
`full` responses also include supported extensions, supported export formats,
|
||||
system info, and UI options.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_engine()`
|
||||
- `src/ui/add_job_window.py`
|
||||
|
||||
### `GET /api/engines/<engine_name>/availability`
|
||||
|
||||
Returns whether an engine can accept jobs on this server, plus CPU count,
|
||||
installed versions, and hostname.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.get_engine_availability()`
|
||||
- `src/distributed_job_manager.py`
|
||||
|
||||
### `GET /api/engines/<engine_name>/args`
|
||||
|
||||
Returns engine arguments.
|
||||
|
||||
Review note: no in-repo callers were found.
|
||||
|
||||
### `GET /api/engines/<engine_name>/help`
|
||||
|
||||
Returns engine help text.
|
||||
|
||||
Known callers:
|
||||
|
||||
- `src/ui/add_job_window.py` opens this URL directly.
|
||||
|
||||
### `GET /api/engines/download_available`
|
||||
|
||||
Checks whether a managed engine version is available to download.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `engine` | Yes | Engine name. |
|
||||
| `version` | Yes | Engine version. |
|
||||
| `system_os` | No | Target OS. |
|
||||
| `cpu` | No | Target CPU architecture. |
|
||||
|
||||
Review note: no in-repo callers were found.
|
||||
|
||||
### `GET /api/engines/most_recent_version`
|
||||
|
||||
Finds the most recent downloadable version for an engine.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `engine` | Yes | Engine name. |
|
||||
| `system_os` | No | Target OS. |
|
||||
| `cpu` | No | Target CPU architecture. |
|
||||
|
||||
Review note: no in-repo callers were found.
|
||||
|
||||
### `POST /api/engines/download`
|
||||
|
||||
Downloads a managed engine version.
|
||||
|
||||
Query parameters:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `engine` | Yes | Engine name. |
|
||||
| `version` | Yes | Engine version. |
|
||||
| `system_os` | No | Target OS. |
|
||||
| `cpu` | No | Target CPU architecture. |
|
||||
|
||||
Review note: no in-repo callers were found. Settings currently calls
|
||||
`EngineManager.download_engine()` directly instead of this API route.
|
||||
|
||||
### `POST /api/engines/delete`
|
||||
|
||||
Deletes a managed engine download.
|
||||
|
||||
JSON body:
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `engine` | Yes | Engine name. |
|
||||
| `version` | Yes | Engine version. |
|
||||
| `system_os` | No | Target OS. |
|
||||
| `cpu` | No | Target CPU architecture. |
|
||||
|
||||
Known callers:
|
||||
|
||||
- `RenderServerProxy.delete_engine_download()`
|
||||
- `src/ui/engine_browser.py`
|
||||
|
||||
## Debug
|
||||
|
||||
### `GET /api/_debug/detected_clients`
|
||||
|
||||
Returns hostnames detected by Zeroconf.
|
||||
|
||||
Review note: development/debug only, with an inline comment saying it probably
|
||||
should not ship.
|
||||
|
||||
### `POST /api/_debug/clear_history`
|
||||
|
||||
Clears render queue history and returns `success`.
|
||||
|
||||
Review note: development/debug only.
|
||||
|
||||
## Redundancy and Cleanup Review
|
||||
|
||||
Routes with no in-repo callers found:
|
||||
|
||||
- `GET /api/jobs/status/<status_val>`
|
||||
- `GET /api/presets`
|
||||
- `GET /api/disk_benchmark`
|
||||
- `GET /api/engines/<engine_name>/args`
|
||||
- `GET /api/engines/download_available`
|
||||
- `GET /api/engines/most_recent_version`
|
||||
- `POST /api/engines/download`
|
||||
- `GET /api/_debug/detected_clients`
|
||||
- `POST /api/_debug/clear_history`
|
||||
|
||||
Routes or methods with cleanup risks:
|
||||
|
||||
- `job_thumbnail()` parses `size=big` but never uses the resulting `big_thumb`
|
||||
value.
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zordon API Reference</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--surface2: #1c2333;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--blue: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--orange: #d29922;
|
||||
--red: #f85149;
|
||||
--purple: #bc8cff;
|
||||
--cyan: #39d2c0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 56px 24px 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 2.7rem;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
header p {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 24px 56px;
|
||||
}
|
||||
|
||||
nav {
|
||||
position: sticky;
|
||||
top: 18px;
|
||||
align-self: start;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
nav strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: block;
|
||||
padding: 5px 0;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: .92rem;
|
||||
}
|
||||
|
||||
nav a:hover { color: var(--blue); }
|
||||
|
||||
section { margin-bottom: 44px; }
|
||||
|
||||
h2 {
|
||||
display: inline-block;
|
||||
margin: 0 0 18px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--blue);
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 10px;
|
||||
color: var(--cyan);
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
p, li { color: var(--text-muted); }
|
||||
|
||||
code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
background: var(--surface2);
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
color: var(--cyan);
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 12px 0;
|
||||
padding: 14px;
|
||||
overflow-x: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
border-collapse: collapse;
|
||||
font-size: .92rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td { color: var(--text-muted); }
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.panel, .endpoint {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel h3 { color: var(--text); }
|
||||
.panel ul { margin: 8px 0 0; padding-left: 20px; }
|
||||
|
||||
.endpoint {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.method {
|
||||
display: inline-block;
|
||||
min-width: 52px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: .76rem;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
.get { background: rgba(88, 166, 255, .16); color: var(--blue); border: 1px solid rgba(88, 166, 255, .35); }
|
||||
.post { background: rgba(63, 185, 80, .16); color: var(--green); border: 1px solid rgba(63, 185, 80, .35); }
|
||||
.multi { background: rgba(210, 153, 34, .16); color: var(--orange); border: 1px solid rgba(210, 153, 34, .35); }
|
||||
|
||||
.path {
|
||||
color: var(--text);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--orange);
|
||||
background: rgba(210, 153, 34, .08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-left-color: var(--red);
|
||||
background: rgba(248, 81, 73, .08);
|
||||
}
|
||||
|
||||
.callers {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.callers li { margin: 2px 0; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
main { grid-template-columns: 1fr; }
|
||||
nav { position: static; }
|
||||
header h1 { font-size: 2.1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Zordon API Reference</h1>
|
||||
<p>Flask endpoints exposed by <code>src/api/api_server.py</code>, with the in-repo client wrapper in <code>src/api/server_proxy.py</code>.</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<nav aria-label="API sections">
|
||||
<strong>Sections</strong>
|
||||
<a href="#overview">Overview</a>
|
||||
<a href="#jobs">Jobs</a>
|
||||
<a href="#job-lifecycle">Job Lifecycle</a>
|
||||
<a href="#status">Status and Environment</a>
|
||||
<a href="#engines">Engines</a>
|
||||
<a href="#debug">Debug</a>
|
||||
<a href="#cleanup">Cleanup Review</a>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<section id="overview">
|
||||
<h2>Overview</h2>
|
||||
<div class="meta-grid">
|
||||
<div class="panel">
|
||||
<h3>Versioning</h3>
|
||||
<ul>
|
||||
<li>Current API version: <code>0.1</code></li>
|
||||
<li><code>RenderServerProxy.request()</code> sends <code>X-API-Version</code>.</li>
|
||||
<li>The server does not currently validate that header.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Response Conventions</h3>
|
||||
<ul>
|
||||
<li>JSON endpoints return Flask-serialized dictionaries or lists.</li>
|
||||
<li>File endpoints return <code>send_file()</code> responses.</li>
|
||||
<li>Most errors are plain text with <code>400</code>, <code>404</code>, <code>500</code>, or <code>503</code>.</li>
|
||||
<li><code>JobNotFoundError</code> maps to HTTP <code>400</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="jobs">
|
||||
<h2>Jobs</h2>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs</span></div>
|
||||
<p>Returns all render jobs and a cache token.</p>
|
||||
<pre><code>{
|
||||
"jobs": [{"id": "job-id", "name": "job name", "status": "running"}],
|
||||
"token": "cache-token"
|
||||
}</code></pre>
|
||||
<p>Known callers:</p>
|
||||
<ul class="callers">
|
||||
<li><code>RenderServerProxy.get_jobs()</code></li>
|
||||
<li><code>src/ui/main_window.py</code></li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/long_poll</span></div>
|
||||
<p>Long-polls the job list until the supplied cache token changes or 30 seconds elapse.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>token</code></td><td>No</td><td>Cache token returned by <code>/api/jobs</code>.</td></tr>
|
||||
</table>
|
||||
<p>Returns <code>200</code> with job data when changed, or <code>204</code> when no change arrives before timeout.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/status/<status_val></span></div>
|
||||
<p>Returns jobs matching a render status converted by <code>string_to_status()</code>.</p>
|
||||
<div class="note">No <code>RenderServerProxy</code> wrapper or in-repo caller was found.</div>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id></span></div>
|
||||
<p>Returns one job as JSON.</p>
|
||||
<p>Known callers include <code>RenderServerProxy.get_job()</code>, <code>add_job.py</code>, <code>src/ui/main_window.py</code>, <code>src/distributed_job_manager.py</code>, and <code>tests/job_creation_tests.py</code>.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/logs</span></div>
|
||||
<p>Returns the job log file as <code>text/plain</code>. The main window opens this URL directly.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/files</span></div>
|
||||
<p>Returns a list of output filenames for the job.</p>
|
||||
<p>Known callers: <code>RenderServerProxy.get_job_files()</code> and <code>src/utilities/server_helper.py</code>.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/download</span></div>
|
||||
<p>Downloads one output file.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>filename</code></td><td>Yes</td><td>Case-insensitive filename from the job file list.</td></tr>
|
||||
</table>
|
||||
<p>Returns <code>200</code> with an attachment, <code>400</code> when <code>filename</code> is missing, or <code>404</code> when the file is not found.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/download_all</span></div>
|
||||
<p>Creates a temporary zip of the job output directory and downloads it.</p>
|
||||
<p>Known callers: <code>RenderServerProxy.download_all_job_files()</code>, <code>src/ui/main_window.py</code>, and <code>src/utilities/server_helper.py</code>.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/thumbnail</span></div>
|
||||
<p>Returns a generated preview image or video for a job.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>size</code></td><td>No</td><td><code>big</code> is parsed but not currently applied.</td></tr>
|
||||
<tr><td><code>video_ok</code></td><td>No</td><td>If truthy and a video preview exists, video can be returned.</td></tr>
|
||||
</table>
|
||||
<div class="note">Cleanup note: <code>size=big</code> is parsed into <code>big_thumb</code> but not used.</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="job-lifecycle">
|
||||
<h2>Job Lifecycle</h2>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/jobs</span></div>
|
||||
<p>Adds one or more render jobs. Accepts either a JSON request body or multipart form data with a <code>json</code> field and optional <code>file</code> upload.</p>
|
||||
<table>
|
||||
<tr><th>Common Field</th><th>Description</th></tr>
|
||||
<tr><td><code>name</code></td><td>Display name for the render job.</td></tr>
|
||||
<tr><td><code>renderer</code></td><td>Render engine name such as <code>blender</code> or <code>ffmpeg</code>.</td></tr>
|
||||
<tr><td><code>start_frame</code></td><td>First frame to render.</td></tr>
|
||||
<tr><td><code>end_frame</code></td><td>Last frame to render.</td></tr>
|
||||
<tr><td><code>args</code></td><td>Engine-specific render arguments.</td></tr>
|
||||
<tr><td><code>enable_split_jobs</code></td><td>Whether distributed subjobs may be created.</td></tr>
|
||||
<tr><td><code>child_jobs</code></td><td>Optional subjob definitions.</td></tr>
|
||||
<tr><td><code>local_path</code></td><td>Local file path used when posting to localhost.</td></tr>
|
||||
</table>
|
||||
<p>Known callers include <code>RenderServerProxy.create_job()</code>, <code>add_job.py</code>, <code>src/ui/add_job_window.py</code>, <code>src/distributed_job_manager.py</code>, and integration tests.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/jobs/<job_id>/cancel</span></div>
|
||||
<p>Cancels a job. Requires a truthy <code>confirm</code> query parameter.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>confirm</code></td><td>Yes</td><td>Must be truthy or the request is rejected.</td></tr>
|
||||
<tr><td><code>redirect</code></td><td>No</td><td>If truthy, redirects to <code>index</code>.</td></tr>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/jobs/<job_id>/delete</span></div>
|
||||
<p>Deletes a job, stops it first, deletes previews, and removes owned upload/output directories when safe.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>confirm</code></td><td>Yes</td><td>Must be truthy or the request is rejected.</td></tr>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/jobs/<job_id>/subjob_update</span></div>
|
||||
<p>Notifies a parent job that a child/subjob changed state. The request body is the JSON representation of the subjob.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="status">
|
||||
<h2>Status and Environment</h2>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/heartbeat</span></div>
|
||||
<p>Returns the current timestamp as plain text. Used by <code>RenderServerProxy.check_connection()</code>.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/status</span></div>
|
||||
<p>Returns local system and queue status, including operating system, CPU, memory, job counts, hostname, port, app version, and API version.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/presets</span></div>
|
||||
<p>Returns <code>config/presets.yaml</code>.</p>
|
||||
<div class="note">No in-repo callers were found.</div>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/cpu_benchmark</span></div>
|
||||
<p>Runs a CPU benchmark for 10 seconds and returns the score as plain text. Used by <code>src/utilities/server_helper.py</code>.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/disk_benchmark</span></div>
|
||||
<p>Runs a disk I/O benchmark and returns write/read speeds.</p>
|
||||
<div class="note">No in-repo callers were found.</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="engines">
|
||||
<h2>Engines</h2>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/for_filename</span></div>
|
||||
<p>Returns the engine name suitable for a project filename.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>filename</code></td><td>Yes</td><td>Project filename or path. The client currently sends only the basename.</td></tr>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines</span></div>
|
||||
<p>Returns installed engine data keyed by engine name.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>response_type</code></td><td>No</td><td><code>standard</code> or <code>full</code>; defaults to <code>standard</code>.</td></tr>
|
||||
</table>
|
||||
<p><code>full</code> responses include supported extensions, supported export formats, system info, and UI options.</p>
|
||||
<p>Known callers: <code>RenderServerProxy.get_engines()</code>, <code>src/ui/settings_window.py</code>, and <code>src/ui/engine_browser.py</code>.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/names</span></div>
|
||||
<p>Returns installed engine names as a list without instantiating engine classes. Use this for lightweight selection UIs that only need engine names.</p>
|
||||
<p>Known callers: <code>RenderServerProxy.get_engine_names()</code> and <code>src/ui/add_job_window.py</code>.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/<engine_name></span></div>
|
||||
<p>Returns installed version data for a single engine.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>response_type</code></td><td>No</td><td><code>standard</code> or <code>full</code>; defaults to <code>standard</code>.</td></tr>
|
||||
</table>
|
||||
<p><code>full</code> responses include supported extensions, supported export formats, system info, and UI options.</p>
|
||||
<p>Known caller: <code>RenderServerProxy.get_engine()</code> in the add-job window.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/<engine_name>/availability</span></div>
|
||||
<p>Returns whether an engine can accept jobs on this server, plus CPU count, installed versions, and hostname.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/<engine_name>/args</span></div>
|
||||
<p>Returns engine arguments.</p>
|
||||
<div class="note">No in-repo callers were found.</div>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/<engine_name>/help</span></div>
|
||||
<p>Returns engine help text. The add-job window opens this URL directly.</p>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/download_available</span></div>
|
||||
<p>Checks whether a managed engine version is available to download.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>engine</code></td><td>Yes</td><td>Engine name.</td></tr>
|
||||
<tr><td><code>version</code></td><td>Yes</td><td>Engine version.</td></tr>
|
||||
<tr><td><code>system_os</code></td><td>No</td><td>Target OS.</td></tr>
|
||||
<tr><td><code>cpu</code></td><td>No</td><td>Target CPU architecture.</td></tr>
|
||||
</table>
|
||||
<div class="note">No in-repo callers were found.</div>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/most_recent_version</span></div>
|
||||
<p>Finds the most recent downloadable version for an engine.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>engine</code></td><td>Yes</td><td>Engine name.</td></tr>
|
||||
<tr><td><code>system_os</code></td><td>No</td><td>Target OS.</td></tr>
|
||||
<tr><td><code>cpu</code></td><td>No</td><td>Target CPU architecture.</td></tr>
|
||||
</table>
|
||||
<div class="note">No in-repo callers were found.</div>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/engines/download</span></div>
|
||||
<p>Downloads a managed engine version.</p>
|
||||
<table>
|
||||
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>engine</code></td><td>Yes</td><td>Engine name.</td></tr>
|
||||
<tr><td><code>version</code></td><td>Yes</td><td>Engine version.</td></tr>
|
||||
<tr><td><code>system_os</code></td><td>No</td><td>Target OS.</td></tr>
|
||||
<tr><td><code>cpu</code></td><td>No</td><td>Target CPU architecture.</td></tr>
|
||||
</table>
|
||||
<div class="note">Settings currently calls <code>EngineManager.download_engine()</code> directly instead of this API route.</div>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/engines/delete</span></div>
|
||||
<p>Deletes a managed engine download.</p>
|
||||
<table>
|
||||
<tr><th>JSON Field</th><th>Required</th><th>Description</th></tr>
|
||||
<tr><td><code>engine</code></td><td>Yes</td><td>Engine name.</td></tr>
|
||||
<tr><td><code>version</code></td><td>Yes</td><td>Engine version.</td></tr>
|
||||
<tr><td><code>system_os</code></td><td>No</td><td>Target OS.</td></tr>
|
||||
<tr><td><code>cpu</code></td><td>No</td><td>Target CPU architecture.</td></tr>
|
||||
</table>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="debug">
|
||||
<h2>Debug</h2>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/_debug/detected_clients</span></div>
|
||||
<p>Returns hostnames detected by Zeroconf.</p>
|
||||
<div class="note">Development/debug only, with an inline comment saying it probably should not ship.</div>
|
||||
</article>
|
||||
|
||||
<article class="endpoint">
|
||||
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/_debug/clear_history</span></div>
|
||||
<p>Clears render queue history and returns <code>success</code>.</p>
|
||||
<div class="note">Development/debug only.</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="cleanup">
|
||||
<h2>Cleanup Review</h2>
|
||||
<div class="panel">
|
||||
<h3>Routes With No In-Repo Callers Found</h3>
|
||||
<ul>
|
||||
<li><code>GET /api/jobs/status/<status_val></code></li>
|
||||
<li><code>GET /api/presets</code></li>
|
||||
<li><code>GET /api/disk_benchmark</code></li>
|
||||
<li><code>GET /api/engines/<engine_name>/args</code></li>
|
||||
<li><code>GET /api/engines/download_available</code></li>
|
||||
<li><code>GET /api/engines/most_recent_version</code></li>
|
||||
<li><code>POST /api/engines/download</code></li>
|
||||
<li><code>GET /api/_debug/detected_clients</code></li>
|
||||
<li><code>POST /api/_debug/clear_history</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top: 14px;">
|
||||
<h3>Cleanup Risks</h3>
|
||||
<ul>
|
||||
<li><code>job_thumbnail()</code> parses <code>size=big</code> but never uses the resulting <code>big_thumb</code> value.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -58,15 +58,15 @@ class ZordonServer:
|
||||
|
||||
# ---- Render Queue ----
|
||||
self.ctx.render_queue = RenderQueue()
|
||||
self.ctx.render_queue.load_state(database_directory=Path(Config.upload_folder).expanduser())
|
||||
RenderQueue._default_instance = self.ctx.render_queue
|
||||
RenderQueue._sync_class()
|
||||
RenderQueue.load_state(database_directory=Path(Config.upload_folder).expanduser())
|
||||
|
||||
# ---- Distributed Job Manager ----
|
||||
self.ctx.distributed_job_manager = DistributedJobManager()
|
||||
self.ctx.distributed_job_manager.subscribe_to_listener()
|
||||
DistributedJobManager._default_instance = self.ctx.distributed_job_manager
|
||||
DistributedJobManager._sync_class()
|
||||
DistributedJobManager.subscribe_to_listener()
|
||||
|
||||
self.api_server = None
|
||||
self.server_hostname: str = socket.gethostname()
|
||||
|
||||
+129
-133
@@ -17,6 +17,7 @@ import psutil
|
||||
import yaml
|
||||
from flask import Flask, request, send_file, after_this_request, Response, redirect, url_for
|
||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from src.api.job_import_handler import JobImportHandler
|
||||
from src.api.preview_manager import PreviewManager
|
||||
@@ -33,9 +34,9 @@ logger = logging.getLogger()
|
||||
server = Flask(__name__)
|
||||
ssl._create_default_https_context = ssl._create_unverified_context # disable SSL for downloads
|
||||
|
||||
API_VERSION = "0.1"
|
||||
API_VERSION = "1.0"
|
||||
|
||||
def start_api_server(hostname: Optional[str] = None) -> None:
|
||||
def start_api_server(hostname: Optional[str] = None, bind_host: str = '0.0.0.0') -> None:
|
||||
|
||||
# get hostname
|
||||
if not hostname:
|
||||
@@ -53,9 +54,9 @@ def start_api_server(hostname: Optional[str] = None) -> None:
|
||||
flask_log = logging.getLogger('werkzeug')
|
||||
flask_log.setLevel(Config.flask_log_level.upper())
|
||||
|
||||
logger.debug('Starting API server')
|
||||
logger.debug(f'Starting API server on {bind_host}:{server.config["PORT"]}')
|
||||
try:
|
||||
server.run(host=hostname, port=server.config['PORT'], debug=Config.flask_debug_enable, use_reloader=False,
|
||||
server.run(host=bind_host, port=server.config['PORT'], debug=Config.flask_debug_enable, use_reloader=False,
|
||||
threaded=True)
|
||||
finally:
|
||||
logger.debug('Stopping API server')
|
||||
@@ -83,7 +84,7 @@ def jobs_json() -> Dict[str, Any]:
|
||||
return {'jobs': all_jobs, 'token': job_cache_token}
|
||||
|
||||
|
||||
@server.get('/api/jobs_long_poll')
|
||||
@server.get('/api/jobs/long_poll')
|
||||
def long_polling_jobs():
|
||||
hash_token = request.args.get('token', None)
|
||||
start_time = time.time()
|
||||
@@ -97,7 +98,7 @@ def long_polling_jobs():
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
@server.get('/api/jobs/<status_val>')
|
||||
@server.get('/api/jobs/status/<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)]
|
||||
@@ -111,7 +112,7 @@ def filtered_jobs_json(status_val):
|
||||
# Job Details / File Handling
|
||||
# --------------------------------------------
|
||||
|
||||
@server.get('/api/job/<job_id>')
|
||||
@server.get('/api/jobs/<job_id>')
|
||||
def get_job_details(job_id):
|
||||
"""Retrieves the details of a requested job in JSON format
|
||||
|
||||
@@ -124,7 +125,7 @@ def get_job_details(job_id):
|
||||
return RenderQueue.job_with_id(job_id).json()
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/logs')
|
||||
@server.get('/api/jobs/<job_id>/logs')
|
||||
def get_job_logs(job_id):
|
||||
"""Retrieves the log file for a specific render job.
|
||||
|
||||
@@ -143,12 +144,12 @@ def get_job_logs(job_id):
|
||||
return Response(log_data, mimetype='text/plain')
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/file_list')
|
||||
def get_file_list(job_id):
|
||||
@server.get('/api/jobs/<job_id>/files')
|
||||
def get_job_files(job_id):
|
||||
return [Path(p).name for p in RenderQueue.job_with_id(job_id).file_list()]
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/download')
|
||||
@server.route('/api/jobs/<job_id>/download')
|
||||
def download_requested_file(job_id):
|
||||
requested_filename = request.args.get("filename")
|
||||
if not requested_filename:
|
||||
@@ -164,7 +165,7 @@ def download_requested_file(job_id):
|
||||
return f"File '{requested_filename}' not found", 404
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/download_all')
|
||||
@server.route('/api/jobs/<job_id>/download_all')
|
||||
def download_all_files(job_id):
|
||||
zip_filename = None
|
||||
|
||||
@@ -205,29 +206,6 @@ def presets() -> Dict[str, Any]:
|
||||
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.route('/api/status')
|
||||
def status():
|
||||
return {"timestamp": datetime.now().isoformat(),
|
||||
@@ -253,10 +231,10 @@ def status():
|
||||
# Job Lifecyle (Create, Cancel, Delete)
|
||||
# --------------------------------------------
|
||||
|
||||
@server.post('/api/add_job')
|
||||
def add_job_handler():
|
||||
@server.post('/api/jobs')
|
||||
def create_jobs_handler():
|
||||
"""
|
||||
POST /api/add_job
|
||||
POST /api/jobs
|
||||
Add a render job to the queue.
|
||||
|
||||
**Request Formats**
|
||||
@@ -306,7 +284,7 @@ def add_job_handler():
|
||||
return 'unknown error', 500
|
||||
|
||||
|
||||
@server.get('/api/job/<job_id>/cancel')
|
||||
@server.post('/api/jobs/<job_id>/cancel')
|
||||
def cancel_job(job_id):
|
||||
if not request.args.get('confirm', False):
|
||||
return 'Confirmation required to cancel job', 400
|
||||
@@ -320,7 +298,7 @@ def cancel_job(job_id):
|
||||
return "Unknown error", 500
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/delete', methods=['POST', 'GET'])
|
||||
@server.post('/api/jobs/<job_id>/delete')
|
||||
def delete_job(job_id):
|
||||
try:
|
||||
if not request.args.get("confirm", False):
|
||||
@@ -344,19 +322,11 @@ def delete_job(job_id):
|
||||
|
||||
RenderQueue.delete_job(found_job)
|
||||
|
||||
# Delete output directory if we own it
|
||||
if output_dir.exists() and output_dir.is_relative_to(upload_root):
|
||||
shutil.rmtree(output_dir)
|
||||
|
||||
# Delete project directory if we own it and it's unused
|
||||
try:
|
||||
if project_dir.exists() and project_dir.is_relative_to(upload_root):
|
||||
project_dir_files = [p for p in project_dir.iterdir() if not p.name.startswith(".")]
|
||||
if not project_dir_files or (len(project_dir_files) == 1 and "source" in project_dir_files[0].name):
|
||||
if _has_remaining_jobs_for_project(project_dir):
|
||||
_delete_job_output(output_path, project_dir, upload_root)
|
||||
elif project_dir.exists() and project_dir.is_relative_to(upload_root):
|
||||
logger.info(f"Removing project directory: {project_dir}")
|
||||
shutil.rmtree(project_dir)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing project files: {e}")
|
||||
|
||||
return "Job deleted", 200
|
||||
|
||||
@@ -365,11 +335,46 @@ def delete_job(job_id):
|
||||
return f"Error deleting job: {e}", 500
|
||||
|
||||
|
||||
def _has_remaining_jobs_for_project(project_dir):
|
||||
for job in RenderQueue.all_jobs():
|
||||
try:
|
||||
if Path(job.input_path).parent.parent == project_dir:
|
||||
return True
|
||||
except (AttributeError, TypeError):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _delete_job_output(output_path, project_dir, upload_root):
|
||||
output_dir = output_path.parent
|
||||
project_output_dir = project_dir / 'output'
|
||||
|
||||
if not output_dir.exists() or not output_dir.is_relative_to(upload_root):
|
||||
return
|
||||
|
||||
if output_dir != project_output_dir:
|
||||
shutil.rmtree(output_dir)
|
||||
return
|
||||
|
||||
output_prefix = output_path.stem
|
||||
for output_file in output_dir.iterdir():
|
||||
if _output_file_matches_prefix(output_file.name, output_prefix) and output_file.is_file():
|
||||
output_file.unlink()
|
||||
|
||||
|
||||
def _output_file_matches_prefix(filename, output_prefix):
|
||||
return (
|
||||
filename == output_prefix or
|
||||
filename.startswith(f'{output_prefix}_') or
|
||||
filename.startswith(f'{output_prefix}.')
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------
|
||||
# Engine Info and Management:
|
||||
# --------------------------------------------
|
||||
|
||||
@server.get('/api/engine_for_filename')
|
||||
@server.get('/api/engines/for_filename')
|
||||
def get_engine_for_filename():
|
||||
filename = request.args.get("filename")
|
||||
if not filename:
|
||||
@@ -379,93 +384,38 @@ def get_engine_for_filename():
|
||||
return f"Error: cannot find a suitable engine for '{filename}'", 400
|
||||
return found_engine.name()
|
||||
|
||||
@server.get('/api/installed_engines')
|
||||
def get_installed_engines():
|
||||
result = {}
|
||||
for engine_class in EngineManager.supported_engines():
|
||||
data = EngineManager.all_version_data_for_engine(engine_class.name())
|
||||
if data:
|
||||
result[engine_class.name()] = data
|
||||
return result
|
||||
|
||||
|
||||
@server.get('/api/engine_info')
|
||||
def engine_info():
|
||||
def _validated_engine_response_type():
|
||||
response_type = request.args.get('response_type', 'standard')
|
||||
if response_type not in ['full', 'standard']:
|
||||
raise ValueError(f"Invalid response_type: {response_type}")
|
||||
return response_type
|
||||
|
||||
def process_engine(engine):
|
||||
|
||||
def _engine_info_for_engine(engine_class, response_type='standard'):
|
||||
try:
|
||||
# Get all installed versions of the engine
|
||||
installed_versions = EngineManager.all_version_data_for_engine(engine.name())
|
||||
installed_versions = EngineManager.all_version_data_for_engine(engine_class.name())
|
||||
if not installed_versions:
|
||||
return None
|
||||
|
||||
system_installed_versions = [v for v in installed_versions if v['type'] == 'system']
|
||||
install_path = system_installed_versions[0]['path'] if system_installed_versions else installed_versions[0]['path']
|
||||
install_path = (
|
||||
system_installed_versions[0]['path'] if system_installed_versions else installed_versions[0]['path']
|
||||
)
|
||||
|
||||
en = engine(install_path)
|
||||
engine_name = en.name()
|
||||
engine = engine_class(install_path)
|
||||
engine_name = engine.name()
|
||||
result = {
|
||||
engine_name: {
|
||||
'is_available': RenderQueue.is_available_for_job(engine_name),
|
||||
'versions': installed_versions
|
||||
}
|
||||
}
|
||||
|
||||
if response_type == 'full':
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future_results = {
|
||||
'supported_extensions': executor.submit(en.supported_extensions),
|
||||
'supported_export_formats': executor.submit(en.get_output_formats),
|
||||
'system_info': executor.submit(en.system_info)
|
||||
}
|
||||
|
||||
for key, future in future_results.items():
|
||||
result[engine_name][key] = future.result()
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc(e)
|
||||
logger.error(f"Error fetching details for engine '{engine.name()}': {e}")
|
||||
return {}
|
||||
|
||||
engine_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:
|
||||
engine_data.update(result)
|
||||
|
||||
return engine_data
|
||||
|
||||
|
||||
@server.get('/api/<engine_name>/info')
|
||||
def get_engine_info(engine_name):
|
||||
try:
|
||||
response_type = request.args.get('response_type', 'standard')
|
||||
# Get all installed versions of the engine
|
||||
installed_versions = EngineManager.all_version_data_for_engine(engine_name)
|
||||
if not installed_versions:
|
||||
return {}
|
||||
|
||||
result = { 'is_available': RenderQueue.is_available_for_job(engine_name),
|
||||
'versions': installed_versions
|
||||
}
|
||||
|
||||
if response_type == 'full':
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
engine_class = EngineManager.engine_class_with_name(engine_name)
|
||||
en = EngineManager.get_latest_engine_instance(engine_class)
|
||||
future_results = {
|
||||
'supported_extensions': executor.submit(en.supported_extensions),
|
||||
'supported_export_formats': executor.submit(en.get_output_formats),
|
||||
'system_info': executor.submit(en.system_info),
|
||||
'options': executor.submit(en.ui_options)
|
||||
'supported_extensions': executor.submit(engine.supported_extensions),
|
||||
'supported_export_formats': executor.submit(engine.get_output_formats),
|
||||
'system_info': executor.submit(engine.system_info),
|
||||
'options': executor.submit(engine.ui_options)
|
||||
}
|
||||
|
||||
for key, future in future_results.items():
|
||||
@@ -473,32 +423,75 @@ def get_engine_info(engine_name):
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching details for engine '{engine_class.name()}': {e}")
|
||||
return {}
|
||||
|
||||
|
||||
@server.get('/api/engines')
|
||||
def get_engines_info():
|
||||
response_type = _validated_engine_response_type()
|
||||
engine_data = {}
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
futures = {
|
||||
executor.submit(_engine_info_for_engine, engine, response_type): engine.name()
|
||||
for engine in EngineManager.supported_engines()
|
||||
}
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
if result:
|
||||
engine_data[futures[future]] = result
|
||||
|
||||
return engine_data
|
||||
|
||||
|
||||
@server.get('/api/engines/names')
|
||||
def get_engine_names():
|
||||
result = []
|
||||
for engine_class in EngineManager.supported_engines():
|
||||
data = EngineManager.all_version_data_for_engine(engine_class.name())
|
||||
if data:
|
||||
result.append(engine_class.name())
|
||||
return result
|
||||
|
||||
|
||||
@server.get('/api/engines/<engine_name>')
|
||||
def get_engine(engine_name):
|
||||
try:
|
||||
response_type = _validated_engine_response_type()
|
||||
engine_class = EngineManager.engine_class_with_name(engine_name)
|
||||
return _engine_info_for_engine(engine_class, response_type) or {}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching details for engine '{engine_name}': {e}")
|
||||
return {}
|
||||
|
||||
|
||||
@server.get('/api/<engine_name>/is_available')
|
||||
def is_engine_available(engine_name):
|
||||
@server.get('/api/engines/<engine_name>/availability')
|
||||
def get_engine_availability(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_version_data_for_engine(engine_name),
|
||||
'hostname': server.config['HOSTNAME']}
|
||||
'hostname': server.config.get('HOSTNAME', socket.gethostname())}
|
||||
|
||||
|
||||
@server.get('/api/engine/<engine_name>/args')
|
||||
@server.get('/api/engines/<engine_name>/args')
|
||||
def get_engine_args(engine_name):
|
||||
try:
|
||||
engine_class = EngineManager.engine_class_with_name(engine_name)
|
||||
if not engine_class:
|
||||
return f"Cannot find engine '{engine_name}'", 400
|
||||
return engine_class().get_arguments()
|
||||
except LookupError:
|
||||
return f"Cannot find engine '{engine_name}'", 400
|
||||
|
||||
|
||||
@server.get('/api/engine/<engine_name>/help')
|
||||
@server.get('/api/engines/<engine_name>/help')
|
||||
def get_engine_help(engine_name):
|
||||
try:
|
||||
engine_class = EngineManager.engine_class_with_name(engine_name)
|
||||
if not engine_class:
|
||||
return f"Cannot find engine '{engine_name}'", 400
|
||||
return engine_class().get_help()
|
||||
except LookupError:
|
||||
return f"Cannot find engine '{engine_name}'", 400
|
||||
@@ -507,7 +500,7 @@ def get_engine_help(engine_name):
|
||||
# Engine Downloads and Updates:
|
||||
# --------------------------------------------
|
||||
|
||||
@server.get('/api/is_engine_available_to_download')
|
||||
@server.get('/api/engines/download_available')
|
||||
def is_engine_available_to_download():
|
||||
available_result = EngineManager.version_is_available_to_download(request.args.get('engine'),
|
||||
request.args.get('version'),
|
||||
@@ -517,7 +510,7 @@ def is_engine_available_to_download():
|
||||
(f"Cannot find available download for {request.args.get('engine')} {request.args.get('version')}", 500)
|
||||
|
||||
|
||||
@server.get('/api/find_most_recent_version')
|
||||
@server.get('/api/engines/most_recent_version')
|
||||
def find_most_recent_version():
|
||||
most_recent = EngineManager.find_most_recent_version(request.args.get('engine'),
|
||||
request.args.get('system_os'),
|
||||
@@ -526,7 +519,7 @@ def find_most_recent_version():
|
||||
(f"Error finding most recent version of {request.args.get('engine')}", 500)
|
||||
|
||||
|
||||
@server.post('/api/download_engine')
|
||||
@server.post('/api/engines/download')
|
||||
def download_engine():
|
||||
download_result = EngineManager.download_engine(request.args.get('engine'),
|
||||
request.args.get('version'),
|
||||
@@ -536,7 +529,7 @@ def download_engine():
|
||||
(f"Error downloading {request.args.get('engine')} {request.args.get('version')}", 500)
|
||||
|
||||
|
||||
@server.post('/api/delete_engine')
|
||||
@server.post('/api/engines/delete')
|
||||
def delete_engine_download():
|
||||
json_data = request.json
|
||||
delete_result = EngineManager.delete_engine_download(json_data.get('engine'),
|
||||
@@ -554,14 +547,14 @@ def delete_engine_download():
|
||||
def heartbeat():
|
||||
return datetime.now().isoformat(), 200
|
||||
|
||||
@server.post('/api/job/<job_id>/send_subjob_update_notification')
|
||||
@server.post('/api/jobs/<job_id>/subjob_update')
|
||||
def subjob_update_notification(job_id):
|
||||
subjob_details = request.json
|
||||
DistributedJobManager.handle_subjob_update_notification(RenderQueue.job_with_id(job_id), subjob_data=subjob_details)
|
||||
return Response(status=200)
|
||||
|
||||
|
||||
@server.route('/api/job/<job_id>/thumbnail')
|
||||
@server.route('/api/jobs/<job_id>/thumbnail')
|
||||
def job_thumbnail(job_id):
|
||||
|
||||
try:
|
||||
@@ -632,6 +625,9 @@ def handle_404(error):
|
||||
|
||||
@server.errorhandler(Exception)
|
||||
def handle_general_error(general_error):
|
||||
if isinstance(general_error, HTTPException):
|
||||
return general_error.description, general_error.code
|
||||
|
||||
traceback.print_exception(type(general_error), general_error, general_error.__traceback__)
|
||||
err_msg = f"Server error: {general_error}"
|
||||
logger.error(err_msg)
|
||||
@@ -649,7 +645,7 @@ def detected_clients():
|
||||
return ZeroconfServer.found_hostnames()
|
||||
|
||||
|
||||
@server.get('/api/_debug/clear_history')
|
||||
@server.post('/api/_debug/clear_history')
|
||||
def clear_history():
|
||||
RenderQueue.clear_history()
|
||||
return 'success'
|
||||
|
||||
@@ -37,6 +37,7 @@ class JobImportHandler:
|
||||
processed_child_job_data = processed_job_data.copy()
|
||||
processed_child_job_data.pop("child_jobs")
|
||||
processed_child_job_data.update(child_job_diffs)
|
||||
processed_child_job_data['__use_output_subdir'] = True
|
||||
job_data_to_create.append(processed_child_job_data)
|
||||
else:
|
||||
job_data_to_create.append(processed_job_data)
|
||||
|
||||
+56
-43
@@ -22,6 +22,8 @@ categories = [RenderStatus.RUNNING, RenderStatus.WAITING_FOR_SUBJOBS, RenderStat
|
||||
|
||||
logger = logging.getLogger()
|
||||
OFFLINE_MAX = 4
|
||||
JOB_UPLOAD_TIMEOUT = (10, 1800)
|
||||
FILE_DOWNLOAD_TIMEOUT = (10, 1800)
|
||||
|
||||
|
||||
class RenderServerProxy:
|
||||
@@ -109,6 +111,11 @@ class RenderServerProxy:
|
||||
return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout,
|
||||
headers={"X-API-Version": str(API_VERSION)})
|
||||
|
||||
def _post(self, payload, timeout=5, **kwargs):
|
||||
from src.api.api_server import API_VERSION
|
||||
return requests.post(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout,
|
||||
headers={"X-API-Version": str(API_VERSION)}, **kwargs)
|
||||
|
||||
# --------------------------------------------
|
||||
# Background Updates:
|
||||
# --------------------------------------------
|
||||
@@ -134,7 +141,7 @@ class RenderServerProxy:
|
||||
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
|
||||
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:
|
||||
@@ -153,13 +160,10 @@ class RenderServerProxy:
|
||||
# Get System Info:
|
||||
# --------------------------------------------
|
||||
|
||||
def get_all_jobs(self, timeout=5, ignore_token=False):
|
||||
def get_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 get_data(self, timeout=5):
|
||||
return self.request_data('full_status', timeout=timeout)
|
||||
return self.__jobs_cache.copy()
|
||||
|
||||
def get_status(self):
|
||||
status = self.request_data('status')
|
||||
@@ -175,17 +179,17 @@ class RenderServerProxy:
|
||||
# Get Job Info:
|
||||
# --------------------------------------------
|
||||
|
||||
def get_job_info(self, job_id, timeout=5):
|
||||
return self.request_data(f'job/{job_id}', timeout=timeout)
|
||||
def get_job(self, job_id, timeout=5):
|
||||
return self.request_data(f'jobs/{job_id}', timeout=timeout)
|
||||
|
||||
def get_job_files_list(self, job_id):
|
||||
return self.request_data(f"job/{job_id}/file_list")
|
||||
def get_job_files(self, job_id):
|
||||
return self.request_data(f'jobs/{job_id}/files')
|
||||
|
||||
# --------------------------------------------
|
||||
# Job Lifecycle:
|
||||
# --------------------------------------------
|
||||
|
||||
def post_job_to_server(self, file_path: Path, job_data, callback=None):
|
||||
def create_job(self, file_path: Path, job_data, callback=None, timeout=JOB_UPLOAD_TIMEOUT):
|
||||
"""
|
||||
Posts a job to the server.
|
||||
|
||||
@@ -193,6 +197,8 @@ class RenderServerProxy:
|
||||
file_path (Path): The path to the file to upload.
|
||||
job_data (dict): A dict of jobs data.
|
||||
callback (function, optional): A callback function to call during the upload. Defaults to None.
|
||||
timeout (float | tuple, optional): Requests timeout. Defaults to a 10-second connect timeout and
|
||||
30-minute read timeout for large uploads.
|
||||
|
||||
Returns:
|
||||
Response: The response from the server.
|
||||
@@ -204,9 +210,9 @@ class RenderServerProxy:
|
||||
# Bypass uploading file if posting to localhost
|
||||
if self.is_localhost:
|
||||
job_data['local_path'] = str(file_path)
|
||||
url = urljoin(f'http://{self.hostname}:{self.port}', '/api/add_job')
|
||||
url = urljoin(f'http://{self.hostname}:{self.port}', '/api/jobs')
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
return requests.post(url, data=json.dumps(job_data), headers=headers)
|
||||
return requests.post(url, data=json.dumps(job_data), headers=headers, timeout=timeout)
|
||||
|
||||
# Prepare the form data for remote host
|
||||
with open(file_path, 'rb') as file:
|
||||
@@ -218,19 +224,25 @@ class RenderServerProxy:
|
||||
# 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')
|
||||
url = urljoin(f'http://{self.hostname}:{self.port}', '/api/jobs')
|
||||
|
||||
# Send the request with proper resource management
|
||||
with requests.post(url, data=monitor, headers=headers) as response:
|
||||
with requests.post(url, data=monitor, headers=headers, timeout=timeout) as response:
|
||||
return response
|
||||
|
||||
def cancel_job(self, job_id, confirm=False):
|
||||
return self.request_data(f'job/{job_id}/cancel?confirm={confirm}')
|
||||
response = self._post(f'jobs/{job_id}/cancel', params={'confirm': confirm})
|
||||
if response.ok:
|
||||
self.__update_job_cache(timeout=5, ignore_token=True)
|
||||
return response
|
||||
|
||||
def delete_job(self, job_id, confirm=False):
|
||||
return self.request_data(f'job/{job_id}/delete?confirm={confirm}')
|
||||
response = self._post(f'jobs/{job_id}/delete', params={'confirm': confirm})
|
||||
if response.ok:
|
||||
self.__update_job_cache(timeout=5, ignore_token=True)
|
||||
return response
|
||||
|
||||
def send_subjob_update_notification(self, parent_id, subjob):
|
||||
def send_subjob_update_notification(self, parent_id, subjob, timeout=5):
|
||||
"""
|
||||
Notifies the parent job of an update in a subjob.
|
||||
|
||||
@@ -241,26 +253,26 @@ class RenderServerProxy:
|
||||
Returns:
|
||||
Response: The response from the server.
|
||||
"""
|
||||
return requests.post(f'http://{self.hostname}:{self.port}/api/job/{parent_id}/send_subjob_update_notification',
|
||||
json=subjob.json())
|
||||
return requests.post(f'http://{self.hostname}:{self.port}/api/jobs/{parent_id}/subjob_update',
|
||||
json=subjob.json(), timeout=timeout)
|
||||
|
||||
# --------------------------------------------
|
||||
# Engines:
|
||||
# --------------------------------------------
|
||||
|
||||
def get_engine_for_filename(self, filename:str, timeout=5):
|
||||
response = self.request(f'engine_for_filename?filename={os.path.basename(filename)}', timeout)
|
||||
response = self.request(f'engines/for_filename?filename={os.path.basename(filename)}', timeout)
|
||||
return response.text
|
||||
|
||||
def get_installed_engines(self, timeout=5):
|
||||
return self.request_data(f'installed_engines', timeout)
|
||||
def get_engine_availability(self, engine_name:str, timeout=5):
|
||||
return self.request_data(f'engines/{engine_name}/availability', timeout)
|
||||
|
||||
def is_engine_available(self, engine_name:str, timeout=5):
|
||||
return self.request_data(f'{engine_name}/is_available', timeout)
|
||||
def get_engine_names(self, timeout=5):
|
||||
return self.request_data('engines/names', timeout=timeout)
|
||||
|
||||
def get_all_engine_info(self, response_type='standard', timeout=5):
|
||||
def get_engines(self, response_type='standard', timeout=5):
|
||||
"""
|
||||
Fetches all engine information from the server.
|
||||
Fetches engine information from the server.
|
||||
|
||||
Args:
|
||||
response_type (str, optional): Returns standard or full version of engine info
|
||||
@@ -269,10 +281,10 @@ class RenderServerProxy:
|
||||
Returns:
|
||||
dict: A dictionary containing the engine information.
|
||||
"""
|
||||
all_data = self.request_data(f"engine_info?response_type={response_type}", timeout=timeout)
|
||||
all_data = self.request_data(f'engines?response_type={response_type}', timeout=timeout)
|
||||
return all_data
|
||||
|
||||
def get_engine_info(self, engine_name:str, response_type='standard', timeout=5):
|
||||
def get_engine(self, engine_name:str, response_type='standard', timeout=5):
|
||||
"""
|
||||
Fetches specific engine information from the server.
|
||||
|
||||
@@ -284,38 +296,39 @@ class RenderServerProxy:
|
||||
Returns:
|
||||
dict: A dictionary containing the engine information.
|
||||
"""
|
||||
return self.request_data(f'{engine_name}/info?response_type={response_type}', timeout)
|
||||
return self.request_data(f'engines/{engine_name}?response_type={response_type}', timeout)
|
||||
|
||||
def delete_engine(self, engine_name:str, version:str, system_cpu=None):
|
||||
def delete_engine_download(self, engine_name:str, version:str, system_os=None, cpu=None):
|
||||
"""
|
||||
Sends a request to the server to delete a specific engine.
|
||||
Sends a request to the server to delete a specific engine download.
|
||||
|
||||
Args:
|
||||
engine_name (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.
|
||||
system_os (str, optional): The system OS. Defaults to None.
|
||||
cpu (str, optional): The system CPU type. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Response: The response from the server.
|
||||
"""
|
||||
form_data = {'engine': engine_name, 'version': version, 'system_cpu': system_cpu}
|
||||
return requests.post(f'http://{self.hostname}:{self.port}/api/delete_engine', json=form_data)
|
||||
form_data = {'engine': engine_name, 'version': version, 'system_os': system_os, 'cpu': cpu}
|
||||
return self._post('engines/delete', json=form_data)
|
||||
|
||||
# --------------------------------------------
|
||||
# Download Files:
|
||||
# --------------------------------------------
|
||||
|
||||
def download_all_job_files(self, job_id, save_path):
|
||||
url = f"http://{self.hostname}:{self.port}/api/job/{job_id}/download_all"
|
||||
return self.__download_file_from_url(url, output_filepath=save_path)
|
||||
def download_all_job_files(self, job_id, save_path, timeout=FILE_DOWNLOAD_TIMEOUT):
|
||||
url = f'http://{self.hostname}:{self.port}/api/jobs/{job_id}/download_all'
|
||||
return self.__download_file_from_url(url, output_filepath=save_path, timeout=timeout)
|
||||
|
||||
def download_job_file(self, job_id, job_filename, save_path):
|
||||
url = f"http://{self.hostname}:{self.port}/api/job/{job_id}/download?filename={job_filename}"
|
||||
return self.__download_file_from_url(url, output_filepath=save_path)
|
||||
def download_job_file(self, job_id, job_filename, save_path, timeout=FILE_DOWNLOAD_TIMEOUT):
|
||||
url = f'http://{self.hostname}:{self.port}/api/jobs/{job_id}/download?filename={job_filename}'
|
||||
return self.__download_file_from_url(url, output_filepath=save_path, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def __download_file_from_url(url, output_filepath):
|
||||
with requests.get(url, stream=True) as r:
|
||||
def __download_file_from_url(url, output_filepath, timeout=FILE_DOWNLOAD_TIMEOUT):
|
||||
with requests.get(url, stream=True, timeout=timeout) as r:
|
||||
r.raise_for_status()
|
||||
with open(output_filepath, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
|
||||
@@ -100,10 +100,12 @@ class DistributedJobManager:
|
||||
# --------------------------------------------
|
||||
|
||||
def _create_render_job(self, new_job_attributes: dict, loaded_project_local_path: Path):
|
||||
output_path = new_job_attributes.get('output_path')
|
||||
output_filename = loaded_project_local_path.name if output_path else loaded_project_local_path.stem
|
||||
requested_output_path = new_job_attributes.get('output_path')
|
||||
output_filename = Path(str(requested_output_path)).name if requested_output_path else loaded_project_local_path.stem
|
||||
|
||||
output_dir = loaded_project_local_path.parent.parent / "output"
|
||||
if new_job_attributes.get('__use_output_subdir'):
|
||||
output_dir = output_dir / output_filename
|
||||
output_path = output_dir / output_filename
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
logger.debug(f"New job output path: {output_path}")
|
||||
@@ -172,7 +174,7 @@ class DistributedJobManager:
|
||||
subjob_id = child_key.split('@')[0]
|
||||
subjob_hostname = child_key.split('@')[-1]
|
||||
|
||||
subjob_data = RenderServerProxy(subjob_hostname).get_job_info(subjob_id)
|
||||
subjob_data = RenderServerProxy(subjob_hostname).get_job(subjob_id)
|
||||
if not subjob_data:
|
||||
logger.warning(f"No response from {subjob_hostname}")
|
||||
parent_job.children[child_key]['download_status'] = f'error: No response from {subjob_hostname}'
|
||||
@@ -260,7 +262,7 @@ class DistributedJobManager:
|
||||
subjob['engine_version'] = parent_worker.engine_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(
|
||||
post_results = RenderServerProxy(server_hostname).create_job(
|
||||
file_path=project_path, job_data=subjob)
|
||||
return post_results
|
||||
|
||||
@@ -276,7 +278,7 @@ class DistributedJobManager:
|
||||
host_properties = ZeroconfServer.get_hostname_properties(hostname)
|
||||
if host_properties.get('api_version') == API_VERSION:
|
||||
if not system_os or (system_os and system_os == host_properties.get('system_os')):
|
||||
response = RenderServerProxy(hostname).is_engine_available(engine_name)
|
||||
response = RenderServerProxy(hostname).get_engine_availability(engine_name)
|
||||
if response and response.get('available', False):
|
||||
found_available_servers.append(response)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class BlenderDownloader(EngineDownloader):
|
||||
|
||||
@staticmethod
|
||||
def __find_LTS_versions():
|
||||
response = requests.get('https://www.blender.org/download/lts/')
|
||||
response = requests.get('https://www.blender.org/download/lts/', timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
lts_pattern = r'https://www.blender.org/download/lts/(\d+-\d+)/'
|
||||
|
||||
@@ -66,7 +66,7 @@ class NewRenderJobForm(QWidget):
|
||||
# Job / Server Data
|
||||
self.server_proxy = RenderServerProxy(socket.gethostname())
|
||||
self.project_info = None
|
||||
self.installed_engines = {}
|
||||
self.installed_engines = []
|
||||
self.preferred_engine = None
|
||||
|
||||
# Setup
|
||||
@@ -133,6 +133,7 @@ class NewRenderJobForm(QWidget):
|
||||
server_list_layout = QHBoxLayout()
|
||||
server_list_layout.addWidget(QLabel("Render Target:"))
|
||||
self.server_input = QComboBox()
|
||||
self.server_input.currentTextChanged.connect(self.server_changed)
|
||||
server_list_layout.addWidget(self.server_input)
|
||||
project_layout.addLayout(server_list_layout)
|
||||
|
||||
@@ -345,7 +346,7 @@ class NewRenderJobForm(QWidget):
|
||||
self.engine_version_combo.addItem('latest')
|
||||
self.file_format_combo.clear()
|
||||
if current_engine:
|
||||
engine_info = self.server_proxy.get_engine_info(current_engine, 'full', timeout=10)
|
||||
engine_info = self.server_proxy.get_engine(current_engine, 'full', timeout=10)
|
||||
self.current_engine_options = engine_info.get('options', [])
|
||||
if not engine_info:
|
||||
raise FileNotFoundError(f"Cannot get information about engine '{current_engine}'")
|
||||
@@ -353,10 +354,27 @@ class NewRenderJobForm(QWidget):
|
||||
self.engine_version_combo.addItems(engine_vers)
|
||||
self.file_format_combo.addItems(engine_info.get('supported_export_formats'))
|
||||
|
||||
def server_changed(self, hostname):
|
||||
if not hostname:
|
||||
return
|
||||
|
||||
self.server_proxy = RenderServerProxy(hostname)
|
||||
|
||||
if self.engine_type and self.engine_type.count():
|
||||
try:
|
||||
self.engine_changed()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating engine data for server '{hostname}': {e}")
|
||||
|
||||
def update_server_list(self):
|
||||
current_hostname = self.server_input.currentText()
|
||||
clients = ZeroconfServer.found_hostnames()
|
||||
self.server_input.clear()
|
||||
self.server_input.addItems(clients)
|
||||
if current_hostname and current_hostname in clients:
|
||||
self.server_input.setCurrentText(current_hostname)
|
||||
elif clients:
|
||||
self.server_changed(clients[0])
|
||||
|
||||
def browse_scene_file(self):
|
||||
file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
|
||||
@@ -386,7 +404,7 @@ class NewRenderJobForm(QWidget):
|
||||
self.job_name_input.setText(directory)
|
||||
|
||||
def args_help_button_clicked(self):
|
||||
url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/engine/'
|
||||
url = (f'http://{self.server_proxy.hostname}:{self.server_proxy.port}/api/engines/'
|
||||
f'{self.engine_type.currentText()}/help')
|
||||
self.engine_help_viewer = EngineHelpViewer(url)
|
||||
self.engine_help_viewer.show()
|
||||
@@ -404,7 +422,7 @@ class NewRenderJobForm(QWidget):
|
||||
"""Called by the GetProjectInfoWorker - Do not call directly."""
|
||||
try:
|
||||
|
||||
self.engine_type.addItems(self.installed_engines.keys())
|
||||
self.engine_type.addItems(self.installed_engines)
|
||||
self.engine_type.setCurrentText(self.preferred_engine)
|
||||
self.engine_changed()
|
||||
|
||||
@@ -583,7 +601,7 @@ class SubmitWorker(QThread):
|
||||
|
||||
# determine if any cameras are checked
|
||||
selected_cameras = []
|
||||
if self.window.cameras_list.count() and not self.window.cameras_group.isHidden():
|
||||
if self.window.cameras_list.count() and self.window.cameras_group.isEnabled():
|
||||
for index in range(self.window.cameras_list.count()):
|
||||
item = self.window.cameras_list.item(index)
|
||||
if item.checkState() == Qt.CheckState.Checked:
|
||||
@@ -595,7 +613,7 @@ class SubmitWorker(QThread):
|
||||
children_jobs = []
|
||||
for cam in selected_cameras:
|
||||
child_job_data = dict()
|
||||
child_job_data['args'] = {}
|
||||
child_job_data['args'] = dict(job_json['args'])
|
||||
child_job_data['args']['camera'] = cam
|
||||
child_job_data['name'] = job_json['name'].replace(' ', '-') + "_" + cam.replace(' ', '')
|
||||
child_job_data['output_path'] = child_job_data['name']
|
||||
@@ -608,7 +626,7 @@ class SubmitWorker(QThread):
|
||||
input_path = Path(latest_engine.perform_presubmission_tasks(input_path))
|
||||
# submit
|
||||
err_msg = ""
|
||||
result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_data=job_json,
|
||||
result = self.window.server_proxy.create_job(file_path=input_path, job_data=job_json,
|
||||
callback=create_callback)
|
||||
if not (result and result.ok):
|
||||
err_msg = f"Error posting job to server: {result.text}"
|
||||
@@ -633,7 +651,7 @@ class GetProjectInfoWorker(QThread):
|
||||
def run(self):
|
||||
try:
|
||||
# get the engine info and add them all to the ui
|
||||
self.window.installed_engines = self.window.server_proxy.get_installed_engines()
|
||||
self.window.installed_engines = self.window.server_proxy.get_engine_names()
|
||||
# select the best engine for the file type
|
||||
self.window.preferred_engine = self.window.server_proxy.get_engine_for_filename(self.project_path)
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class EngineBrowserWindow(QMainWindow):
|
||||
def update_table(self):
|
||||
|
||||
def update_table_worker():
|
||||
raw_server_data = RenderServerProxy(self.hostname).get_all_engine_info()
|
||||
raw_server_data = RenderServerProxy(self.hostname).get_engines()
|
||||
if not raw_server_data:
|
||||
return
|
||||
|
||||
@@ -158,7 +158,9 @@ class EngineBrowserWindow(QMainWindow):
|
||||
if reply is not QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
result = RenderServerProxy(self.hostname).delete_engine(engine_info['engine'], engine_info['version'])
|
||||
result = RenderServerProxy(self.hostname).delete_engine_download(
|
||||
engine_info['engine'], engine_info['version'], engine_info.get('system_os'), engine_info.get('cpu'),
|
||||
)
|
||||
if result.ok:
|
||||
self.update_table()
|
||||
else:
|
||||
|
||||
@@ -26,5 +26,5 @@ class EngineHelpViewer(QMainWindow):
|
||||
self.fetch_help()
|
||||
|
||||
def fetch_help(self):
|
||||
result = requests.get(self.help_path)
|
||||
result = requests.get(self.help_path, timeout=10)
|
||||
self.text_edit.setPlainText(result.text)
|
||||
|
||||
@@ -26,5 +26,5 @@ class LogViewer(QMainWindow):
|
||||
self.fetch_logs()
|
||||
|
||||
def fetch_logs(self):
|
||||
result = requests.get(self.log_path)
|
||||
result = requests.get(self.log_path, timeout=10)
|
||||
self.text_edit.setPlainText(result.text)
|
||||
|
||||
+22
-20
@@ -229,27 +229,23 @@ class MainWindow(QMainWindow):
|
||||
|
||||
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()
|
||||
current_item = self.server_list_view.currentItem()
|
||||
if current_item is None:
|
||||
return
|
||||
|
||||
new_hostname = current_item.text()
|
||||
if new_hostname == self.current_hostname:
|
||||
return
|
||||
|
||||
# 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.refresh_job_list()
|
||||
|
||||
# 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 as e:
|
||||
logger.error(f"AttributeError in server_picked: {e}")
|
||||
|
||||
def update_server_info_display(self, hostname):
|
||||
"""Updates the server information section of the UI."""
|
||||
self.server_info_hostname.setText(f"Name: {hostname}")
|
||||
@@ -297,7 +293,9 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
|
||||
server_job_data = self.job_data.get(self.current_server_proxy.hostname)
|
||||
if server_job_data:
|
||||
if server_job_data is None:
|
||||
return
|
||||
|
||||
num_jobs = len(server_job_data)
|
||||
self.job_list_view.setRowCount(num_jobs)
|
||||
|
||||
@@ -334,7 +332,7 @@ class MainWindow(QMainWindow):
|
||||
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')
|
||||
response = self.current_server_proxy.request(f'jobs/{job_id}/thumbnail?size=big')
|
||||
if response.ok:
|
||||
try:
|
||||
with io.BytesIO(response.content) as image_data_stream:
|
||||
@@ -494,6 +492,9 @@ class MainWindow(QMainWindow):
|
||||
if not old_count and self.server_list_view.count():
|
||||
self.server_list_view.setCurrentRow(0)
|
||||
self.server_picked()
|
||||
elif self.server_list_view.count() and self.server_list_view.currentItem() is None:
|
||||
self.server_list_view.setCurrentRow(0)
|
||||
self.server_picked()
|
||||
|
||||
def create_toolbars(self) -> None:
|
||||
"""
|
||||
@@ -547,7 +548,8 @@ class MainWindow(QMainWindow):
|
||||
"""
|
||||
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'
|
||||
url = (f'http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}'
|
||||
f'/api/jobs/{selected_job_ids[0]}/logs')
|
||||
self.log_viewer_window = LogViewer(url)
|
||||
self.log_viewer_window.show()
|
||||
|
||||
@@ -562,7 +564,7 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
job = next((job for job in self.current_server_proxy.get_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 stop job: {display_name}?"
|
||||
@@ -591,7 +593,7 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
job = next((job for job in self.current_server_proxy.get_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}?"
|
||||
@@ -616,8 +618,8 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
|
||||
import webbrowser
|
||||
download_url = (f"http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}"
|
||||
f"/api/job/{job_ids[0]}/download_all")
|
||||
download_url = (f'http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}'
|
||||
f'/api/jobs/{job_ids[0]}/download_all')
|
||||
webbrowser.open(download_url)
|
||||
|
||||
def open_files(self, event):
|
||||
@@ -626,7 +628,7 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
|
||||
for job_id in job_ids:
|
||||
job_info = self.current_server_proxy.get_job_info(job_id)
|
||||
job_info = self.current_server_proxy.get_job(job_id)
|
||||
path = os.path.dirname(job_info['output_path'])
|
||||
launch_url(path)
|
||||
|
||||
@@ -665,7 +667,7 @@ class BackgroundUpdater(QThread):
|
||||
ZeroconfServer.get_hostname_properties(x)['api_version'] == API_VERSION]
|
||||
if self.window.current_server_proxy:
|
||||
self.window.job_data[self.window.current_server_proxy.hostname] = \
|
||||
self.window.current_server_proxy.get_all_jobs(ignore_token=False)
|
||||
self.window.current_server_proxy.get_jobs(ignore_token=False)
|
||||
self.needs_update = False
|
||||
self.updated_signal.emit()
|
||||
time.sleep(0.05)
|
||||
|
||||
@@ -37,7 +37,7 @@ class GetEngineInfoWorker(QThread):
|
||||
self.parent = parent
|
||||
|
||||
def run(self):
|
||||
data = RenderServerProxy(socket.gethostname()).get_all_engine_info()
|
||||
data = RenderServerProxy(socket.gethostname()).get_engines()
|
||||
self.done.emit(data)
|
||||
|
||||
class SettingsWindow(QMainWindow):
|
||||
|
||||
@@ -17,7 +17,7 @@ def download_missing_frames_from_subjob(local_job, subjob_id, subjob_hostname):
|
||||
try:
|
||||
local_files = [os.path.basename(x) for x in local_job.file_list()]
|
||||
subjob_proxy = RenderServerProxy(subjob_hostname)
|
||||
subjob_files = subjob_proxy.get_job_files_list(job_id=subjob_id) or []
|
||||
subjob_files = subjob_proxy.get_job_files(job_id=subjob_id) or []
|
||||
|
||||
for subjob_filename in subjob_files:
|
||||
if subjob_filename not in local_files:
|
||||
@@ -127,7 +127,7 @@ def distribute_server_work(start_frame, end_frame, available_servers, method='ev
|
||||
def fetch_benchmark(server):
|
||||
try:
|
||||
benchmark = requests.get(f'http://{server["hostname"]}:{ZeroconfServer.server_port}'
|
||||
f'/api/cpu_benchmark').text
|
||||
f'/api/cpu_benchmark', timeout=15).text
|
||||
server['cpu_benchmark'] = benchmark
|
||||
logger.debug(f'Benchmark for {server["hostname"]}: {benchmark}')
|
||||
except requests.exceptions.RequestException as e:
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
APP_NAME = "Zordon"
|
||||
APP_VERSION = "0.0.1"
|
||||
APP_VERSION = "0.8.0"
|
||||
APP_AUTHOR = "Brett Williams"
|
||||
APP_DESCRIPTION = "Distributed Render Farm Tools"
|
||||
APP_COPYRIGHT_YEAR = "2024"
|
||||
APP_COPYRIGHT_YEAR = "2026"
|
||||
APP_LICENSE = "MIT License"
|
||||
APP_REPO_NAME = APP_NAME
|
||||
APP_REPO_OWNER = "blw1138"
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.api.server_proxy import RenderServerProxy
|
||||
|
||||
@@ -38,9 +39,8 @@ class SubmissionTestCase(unittest.TestCase):
|
||||
msg=f'Server not reachable at {SERVER_HOST}:{SERVER_PORT}')
|
||||
|
||||
def test_submit_job(self):
|
||||
sample_file_path = os.path.join(os.path.dirname(__file__), 'resources',
|
||||
'batman_sample.blend')
|
||||
self.assertTrue(os.path.exists(sample_file_path),
|
||||
sample_file_path = Path(__file__).parent / 'resources' / 'batman_sample.blend'
|
||||
self.assertTrue(sample_file_path.exists(),
|
||||
msg=f'Test file not found: {sample_file_path}')
|
||||
|
||||
sample_job = {
|
||||
@@ -52,8 +52,8 @@ class SubmissionTestCase(unittest.TestCase):
|
||||
'enable_split_jobs': False,
|
||||
}
|
||||
|
||||
response = self.render_server.post_job_to_server(
|
||||
file_path=sample_file_path, job_list=[sample_job])
|
||||
response = self.render_server.create_job(
|
||||
file_path=sample_file_path, job_data=sample_job)
|
||||
self.assertIsNotNone(response, msg='No response from server')
|
||||
self.assertTrue(response.ok, msg=f'Server returned {response.status_code}')
|
||||
|
||||
@@ -68,7 +68,7 @@ class SubmissionTestCase(unittest.TestCase):
|
||||
file_count = 0
|
||||
|
||||
while True:
|
||||
update_response = self.render_server.get_job_info(self.__class__.test_job_id)
|
||||
update_response = self.render_server.get_job(self.__class__.test_job_id)
|
||||
if update_response:
|
||||
print(f"Status: {update_response['status']}")
|
||||
|
||||
|
||||
@@ -56,8 +56,70 @@ class TestCreateRenderJob:
|
||||
|
||||
assert result == worker
|
||||
assert worker.status == RenderStatus.NOT_STARTED
|
||||
assert mock_create_worker.call_args.kwargs['output_path'] == project_path.parent.parent / 'output' / 'test_project'
|
||||
mock_add.assert_called_once_with(worker, force_start=False)
|
||||
|
||||
@patch('src.distributed_job_manager.os.makedirs')
|
||||
@patch('src.distributed_job_manager.EngineManager.create_worker')
|
||||
def test_uses_requested_output_path(
|
||||
self, mock_create_worker, mock_makedirs, distributed_job_manager_instance,
|
||||
config_instance, tmp_path,
|
||||
):
|
||||
worker = MagicMock()
|
||||
worker.total_frames = 10
|
||||
worker.parent = None
|
||||
mock_create_worker.return_value = worker
|
||||
|
||||
project_path = tmp_path / 'test_project.blend'
|
||||
project_path.write_text('fake')
|
||||
|
||||
attrs = {
|
||||
'engine_name': 'blender',
|
||||
'args': {},
|
||||
'name': 'Camera Job',
|
||||
'output_path': 'test_project_Camera-001',
|
||||
'enable_split_jobs': False,
|
||||
}
|
||||
|
||||
with patch('src.distributed_job_manager.RenderQueue.add_to_render_queue'):
|
||||
with patch('src.distributed_job_manager.PreviewManager.update_previews_for_job'):
|
||||
DistributedJobManager.create_render_job(attrs, project_path)
|
||||
|
||||
assert mock_create_worker.call_args.kwargs['output_path'] == (
|
||||
project_path.parent.parent / 'output' / 'test_project_Camera-001'
|
||||
)
|
||||
|
||||
@patch('src.distributed_job_manager.os.makedirs')
|
||||
@patch('src.distributed_job_manager.EngineManager.create_worker')
|
||||
def test_uses_output_subdir_when_requested(
|
||||
self, mock_create_worker, mock_makedirs, distributed_job_manager_instance,
|
||||
config_instance, tmp_path,
|
||||
):
|
||||
worker = MagicMock()
|
||||
worker.total_frames = 10
|
||||
worker.parent = None
|
||||
mock_create_worker.return_value = worker
|
||||
|
||||
project_path = tmp_path / 'test_project.blend'
|
||||
project_path.write_text('fake')
|
||||
|
||||
attrs = {
|
||||
'engine_name': 'blender',
|
||||
'args': {},
|
||||
'name': 'Camera Job',
|
||||
'output_path': 'test_project_Camera-001',
|
||||
'__use_output_subdir': True,
|
||||
'enable_split_jobs': False,
|
||||
}
|
||||
|
||||
with patch('src.distributed_job_manager.RenderQueue.add_to_render_queue'):
|
||||
with patch('src.distributed_job_manager.PreviewManager.update_previews_for_job'):
|
||||
DistributedJobManager.create_render_job(attrs, project_path)
|
||||
|
||||
expected_output_dir = project_path.parent.parent / 'output' / 'test_project_Camera-001'
|
||||
assert mock_create_worker.call_args.kwargs['output_path'] == expected_output_dir / 'test_project_Camera-001'
|
||||
mock_makedirs.assert_called_with(expected_output_dir, exist_ok=True)
|
||||
|
||||
@patch('src.distributed_job_manager.os.makedirs')
|
||||
@patch('src.distributed_job_manager.EngineManager.create_worker')
|
||||
def test_split_jobs_enabled_calls_split_async(
|
||||
@@ -124,10 +186,12 @@ class TestFindAvailableServers:
|
||||
self, mock_proxy_class, mock_get_props, mock_found_hostnames,
|
||||
):
|
||||
mock_found_hostnames.return_value = ['server-1.local']
|
||||
mock_get_props.return_value = {'api_version': '0.1', 'system_os': 'macos'}
|
||||
from src.api.api_server import API_VERSION
|
||||
|
||||
mock_get_props.return_value = {'api_version': API_VERSION, 'system_os': 'macos'}
|
||||
|
||||
mock_proxy = MagicMock()
|
||||
mock_proxy.is_engine_available.return_value = {
|
||||
mock_proxy.get_engine_availability.return_value = {
|
||||
'available': True,
|
||||
'hostname': 'server-1.local',
|
||||
}
|
||||
@@ -136,3 +200,4 @@ class TestFindAvailableServers:
|
||||
result = DistributedJobManager.find_available_servers('blender')
|
||||
assert len(result) == 1
|
||||
assert result[0]['hostname'] == 'server-1.local'
|
||||
mock_proxy.get_engine_availability.assert_called_once_with('blender')
|
||||
|
||||
Reference in New Issue
Block a user