7 Commits

Author SHA1 Message Date
Brett Williams 44e6b4332f Fix create-executables.yml workflow to upload output to release page 2026-06-06 17:32:16 -05:00
brett c38213fb58 Update and modernize create-executables action (#138)
* Modernize create-executables.yml

* Update version numbers

* Fix API version in test
2026-06-06 17:10:53 -05:00
brett 3486feaaf4 Add timeouts and fix multi-camera render issue (#136)
* Fix issue where add job window always submits to localhost, regardless of selected hostname

* Add lots of timeouts

* Fix issue where multiple cameras would not render
2026-06-06 17:01:37 -05:00
brett 141843c916 Fix issue where jobs were not always deleted (#137)
* Better job deletion logic

* Cleanup UI after deleting runs
2026-06-06 16:08:02 -05:00
brett 472c7968b3 Fix issue where add job window always submits to localhost, regardless of selected hostname (#135) 2026-06-06 15:05:31 -05:00
brett b8b71d1e16 Add Blender plugin (#134)
* Unbind hostname to allow localhost submissions

* Fix issue where multiple cameras were outputting to the same directory

* Add Blender plugin
2026-06-06 14:32:48 -05:00
brett f0be78adcc REST API endpoint streamlining and cleanup (#133)
* Consolidate engine_info api calls

* Change api methods to use POST when possible

* Delete engine API cleanup

* Remove redundant installed_engines endpoint

* Update proxy to use similar named methods to new API calls

* More API cleanup

* Jobs API cleanup

* More jobs API cleanup

* Fix add jobs error due to null queue

* Remove unnecessary full_status and snapshot API endpoints

* Streamline add job POST endpoint

* Fix test after method name change
2026-06-06 12:04:52 -05:00
24 changed files with 2336 additions and 309 deletions
+94 -28
View File
@@ -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
+34 -10
View File
@@ -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
View File
@@ -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.
+153
View File
@@ -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.
+621
View File
@@ -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
View File
@@ -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
View File
@@ -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/&lt;status_val&gt;</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/&lt;job_id&gt;</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/&lt;job_id&gt;/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/&lt;job_id&gt;/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/&lt;job_id&gt;/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/&lt;job_id&gt;/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/&lt;job_id&gt;/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/&lt;job_id&gt;/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/&lt;job_id&gt;/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/&lt;job_id&gt;/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/&lt;engine_name&gt;</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/&lt;engine_name&gt;/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/&lt;engine_name&gt;/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/&lt;engine_name&gt;/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/&lt;status_val&gt;</code></li>
<li><code>GET /api/presets</code></li>
<li><code>GET /api/disk_benchmark</code></li>
<li><code>GET /api/engines/&lt;engine_name&gt;/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>
+2 -2
View File
@@ -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()
+138 -142
View File
@@ -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):
return [Path(p).name for p in RenderQueue.job_with_id(job_id).file_list()]
@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):
logger.info(f"Removing project directory: {project_dir}")
shutil.rmtree(project_dir)
except Exception as e:
logger.error(f"Error removing project files: {e}")
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)
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}")
def process_engine(engine):
try:
# Get all installed versions of the engine
installed_versions = EngineManager.all_version_data_for_engine(engine.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']
en = engine(install_path)
engine_name = en.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
return response_type
@server.get('/api/<engine_name>/info')
def get_engine_info(engine_name):
def _engine_info_for_engine(engine_class, response_type='standard'):
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)
installed_versions = EngineManager.all_version_data_for_engine(engine_class.name())
if not installed_versions:
return {}
return None
result = { 'is_available': RenderQueue.is_available_for_job(engine_name),
'versions': installed_versions
}
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']
)
engine = engine_class(install_path)
engine_name = engine.name()
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'
+1
View File
@@ -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
View File
@@ -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):
+7 -5
View File
@@ -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)
+1 -1
View File
@@ -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+)/'
+27 -9
View File
@@ -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,8 +626,8 @@ 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,
callback=create_callback)
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)
+4 -2
View File
@@ -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:
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+48 -46
View File
@@ -229,26 +229,22 @@ 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
# 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()
new_hostname = current_item.text()
if new_hostname == self.current_hostname:
return
# Select the first row if there are jobs listed
if self.job_list_view.rowCount():
self.job_list_view.selectRow(0)
self.current_hostname = new_hostname
self.job_list_view.setRowCount(0)
self.refresh_job_list()
# Update server information display
self.update_server_info_display(new_hostname)
if self.job_list_view.rowCount():
self.job_list_view.selectRow(0)
except AttributeError as e:
logger.error(f"AttributeError in server_picked: {e}")
self.update_server_info_display(new_hostname)
def update_server_info_display(self, hostname):
"""Updates the server information section of the UI."""
@@ -297,34 +293,36 @@ class MainWindow(QMainWindow):
return
server_job_data = self.job_data.get(self.current_server_proxy.hostname)
if server_job_data:
num_jobs = len(server_job_data)
self.job_list_view.setRowCount(num_jobs)
if server_job_data is None:
return
for row, job in enumerate(server_job_data):
num_jobs = len(server_job_data)
self.job_list_view.setRowCount(num_jobs)
display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \
('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status
tags = (job['status'],)
start_time = datetime.datetime.fromisoformat(job['start_time']) if job['start_time'] else None
end_time = datetime.datetime.fromisoformat(job['end_time']) if job['end_time'] else None
for row, job in enumerate(server_job_data):
time_elapsed = "" if (job['status'] != RenderStatus.RUNNING.value and not end_time) else \
get_time_elapsed(start_time, end_time)
display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \
('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status
tags = (job['status'],)
start_time = datetime.datetime.fromisoformat(job['start_time']) if job['start_time'] else None
end_time = datetime.datetime.fromisoformat(job['end_time']) if job['end_time'] else None
name = job.get('name') or os.path.basename(job.get('input_path', ''))
engine_name = f"{job.get('engine', '')}-{job.get('engine_version')}"
priority = str(job.get('priority', ''))
total_frames = str(job.get('total_frames', ''))
converted_time = datetime.datetime.fromisoformat(job['date_created'])
humanized_time = humanize.naturaltime(converted_time)
time_elapsed = "" if (job['status'] != RenderStatus.RUNNING.value and not end_time) else \
get_time_elapsed(start_time, end_time)
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(engine_name),
QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed),
QTableWidgetItem(total_frames), QTableWidgetItem(humanized_time)]
name = job.get('name') or os.path.basename(job.get('input_path', ''))
engine_name = f"{job.get('engine', '')}-{job.get('engine_version')}"
priority = str(job.get('priority', ''))
total_frames = str(job.get('total_frames', ''))
converted_time = datetime.datetime.fromisoformat(job['date_created'])
humanized_time = humanize.naturaltime(converted_time)
for col, item in enumerate(items):
self.job_list_view.setItem(row, col, item)
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(engine_name),
QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed),
QTableWidgetItem(total_frames), QTableWidgetItem(humanized_time)]
for col, item in enumerate(items):
self.job_list_view.setItem(row, col, item)
# -- Job Code -- #
def job_picked(self):
@@ -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)
+1 -1
View File
@@ -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):
+2 -2
View File
@@ -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
View File
@@ -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"
+6 -6
View File
@@ -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']}")
+67 -2
View File
@@ -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')