mirror of
https://github.com/blw1138/Zordon.git
synced 2026-06-09 13:39:24 -05:00
Compare commits
14 Commits
e8992fc91a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 44e6b4332f | |||
| c38213fb58 | |||
| 3486feaaf4 | |||
| 141843c916 | |||
| 472c7968b3 | |||
| b8b71d1e16 | |||
| f0be78adcc | |||
| 24eb7b5616 | |||
| 7bf5fb554e | |||
| fa4a97f6fa | |||
| b7ba1201e4 | |||
| c592236c98 | |||
| 0c62f454a7 | |||
| 552c791207 |
@@ -0,0 +1,45 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install system deps
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libxcb-cursor0 libxcb-xinerama0 blender
|
||||||
|
|
||||||
|
- name: Install Python deps
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install pytest flake8 pylint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: python -m pytest tests/ --ignore=tests/job_creation_tests.py -v
|
||||||
|
|
||||||
|
- name: Lint — bugs (flake8)
|
||||||
|
# Fails on syntax errors only. Ignored: ~100+ pre-existing
|
||||||
|
# style issues (E/W), star-import false positives (F403/F405),
|
||||||
|
# unused imports (F401), unused vars (F841), f-string debt (F541).
|
||||||
|
run: flake8 --select=E9 --exclude=src/engines/aerender,build,dist,.git,__pycache__,venv --max-line-length=127 --max-complexity=35
|
||||||
|
|
||||||
|
- name: Lint — style (flake8)
|
||||||
|
# Reports style issues but never fails the build.
|
||||||
|
# TODO: resolve ~100+ pre-existing style issues
|
||||||
|
run: flake8 --exit-zero --exclude=src/engines/aerender,build,dist,.git,__pycache__,venv --max-line-length=127 --max-complexity=35
|
||||||
|
|
||||||
|
- name: Lint (pylint)
|
||||||
|
# Reports all issues but never fails the build.
|
||||||
|
# TODO: resolve pre-existing debt (current score 7.73/10)
|
||||||
|
run: pylint src/ --max-line-length=120 --ignore-paths=^src/engines/aerender/ --fail-under=0
|
||||||
@@ -3,36 +3,102 @@ name: Create Executables
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
release:
|
release:
|
||||||
- types: [created]
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pyinstaller-build-windows:
|
build:
|
||||||
runs-on: windows-latest
|
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:
|
steps:
|
||||||
- name: Create Executables (Windows)
|
- name: Checkout
|
||||||
uses: sayyid5416/pyinstaller@v1
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python_ver: '3.11'
|
python-version: '3.11'
|
||||||
spec: 'client.spec'
|
|
||||||
requirements: 'requirements.txt'
|
- name: Install Linux system dependencies
|
||||||
upload_exe_with_name: 'Zordon'
|
if: runner.os == 'Linux'
|
||||||
pyinstaller-build-linux:
|
run: sudo apt-get update && sudo apt-get install -y libxcb-cursor0 libxcb-xinerama0
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
- name: Install Python dependencies
|
||||||
- name: Create Executables (Linux)
|
run: |
|
||||||
uses: sayyid5416/pyinstaller@v1
|
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:
|
with:
|
||||||
python_ver: '3.11'
|
name: Zordon-build-${{ matrix.artifact_suffix }}
|
||||||
spec: 'client.spec'
|
path: release-assets/*.zip
|
||||||
requirements: 'requirements.txt'
|
if-no-files-found: error
|
||||||
upload_exe_with_name: 'Zordon'
|
|
||||||
pyinstaller-build-macos:
|
- name: Attach assets to release
|
||||||
runs-on: macos-latest
|
if: github.event_name == 'release'
|
||||||
steps:
|
shell: bash
|
||||||
- name: Create Executables (macOS)
|
env:
|
||||||
uses: sayyid5416/pyinstaller@v1
|
GH_TOKEN: ${{ github.token }}
|
||||||
with:
|
run: gh release upload "${{ github.event.release.tag_name }}" release-assets/*.zip --clobber
|
||||||
python_ver: '3.11'
|
|
||||||
spec: 'client.spec'
|
|
||||||
requirements: 'requirements.txt'
|
|
||||||
upload_exe_with_name: 'Zordon'
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
|
||||||
|
|
||||||
name: Python application
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "master" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "master" ]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Set up Python 3.10
|
|
||||||
uses: actions/setup-python@v3
|
|
||||||
with:
|
|
||||||
python-version: "3.10"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install flake8 pytest
|
|
||||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
|
||||||
- name: Lint with flake8
|
|
||||||
run: |
|
|
||||||
# stop the build if there are Python syntax errors or undefined names
|
|
||||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
||||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
|
||||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
|
||||||
continue-on-error: false
|
|
||||||
# - name: Test with pytest
|
|
||||||
# run: |
|
|
||||||
# pytest
|
|
||||||
@@ -83,18 +83,24 @@ The system works by:
|
|||||||
Jobs can be submitted via the desktop UI or programmatically via the API:
|
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 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:
|
Example API request:
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:5000/api/jobs \
|
curl -X POST http://localhost:8080/api/jobs \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"engine": "blender",
|
"name": "example-render",
|
||||||
"project_path": "/path/to/project.blend",
|
"engine_name": "blender",
|
||||||
"output_path": "/path/to/output",
|
"local_path": "/path/to/project.blend",
|
||||||
"frames": "1-100",
|
"output_path": "example-output",
|
||||||
"settings": {"resolution": "1920x1080"}
|
"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.
|
- **UI**: View job status, progress, logs, and worker availability in real-time.
|
||||||
- **API Endpoints**:
|
- **API Endpoints**:
|
||||||
- `GET /api/jobs`: List all jobs
|
- `GET /api/jobs`: List all jobs
|
||||||
- `GET /api/jobs/{id}`: Get job details
|
- `POST /api/jobs`: Submit a new job
|
||||||
- `DELETE /api/jobs/{id}`: Cancel a job
|
- `GET /api/jobs/<job_id>`: Get job details
|
||||||
- `GET /api/workers`: List connected workers
|
- `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
|
#### Worker Management
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -95,7 +95,7 @@ def main():
|
|||||||
|
|
||||||
new_job = {"name": job_name, "engine_name": args.engine}
|
new_job = {"name": job_name, "engine_name": args.engine}
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
print(f"Error creating job: {e}")
|
print(f"Error creating job: {e}")
|
||||||
exit(1)
|
exit(1)
|
||||||
@@ -113,7 +113,7 @@ def main():
|
|||||||
while percent_complete < 1.0:
|
while percent_complete < 1.0:
|
||||||
# add checks for errors
|
# add checks for errors
|
||||||
time.sleep(1)
|
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']
|
percent_complete = running_job_data['percent_complete']
|
||||||
sys.stdout.write("\x1b[1A") # Move up 1
|
sys.stdout.write("\x1b[1A") # Move up 1
|
||||||
sys.stdout.write("\x1b[0J") # Clear from cursor to end of screen (optional)
|
sys.stdout.write("\x1b[0J") # Clear from cursor to end of screen (optional)
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,153 @@
|
|||||||
|
# Zordon Blender Add-on
|
||||||
|
|
||||||
|
Submit the current Blender file to a Zordon render server from Blender's Render
|
||||||
|
properties panel.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Submit the current `.blend` file directly to `POST /api/jobs`.
|
||||||
|
- Choose a configured Zordon server from Blender.
|
||||||
|
- Test the server connection before submitting.
|
||||||
|
- Save the current file before upload.
|
||||||
|
- Default job and output names to the `.blend` filename.
|
||||||
|
- Use the current scene frame range, render resolution, FPS, and image format.
|
||||||
|
- Select one or more cameras for submission.
|
||||||
|
- Submit multiple selected cameras as camera-specific child jobs.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. In Blender, open `Edit > Preferences > Add-ons`.
|
||||||
|
2. Click `Install...`.
|
||||||
|
3. Select `addons/blender/zordon_blender.zip`.
|
||||||
|
4. Enable `Zordon Render Submitter`.
|
||||||
|
|
||||||
|
## Configure Servers
|
||||||
|
|
||||||
|
Use `Discover Servers` to find Zordon servers advertised with Zeroconf.
|
||||||
|
Discovered servers are merged into the configured server list.
|
||||||
|
|
||||||
|
You can also open the add-on preferences and edit `Servers` manually.
|
||||||
|
|
||||||
|
Use a comma-separated list:
|
||||||
|
|
||||||
|
```text
|
||||||
|
localhost:8080, render-node.local:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The selected server appears in `Properties > Render > Zordon`.
|
||||||
|
|
||||||
|
If Blender is running on the same machine as Zordon, `localhost:8080` should
|
||||||
|
work. If Zordon is running on another machine, use that machine's hostname or IP
|
||||||
|
address.
|
||||||
|
|
||||||
|
Start the Zordon server before testing the connection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Discovery first tries Python's `zeroconf` package if it is available inside
|
||||||
|
Blender. If it is not available, the add-on falls back to the macOS `dns-sd`
|
||||||
|
command when present.
|
||||||
|
|
||||||
|
## Submit A Job
|
||||||
|
|
||||||
|
1. Open or save a `.blend` file.
|
||||||
|
2. Choose a Zordon server in `Properties > Render > Zordon`.
|
||||||
|
3. Click `Test Connection`.
|
||||||
|
4. Set the job name, output name, and notes if needed.
|
||||||
|
5. Choose one or more cameras. At least one camera is always required.
|
||||||
|
6. Click `Submit Current File`.
|
||||||
|
|
||||||
|
The addon uploads the current `.blend` to `POST /api/jobs` as multipart form
|
||||||
|
data. It uses the current scene frame range, render resolution, FPS, and image
|
||||||
|
format.
|
||||||
|
|
||||||
|
Job name and output name default to the current `.blend` filename.
|
||||||
|
|
||||||
|
If one camera is selected, the job renders that camera. If multiple cameras are
|
||||||
|
selected, the addon submits camera-specific child jobs so each camera renders as
|
||||||
|
its own Zordon job.
|
||||||
|
|
||||||
|
Use `All` to select every camera or `Active Only` to render just the active
|
||||||
|
scene camera.
|
||||||
|
|
||||||
|
## Camera Behavior
|
||||||
|
|
||||||
|
At least one camera must be selected. The add-on prevents unchecking the final
|
||||||
|
selected camera.
|
||||||
|
|
||||||
|
When one camera is selected, the add-on sends a single job with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"args": {
|
||||||
|
"camera": "Camera"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When multiple cameras are selected, the add-on sends `child_jobs`. Each child job
|
||||||
|
gets its own camera argument and output name suffix, such as:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "scene_Camera-001",
|
||||||
|
"output_path": "scene_Camera-001",
|
||||||
|
"args": {
|
||||||
|
"camera": "Camera.001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Could Not Reach Zordon
|
||||||
|
|
||||||
|
Make sure the Zordon server is running and reachable from Blender.
|
||||||
|
|
||||||
|
Check from a terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/api/heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
If the server is remote, replace `localhost` with the server hostname or IP.
|
||||||
|
|
||||||
|
### No Cameras Found
|
||||||
|
|
||||||
|
Add at least one Blender camera to the scene before submitting.
|
||||||
|
|
||||||
|
### Output Files Already Exist
|
||||||
|
|
||||||
|
Multiple camera jobs should use camera-specific output names. If they collide,
|
||||||
|
make sure the Zordon server includes the output-path fix that preserves child
|
||||||
|
job `output_path` values.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
The add-on source lives at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
addons/blender/zordon_blender/
|
||||||
|
```
|
||||||
|
|
||||||
|
The installable archive is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
addons/blender/zordon_blender.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuild the archive from `addons/blender`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m zipfile -c zordon_blender.zip zordon_blender
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The add-on is dependency-free and uses Blender's bundled Python standard
|
||||||
|
library.
|
||||||
|
- Only `http://` Zordon servers are currently supported.
|
||||||
|
- `Save Before Submit` is enabled by default so the uploaded file matches the
|
||||||
|
current Blender state.
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
bl_info = {
|
||||||
|
'name': 'Zordon Render Submitter',
|
||||||
|
'author': 'Zordon',
|
||||||
|
'version': (0, 1, 0),
|
||||||
|
'blender': (3, 6, 0),
|
||||||
|
'location': 'Properties > Render > Zordon',
|
||||||
|
'description': 'Submit the current Blender file to a Zordon render server.',
|
||||||
|
'category': 'Render',
|
||||||
|
}
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy.props import BoolProperty, EnumProperty, IntProperty, StringProperty
|
||||||
|
from bpy.types import AddonPreferences, Operator, Panel, PropertyGroup
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SERVER = 'localhost:8080'
|
||||||
|
DEFAULT_TIMEOUT = 10
|
||||||
|
UPLOAD_TIMEOUT = 300
|
||||||
|
ZORDON_SERVICE_TYPE = '_zordon._tcp.local.'
|
||||||
|
|
||||||
|
|
||||||
|
def _addon_preferences(context):
|
||||||
|
return context.preferences.addons[__name__].preferences
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_server_items(self, context):
|
||||||
|
preferences = _addon_preferences(context)
|
||||||
|
raw_servers = [server.strip() for server in preferences.servers.split(',')]
|
||||||
|
servers = [server for server in raw_servers if server]
|
||||||
|
if not servers:
|
||||||
|
servers = [DEFAULT_SERVER]
|
||||||
|
return [(server, server, '', index) for index, server in enumerate(servers)]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_server_url(server, fallback_port):
|
||||||
|
if not server:
|
||||||
|
server = DEFAULT_SERVER
|
||||||
|
|
||||||
|
server = server.strip()
|
||||||
|
if not server.startswith(('http://', 'https://')):
|
||||||
|
server = f'http://{server}'
|
||||||
|
|
||||||
|
parsed = urlparse(server)
|
||||||
|
hostname = parsed.hostname or 'localhost'
|
||||||
|
scheme = parsed.scheme or 'http'
|
||||||
|
port = parsed.port or fallback_port or 8080
|
||||||
|
return scheme, hostname, int(port)
|
||||||
|
|
||||||
|
|
||||||
|
def _api_url(server, port, path):
|
||||||
|
scheme, hostname, parsed_port = _normalize_server_url(server, port)
|
||||||
|
return f'{scheme}://{hostname}:{parsed_port}{path}'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_text(server, port, path, timeout=DEFAULT_TIMEOUT):
|
||||||
|
request = Request(_api_url(server, port, path), method='GET')
|
||||||
|
with urlopen(request, timeout=timeout) as response:
|
||||||
|
return response.status, response.read().decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _server_list_from_preferences(preferences):
|
||||||
|
return [server.strip() for server in preferences.servers.split(',') if server.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _save_server_list(preferences, servers):
|
||||||
|
unique_servers = []
|
||||||
|
for server in servers:
|
||||||
|
if server and server not in unique_servers:
|
||||||
|
unique_servers.append(server)
|
||||||
|
preferences.servers = ', '.join(unique_servers)
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_servers_with_zeroconf(timeout=3.0):
|
||||||
|
try:
|
||||||
|
from zeroconf import ServiceBrowser, Zeroconf
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
discovered = []
|
||||||
|
zeroconf = Zeroconf()
|
||||||
|
|
||||||
|
class Listener:
|
||||||
|
def add_service(self, zc, service_type, name):
|
||||||
|
info = zc.get_service_info(service_type, name, timeout=1000)
|
||||||
|
if info and info.server and info.port:
|
||||||
|
discovered.append(f'{info.server.rstrip(".")}:{info.port}')
|
||||||
|
|
||||||
|
def update_service(self, zc, service_type, name):
|
||||||
|
self.add_service(zc, service_type, name)
|
||||||
|
|
||||||
|
def remove_service(self, zc, service_type, name):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
ServiceBrowser(zeroconf, ZORDON_SERVICE_TYPE, Listener())
|
||||||
|
time.sleep(timeout)
|
||||||
|
finally:
|
||||||
|
zeroconf.close()
|
||||||
|
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_service_names_with_dns_sd(timeout=2.0):
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
['dns-sd', '-B', '_zordon', '_tcp', 'local'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, _ = process.communicate(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.terminate()
|
||||||
|
stdout, _ = process.communicate(timeout=1)
|
||||||
|
|
||||||
|
service_names = []
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if ' Add ' not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 7:
|
||||||
|
service_name = ' '.join(parts[6:])
|
||||||
|
if service_name and service_name not in service_names:
|
||||||
|
service_names.append(service_name)
|
||||||
|
return service_names
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_service_with_dns_sd(service_name, timeout=2.0):
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
['dns-sd', '-L', service_name, '_zordon', '_tcp', 'local'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, _ = process.communicate(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.terminate()
|
||||||
|
stdout, _ = process.communicate(timeout=1)
|
||||||
|
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if ' can be reached at ' not in line:
|
||||||
|
continue
|
||||||
|
match = re.search(r'can be reached at\s+([^:\s]+):(\d+)', line)
|
||||||
|
if match:
|
||||||
|
hostname, port = match.groups()
|
||||||
|
return f'{hostname.rstrip(".")}:{port}'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_servers_with_dns_sd():
|
||||||
|
servers = []
|
||||||
|
for service_name in _discover_service_names_with_dns_sd():
|
||||||
|
server = _resolve_service_with_dns_sd(service_name)
|
||||||
|
if server and server not in servers:
|
||||||
|
servers.append(server)
|
||||||
|
return servers
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_zordon_servers():
|
||||||
|
discovered = _discover_servers_with_zeroconf()
|
||||||
|
if discovered:
|
||||||
|
return discovered
|
||||||
|
return _discover_servers_with_dns_sd()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_multipart_job(server, port, job_data, file_path):
|
||||||
|
scheme, hostname, parsed_port = _normalize_server_url(server, port)
|
||||||
|
if scheme != 'http':
|
||||||
|
raise ValueError('Only http:// Zordon servers are currently supported.')
|
||||||
|
|
||||||
|
boundary = f'----ZordonBlender{int(time.time() * 1000)}'
|
||||||
|
json_payload = json.dumps(job_data).encode('utf-8')
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
parts_before_file = [
|
||||||
|
f'--{boundary}\r\n',
|
||||||
|
'Content-Disposition: form-data; name="json"\r\n',
|
||||||
|
'Content-Type: application/json\r\n\r\n',
|
||||||
|
json_payload.decode('utf-8'),
|
||||||
|
'\r\n',
|
||||||
|
f'--{boundary}\r\n',
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="{file_name}"\r\n',
|
||||||
|
'Content-Type: application/octet-stream\r\n\r\n',
|
||||||
|
]
|
||||||
|
preamble = ''.join(parts_before_file).encode('utf-8')
|
||||||
|
closing = f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||||
|
content_length = len(preamble) + file_size + len(closing)
|
||||||
|
|
||||||
|
connection = http.client.HTTPConnection(hostname, parsed_port, timeout=UPLOAD_TIMEOUT)
|
||||||
|
try:
|
||||||
|
connection.putrequest('POST', '/api/jobs')
|
||||||
|
connection.putheader('Content-Type', f'multipart/form-data; boundary={boundary}')
|
||||||
|
connection.putheader('Content-Length', str(content_length))
|
||||||
|
connection.endheaders()
|
||||||
|
connection.send(preamble)
|
||||||
|
|
||||||
|
with open(file_path, 'rb') as upload_file:
|
||||||
|
while True:
|
||||||
|
chunk = upload_file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
connection.send(chunk)
|
||||||
|
|
||||||
|
connection.send(closing)
|
||||||
|
response = connection.getresponse()
|
||||||
|
response_body = response.read().decode('utf-8', errors='replace')
|
||||||
|
return response.status, response_body
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _current_file_path():
|
||||||
|
return bpy.data.filepath
|
||||||
|
|
||||||
|
|
||||||
|
def _default_job_name():
|
||||||
|
file_path = _current_file_path()
|
||||||
|
if file_path:
|
||||||
|
return os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
return 'Blender Render'
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_objects():
|
||||||
|
return [obj for obj in bpy.data.objects if obj.type == 'CAMERA']
|
||||||
|
|
||||||
|
|
||||||
|
def _default_selected_camera_names(scene):
|
||||||
|
cameras = _camera_objects()
|
||||||
|
if not cameras:
|
||||||
|
return []
|
||||||
|
if scene.camera:
|
||||||
|
return [scene.camera.name]
|
||||||
|
if len(cameras) == 1:
|
||||||
|
return [cameras[0].name]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_camera_names(props, scene=None):
|
||||||
|
if props.selected_cameras:
|
||||||
|
try:
|
||||||
|
names = json.loads(props.selected_cameras)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
names = []
|
||||||
|
existing_names = {camera.name for camera in _camera_objects()}
|
||||||
|
selected_names = [name for name in names if name in existing_names]
|
||||||
|
if selected_names:
|
||||||
|
return selected_names
|
||||||
|
if scene:
|
||||||
|
return _default_selected_camera_names(scene)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _set_selected_camera_names(props, camera_names):
|
||||||
|
props.selected_cameras = json.dumps(list(camera_names))
|
||||||
|
|
||||||
|
|
||||||
|
def _active_camera_names(scene):
|
||||||
|
if scene.camera:
|
||||||
|
return [scene.camera.name]
|
||||||
|
return _default_selected_camera_names(scene)
|
||||||
|
|
||||||
|
|
||||||
|
def _scene_export_format(scene):
|
||||||
|
file_format = scene.render.image_settings.file_format
|
||||||
|
return file_format or 'PNG'
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_AddonPreferences(AddonPreferences):
|
||||||
|
bl_idname = __name__
|
||||||
|
|
||||||
|
servers: StringProperty(
|
||||||
|
name='Servers',
|
||||||
|
description='Comma-separated Zordon servers. Example: studio-render.local:8080, localhost:8080',
|
||||||
|
default=DEFAULT_SERVER,
|
||||||
|
)
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
layout.prop(self, 'servers')
|
||||||
|
layout.operator('zordon.discover_servers', icon='VIEWZOOM')
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_SubmissionProperties(PropertyGroup):
|
||||||
|
server: EnumProperty(
|
||||||
|
name='Server',
|
||||||
|
description='Zordon server to submit this file to',
|
||||||
|
items=_configured_server_items,
|
||||||
|
)
|
||||||
|
port: IntProperty(
|
||||||
|
name='Fallback Port',
|
||||||
|
description='Port used when the selected server does not include one',
|
||||||
|
default=8080,
|
||||||
|
min=1,
|
||||||
|
max=65535,
|
||||||
|
)
|
||||||
|
job_name: StringProperty(
|
||||||
|
name='Job Name',
|
||||||
|
description='Name shown in Zordon',
|
||||||
|
default='',
|
||||||
|
)
|
||||||
|
output_path: StringProperty(
|
||||||
|
name='Output Name',
|
||||||
|
description='Output name or path used by the Zordon job',
|
||||||
|
default='',
|
||||||
|
)
|
||||||
|
notes: StringProperty(
|
||||||
|
name='Notes',
|
||||||
|
description='Optional notes for the Zordon job',
|
||||||
|
default='',
|
||||||
|
)
|
||||||
|
save_before_submit: BoolProperty(
|
||||||
|
name='Save Before Submit',
|
||||||
|
description='Save the current .blend before uploading it',
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
use_scene_frame_range: BoolProperty(
|
||||||
|
name='Use Scene Frame Range',
|
||||||
|
description='Submit the current scene frame start and end',
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
selected_cameras: StringProperty(
|
||||||
|
name='Selected Cameras',
|
||||||
|
description='JSON encoded list of selected camera names',
|
||||||
|
default='',
|
||||||
|
options={'HIDDEN'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_OT_DiscoverServers(Operator):
|
||||||
|
bl_idname = 'zordon.discover_servers'
|
||||||
|
bl_label = 'Discover Servers'
|
||||||
|
bl_description = 'Find Zordon servers advertised with Zeroconf'
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
preferences = _addon_preferences(context)
|
||||||
|
existing_servers = _server_list_from_preferences(preferences)
|
||||||
|
discovered_servers = _discover_zordon_servers()
|
||||||
|
if not discovered_servers:
|
||||||
|
self.report({'WARNING'}, 'No Zordon servers found.')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
merged_servers = existing_servers + discovered_servers
|
||||||
|
_save_server_list(preferences, merged_servers)
|
||||||
|
|
||||||
|
props = getattr(context.scene, 'zordon_submission', None)
|
||||||
|
if props:
|
||||||
|
try:
|
||||||
|
props.server = discovered_servers[0]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.report({'INFO'}, f'Found {len(discovered_servers)} Zordon server(s).')
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_OT_TestConnection(Operator):
|
||||||
|
bl_idname = 'zordon.test_connection'
|
||||||
|
bl_label = 'Test Connection'
|
||||||
|
bl_description = 'Check whether the selected Zordon server is reachable'
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
props = context.scene.zordon_submission
|
||||||
|
try:
|
||||||
|
status, body = _get_text(props.server, props.port, '/api/heartbeat')
|
||||||
|
except (HTTPError, URLError, TimeoutError, OSError) as error:
|
||||||
|
self.report({'ERROR'}, f'Could not reach Zordon: {error}')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
self.report({'INFO'}, f'Connected to Zordon: {body}')
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
self.report({'ERROR'}, f'Unexpected response from Zordon: HTTP {status}')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_OT_RefreshCameras(Operator):
|
||||||
|
bl_idname = 'zordon.refresh_cameras'
|
||||||
|
bl_label = 'Refresh Cameras'
|
||||||
|
bl_description = 'Refresh the camera checklist from the current Blender scene'
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
_set_selected_camera_names(
|
||||||
|
context.scene.zordon_submission,
|
||||||
|
_default_selected_camera_names(context.scene),
|
||||||
|
)
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_OT_ToggleCamera(Operator):
|
||||||
|
bl_idname = 'zordon.toggle_camera'
|
||||||
|
bl_label = 'Toggle Camera'
|
||||||
|
bl_description = 'Toggle whether this camera is submitted to Zordon'
|
||||||
|
|
||||||
|
camera_name: StringProperty(name='Camera Name')
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
props = context.scene.zordon_submission
|
||||||
|
selected = set(_selected_camera_names(props, context.scene))
|
||||||
|
if self.camera_name in selected:
|
||||||
|
if len(selected) == 1:
|
||||||
|
self.report({'WARNING'}, 'At least one camera must be selected.')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
selected.remove(self.camera_name)
|
||||||
|
else:
|
||||||
|
selected.add(self.camera_name)
|
||||||
|
_set_selected_camera_names(props, sorted(selected))
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_OT_SelectAllCameras(Operator):
|
||||||
|
bl_idname = 'zordon.select_all_cameras'
|
||||||
|
bl_label = 'All'
|
||||||
|
bl_description = 'Select all cameras for submission'
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
props = context.scene.zordon_submission
|
||||||
|
_set_selected_camera_names(props, [camera.name for camera in _camera_objects()])
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_OT_SelectActiveCamera(Operator):
|
||||||
|
bl_idname = 'zordon.select_active_camera'
|
||||||
|
bl_label = 'Active Only'
|
||||||
|
bl_description = 'Select only the active scene camera'
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
active_cameras = _active_camera_names(context.scene)
|
||||||
|
if not active_cameras:
|
||||||
|
self.report({'WARNING'}, 'No cameras found.')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
_set_selected_camera_names(context.scene.zordon_submission, active_cameras)
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_OT_SubmitCurrentFile(Operator):
|
||||||
|
bl_idname = 'zordon.submit_current_file'
|
||||||
|
bl_label = 'Submit Current File'
|
||||||
|
bl_description = 'Upload the current Blender file to the selected Zordon server'
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
props = context.scene.zordon_submission
|
||||||
|
scene = context.scene
|
||||||
|
file_path = _current_file_path()
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
self.report({'ERROR'}, 'Save this Blender file before submitting it to Zordon.')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
if props.save_before_submit:
|
||||||
|
bpy.ops.wm.save_as_mainfile(filepath=file_path)
|
||||||
|
|
||||||
|
job_name = props.job_name.strip() or _default_job_name()
|
||||||
|
output_path = props.output_path.strip() or job_name
|
||||||
|
selected_cameras = _selected_camera_names(props, scene)
|
||||||
|
if not selected_cameras:
|
||||||
|
self.report({'ERROR'}, 'Add at least one camera before submitting to Zordon.')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
resolution = (
|
||||||
|
int(scene.render.resolution_x),
|
||||||
|
int(scene.render.resolution_y),
|
||||||
|
)
|
||||||
|
|
||||||
|
job_data = {
|
||||||
|
'owner': f'{getpass.getuser()}@{socket.gethostname()}',
|
||||||
|
'engine_name': 'blender',
|
||||||
|
'engine_version': 'latest',
|
||||||
|
'name': job_name,
|
||||||
|
'output_path': output_path,
|
||||||
|
'start_frame': int(scene.frame_start) if props.use_scene_frame_range else int(scene.frame_current),
|
||||||
|
'end_frame': int(scene.frame_end) if props.use_scene_frame_range else int(scene.frame_current),
|
||||||
|
'priority': 1,
|
||||||
|
'notes': props.notes,
|
||||||
|
'enable_split_jobs': False,
|
||||||
|
'split_jobs_same_os': False,
|
||||||
|
'args': {
|
||||||
|
'raw': '',
|
||||||
|
'export_format': _scene_export_format(scene),
|
||||||
|
'resolution': resolution,
|
||||||
|
'fps': scene.render.fps,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(selected_cameras) == 1:
|
||||||
|
job_data['args']['camera'] = selected_cameras[0]
|
||||||
|
elif len(selected_cameras) > 1:
|
||||||
|
child_jobs = []
|
||||||
|
for camera_name in selected_cameras:
|
||||||
|
camera_suffix = camera_name.replace(' ', '-')
|
||||||
|
child_args = dict(job_data['args'])
|
||||||
|
child_args['camera'] = camera_name
|
||||||
|
child_jobs.append({
|
||||||
|
'name': f'{job_name}_{camera_suffix}',
|
||||||
|
'output_path': f'{output_path}_{camera_suffix}',
|
||||||
|
'args': child_args,
|
||||||
|
})
|
||||||
|
job_data['child_jobs'] = child_jobs
|
||||||
|
|
||||||
|
try:
|
||||||
|
status, body = _send_multipart_job(props.server, props.port, job_data, file_path)
|
||||||
|
except (ValueError, TimeoutError, OSError) as error:
|
||||||
|
self.report({'ERROR'}, f'Error submitting to Zordon: {error}')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
if 200 <= status < 300:
|
||||||
|
self.report({'INFO'}, f'Submitted "{job_name}" to Zordon.')
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
self.report({'ERROR'}, f'Zordon returned HTTP {status}: {body}')
|
||||||
|
return {'CANCELLED'}
|
||||||
|
|
||||||
|
|
||||||
|
class ZORDON_PT_SubmitPanel(Panel):
|
||||||
|
bl_label = 'Zordon'
|
||||||
|
bl_idname = 'ZORDON_PT_submit_panel'
|
||||||
|
bl_space_type = 'PROPERTIES'
|
||||||
|
bl_region_type = 'WINDOW'
|
||||||
|
bl_context = 'render'
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
props = context.scene.zordon_submission
|
||||||
|
layout = self.layout
|
||||||
|
default_name = _default_job_name()
|
||||||
|
cameras = _camera_objects()
|
||||||
|
selected_cameras = set(_selected_camera_names(props, context.scene))
|
||||||
|
|
||||||
|
layout.prop(props, 'server')
|
||||||
|
layout.prop(props, 'port')
|
||||||
|
|
||||||
|
row = layout.row(align=True)
|
||||||
|
row.operator('zordon.discover_servers', icon='VIEWZOOM')
|
||||||
|
row.operator('zordon.test_connection', icon='LINKED')
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
if not props.job_name:
|
||||||
|
layout.label(text=f'Job Name Default: {default_name}')
|
||||||
|
layout.prop(props, 'job_name')
|
||||||
|
if not props.output_path:
|
||||||
|
layout.label(text=f'Output Name Default: {default_name}')
|
||||||
|
layout.prop(props, 'output_path')
|
||||||
|
layout.prop(props, 'notes')
|
||||||
|
layout.prop(props, 'save_before_submit')
|
||||||
|
layout.prop(props, 'use_scene_frame_range')
|
||||||
|
|
||||||
|
if props.use_scene_frame_range:
|
||||||
|
layout.label(text=f'Frames: {context.scene.frame_start} - {context.scene.frame_end}')
|
||||||
|
else:
|
||||||
|
layout.label(text=f'Frame: {context.scene.frame_current}')
|
||||||
|
|
||||||
|
layout.label(text=f'Format: {_scene_export_format(context.scene)}')
|
||||||
|
|
||||||
|
layout.separator()
|
||||||
|
camera_header = layout.row(align=True)
|
||||||
|
camera_header.label(text='Cameras')
|
||||||
|
camera_header.operator('zordon.refresh_cameras', text='', icon='FILE_REFRESH')
|
||||||
|
camera_header.operator('zordon.select_all_cameras', text='All')
|
||||||
|
camera_header.operator('zordon.select_active_camera', text='Active Only')
|
||||||
|
|
||||||
|
if cameras:
|
||||||
|
for camera in cameras:
|
||||||
|
icon = 'CHECKBOX_HLT' if camera.name in selected_cameras else 'CHECKBOX_DEHLT'
|
||||||
|
label = f'{camera.name} - {camera.data.lens:g}mm'
|
||||||
|
row = layout.row()
|
||||||
|
op = row.operator('zordon.toggle_camera', text=label, icon=icon, depress=camera.name in selected_cameras)
|
||||||
|
op.camera_name = camera.name
|
||||||
|
else:
|
||||||
|
layout.label(text='No cameras found')
|
||||||
|
|
||||||
|
layout.operator('zordon.submit_current_file', icon='RENDER_ANIMATION')
|
||||||
|
|
||||||
|
|
||||||
|
classes = (
|
||||||
|
ZORDON_AddonPreferences,
|
||||||
|
ZORDON_SubmissionProperties,
|
||||||
|
ZORDON_OT_DiscoverServers,
|
||||||
|
ZORDON_OT_TestConnection,
|
||||||
|
ZORDON_OT_RefreshCameras,
|
||||||
|
ZORDON_OT_ToggleCamera,
|
||||||
|
ZORDON_OT_SelectAllCameras,
|
||||||
|
ZORDON_OT_SelectActiveCamera,
|
||||||
|
ZORDON_OT_SubmitCurrentFile,
|
||||||
|
ZORDON_PT_SubmitPanel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for cls in classes:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
bpy.types.Scene.zordon_submission = bpy.props.PointerProperty(type=ZORDON_SubmissionProperties)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
del bpy.types.Scene.zordon_submission
|
||||||
|
for cls in reversed(classes):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
register()
|
||||||
+487
@@ -0,0 +1,487 @@
|
|||||||
|
# Zordon API Reference
|
||||||
|
|
||||||
|
Zordon exposes a Flask API from `src/api/api_server.py`. The server is started by
|
||||||
|
`start_api_server()` and listens on `Config.port_number` with all application
|
||||||
|
routes mounted under `/api`.
|
||||||
|
|
||||||
|
The in-repo client wrapper is `src/api/server_proxy.py`. Most UI and distributed
|
||||||
|
rendering code should prefer `RenderServerProxy` instead of constructing request
|
||||||
|
URLs directly.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- Current API version: `0.1`
|
||||||
|
- `RenderServerProxy.request()` sends `X-API-Version` with the current
|
||||||
|
`API_VERSION`, but the server does not currently validate this header.
|
||||||
|
|
||||||
|
## Response Conventions
|
||||||
|
|
||||||
|
- JSON endpoints return Flask-serialized dictionaries or lists.
|
||||||
|
- File endpoints return `send_file()` responses.
|
||||||
|
- Most error responses are plain text with HTTP `400`, `404`, `500`, or `503`.
|
||||||
|
- `JobNotFoundError` is mapped to HTTP `400`.
|
||||||
|
- Unhandled exceptions are mapped to HTTP `500` with a plain-text message.
|
||||||
|
|
||||||
|
## Jobs
|
||||||
|
|
||||||
|
### `GET /api/jobs`
|
||||||
|
|
||||||
|
Returns all render jobs and a cache token.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"id": "job-id",
|
||||||
|
"name": "job name",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"token": "cache-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_jobs()`
|
||||||
|
- `src/ui/main_window.py`
|
||||||
|
|
||||||
|
### `GET /api/jobs/long_poll`
|
||||||
|
|
||||||
|
Long-polls the job list until the supplied cache token changes or 30 seconds
|
||||||
|
elapse.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `token` | No | Cache token returned by `/api/jobs`. |
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200` with the same shape as `/api/jobs` when jobs changed.
|
||||||
|
- `204` with an empty body when no changes arrive before timeout.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_jobs()` through the background cache updater.
|
||||||
|
|
||||||
|
### `GET /api/jobs/status/<status_val>`
|
||||||
|
|
||||||
|
Returns jobs matching a render status.
|
||||||
|
|
||||||
|
Path parameters:
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `status_val` | Status string converted by `string_to_status()`. |
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200` with a list of job JSON objects when matches exist.
|
||||||
|
- `400` when no jobs match the requested status.
|
||||||
|
|
||||||
|
Review note: this route is not currently wrapped by `RenderServerProxy` and no
|
||||||
|
in-repo callers were found.
|
||||||
|
|
||||||
|
### `GET /api/jobs/<job_id>`
|
||||||
|
|
||||||
|
Returns one job as JSON.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_job()`
|
||||||
|
- `add_job.py`
|
||||||
|
- `src/ui/main_window.py`
|
||||||
|
- `src/distributed_job_manager.py`
|
||||||
|
- `tests/job_creation_tests.py`
|
||||||
|
|
||||||
|
### `GET /api/jobs/<job_id>/logs`
|
||||||
|
|
||||||
|
Returns the job log file as `text/plain`.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `src/ui/main_window.py` opens this URL directly.
|
||||||
|
|
||||||
|
### `GET /api/jobs/<job_id>/files`
|
||||||
|
|
||||||
|
Returns a list of output filenames for the job.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_job_files()`
|
||||||
|
- `src/utilities/server_helper.py`
|
||||||
|
|
||||||
|
### `GET /api/jobs/<job_id>/download`
|
||||||
|
|
||||||
|
Downloads one output file.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `filename` | Yes | Case-insensitive filename from the job file list. |
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200` with the requested file as an attachment.
|
||||||
|
- `400` when `filename` is missing.
|
||||||
|
- `404` when the file is not found.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.download_job_file()`
|
||||||
|
- `src/utilities/server_helper.py`
|
||||||
|
|
||||||
|
### `GET /api/jobs/<job_id>/download_all`
|
||||||
|
|
||||||
|
Creates a temporary zip of the job output directory and downloads it.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.download_all_job_files()`
|
||||||
|
- `src/ui/main_window.py`
|
||||||
|
- `src/utilities/server_helper.py`
|
||||||
|
|
||||||
|
### `GET /api/jobs/<job_id>/thumbnail`
|
||||||
|
|
||||||
|
Returns a generated preview image or video for a job.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `size` | No | `big` selects the larger preview path. Currently parsed but not applied. |
|
||||||
|
| `video_ok` | No | If truthy and a video preview exists, video can be returned. |
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200` with `image/jpeg` or `video/mp4`.
|
||||||
|
- `404` when no thumbnail is available.
|
||||||
|
- `500` on preview generation errors.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `src/ui/main_window.py`
|
||||||
|
|
||||||
|
Review note: `size=big` is parsed into `big_thumb` but not used.
|
||||||
|
|
||||||
|
## Job Lifecycle
|
||||||
|
|
||||||
|
### `POST /api/jobs`
|
||||||
|
|
||||||
|
Adds one or more render jobs.
|
||||||
|
|
||||||
|
Request formats:
|
||||||
|
|
||||||
|
- JSON request body.
|
||||||
|
- Multipart form with a `json` field and optional `file` upload.
|
||||||
|
|
||||||
|
Common job fields include:
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` | Display name for the render job. |
|
||||||
|
| `renderer` | Render engine name such as `blender` or `ffmpeg`. |
|
||||||
|
| `start_frame` | First frame to render. |
|
||||||
|
| `end_frame` | Last frame to render. |
|
||||||
|
| `args` | Engine-specific render arguments. |
|
||||||
|
| `enable_split_jobs` | Whether distributed subjobs may be created. |
|
||||||
|
| `child_jobs` | Optional subjob definitions. |
|
||||||
|
| `local_path` | Local file path used when posting to localhost. |
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
- `200` with created job data.
|
||||||
|
- `400` for invalid or missing job data.
|
||||||
|
- `500` for unexpected processing or creation errors.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.create_job()`
|
||||||
|
- `add_job.py`
|
||||||
|
- `src/ui/add_job_window.py`
|
||||||
|
- `src/distributed_job_manager.py`
|
||||||
|
- `tests/job_creation_tests.py`
|
||||||
|
|
||||||
|
### `POST /api/jobs/<job_id>/cancel`
|
||||||
|
|
||||||
|
Cancels a job.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `confirm` | Yes | Must be truthy or the request is rejected. |
|
||||||
|
| `redirect` | No | If truthy, redirects to `index`. |
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.cancel_job()`
|
||||||
|
- `src/ui/main_window.py`
|
||||||
|
- `src/distributed_job_manager.py`
|
||||||
|
|
||||||
|
### `POST /api/jobs/<job_id>/delete`
|
||||||
|
|
||||||
|
Deletes a job, stops it first, deletes previews, and removes owned upload/output
|
||||||
|
directories when safe.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `confirm` | Yes | Must be truthy or the request is rejected. |
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.delete_job()`
|
||||||
|
- `src/ui/main_window.py`
|
||||||
|
|
||||||
|
### `POST /api/jobs/<job_id>/subjob_update`
|
||||||
|
|
||||||
|
Notifies a parent job that a child/subjob changed state.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
- JSON representation of a subjob.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.send_subjob_update_notification()`
|
||||||
|
- `src/distributed_job_manager.py`
|
||||||
|
|
||||||
|
## Status and Environment
|
||||||
|
|
||||||
|
### `GET /api/heartbeat`
|
||||||
|
|
||||||
|
Returns the current timestamp as plain text. Used for fast connectivity checks.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.check_connection()`
|
||||||
|
|
||||||
|
### `GET /api/status`
|
||||||
|
|
||||||
|
Returns local system and queue status.
|
||||||
|
|
||||||
|
Response includes:
|
||||||
|
|
||||||
|
- timestamp
|
||||||
|
- operating system and version
|
||||||
|
- CPU brand, count, and current utilization
|
||||||
|
- memory totals and current utilization
|
||||||
|
- job counts
|
||||||
|
- hostname and port
|
||||||
|
- app and API versions
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_status()`
|
||||||
|
|
||||||
|
### `GET /api/presets`
|
||||||
|
|
||||||
|
Returns `config/presets.yaml`.
|
||||||
|
|
||||||
|
Review note: no in-repo callers were found.
|
||||||
|
|
||||||
|
### `GET /api/cpu_benchmark`
|
||||||
|
|
||||||
|
Runs a CPU benchmark for 10 seconds and returns the score as plain text.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `src/utilities/server_helper.py`
|
||||||
|
|
||||||
|
### `GET /api/disk_benchmark`
|
||||||
|
|
||||||
|
Runs a disk I/O benchmark and returns write/read speeds.
|
||||||
|
|
||||||
|
Review note: no in-repo callers were found.
|
||||||
|
|
||||||
|
## Engines
|
||||||
|
|
||||||
|
### `GET /api/engines/for_filename`
|
||||||
|
|
||||||
|
Returns the engine name suitable for a project filename.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `filename` | Yes | Project filename or path. The client currently sends only the basename. |
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_engine_for_filename()`
|
||||||
|
- `src/ui/add_job_window.py`
|
||||||
|
|
||||||
|
### `GET /api/engines`
|
||||||
|
|
||||||
|
Returns installed engine data keyed by engine name.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `response_type` | No | `standard` or `full`; defaults to `standard`. |
|
||||||
|
|
||||||
|
`full` responses also include supported extensions, supported export formats,
|
||||||
|
system info, and UI options.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_engines()`
|
||||||
|
- `src/ui/settings_window.py`
|
||||||
|
- `src/ui/engine_browser.py`
|
||||||
|
|
||||||
|
### `GET /api/engines/names`
|
||||||
|
|
||||||
|
Returns installed engine names as a list without instantiating engine classes.
|
||||||
|
Use this for lightweight selection UIs that only need engine names.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_engine_names()`
|
||||||
|
- `src/ui/add_job_window.py`
|
||||||
|
|
||||||
|
### `GET /api/engines/<engine_name>`
|
||||||
|
|
||||||
|
Returns installed version data for a single engine.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `response_type` | No | `standard` or `full`; defaults to `standard`. |
|
||||||
|
|
||||||
|
`full` responses also include supported extensions, supported export formats,
|
||||||
|
system info, and UI options.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_engine()`
|
||||||
|
- `src/ui/add_job_window.py`
|
||||||
|
|
||||||
|
### `GET /api/engines/<engine_name>/availability`
|
||||||
|
|
||||||
|
Returns whether an engine can accept jobs on this server, plus CPU count,
|
||||||
|
installed versions, and hostname.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.get_engine_availability()`
|
||||||
|
- `src/distributed_job_manager.py`
|
||||||
|
|
||||||
|
### `GET /api/engines/<engine_name>/args`
|
||||||
|
|
||||||
|
Returns engine arguments.
|
||||||
|
|
||||||
|
Review note: no in-repo callers were found.
|
||||||
|
|
||||||
|
### `GET /api/engines/<engine_name>/help`
|
||||||
|
|
||||||
|
Returns engine help text.
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `src/ui/add_job_window.py` opens this URL directly.
|
||||||
|
|
||||||
|
### `GET /api/engines/download_available`
|
||||||
|
|
||||||
|
Checks whether a managed engine version is available to download.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `engine` | Yes | Engine name. |
|
||||||
|
| `version` | Yes | Engine version. |
|
||||||
|
| `system_os` | No | Target OS. |
|
||||||
|
| `cpu` | No | Target CPU architecture. |
|
||||||
|
|
||||||
|
Review note: no in-repo callers were found.
|
||||||
|
|
||||||
|
### `GET /api/engines/most_recent_version`
|
||||||
|
|
||||||
|
Finds the most recent downloadable version for an engine.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `engine` | Yes | Engine name. |
|
||||||
|
| `system_os` | No | Target OS. |
|
||||||
|
| `cpu` | No | Target CPU architecture. |
|
||||||
|
|
||||||
|
Review note: no in-repo callers were found.
|
||||||
|
|
||||||
|
### `POST /api/engines/download`
|
||||||
|
|
||||||
|
Downloads a managed engine version.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `engine` | Yes | Engine name. |
|
||||||
|
| `version` | Yes | Engine version. |
|
||||||
|
| `system_os` | No | Target OS. |
|
||||||
|
| `cpu` | No | Target CPU architecture. |
|
||||||
|
|
||||||
|
Review note: no in-repo callers were found. Settings currently calls
|
||||||
|
`EngineManager.download_engine()` directly instead of this API route.
|
||||||
|
|
||||||
|
### `POST /api/engines/delete`
|
||||||
|
|
||||||
|
Deletes a managed engine download.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
|
||||||
|
| Name | Required | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `engine` | Yes | Engine name. |
|
||||||
|
| `version` | Yes | Engine version. |
|
||||||
|
| `system_os` | No | Target OS. |
|
||||||
|
| `cpu` | No | Target CPU architecture. |
|
||||||
|
|
||||||
|
Known callers:
|
||||||
|
|
||||||
|
- `RenderServerProxy.delete_engine_download()`
|
||||||
|
- `src/ui/engine_browser.py`
|
||||||
|
|
||||||
|
## Debug
|
||||||
|
|
||||||
|
### `GET /api/_debug/detected_clients`
|
||||||
|
|
||||||
|
Returns hostnames detected by Zeroconf.
|
||||||
|
|
||||||
|
Review note: development/debug only, with an inline comment saying it probably
|
||||||
|
should not ship.
|
||||||
|
|
||||||
|
### `POST /api/_debug/clear_history`
|
||||||
|
|
||||||
|
Clears render queue history and returns `success`.
|
||||||
|
|
||||||
|
Review note: development/debug only.
|
||||||
|
|
||||||
|
## Redundancy and Cleanup Review
|
||||||
|
|
||||||
|
Routes with no in-repo callers found:
|
||||||
|
|
||||||
|
- `GET /api/jobs/status/<status_val>`
|
||||||
|
- `GET /api/presets`
|
||||||
|
- `GET /api/disk_benchmark`
|
||||||
|
- `GET /api/engines/<engine_name>/args`
|
||||||
|
- `GET /api/engines/download_available`
|
||||||
|
- `GET /api/engines/most_recent_version`
|
||||||
|
- `POST /api/engines/download`
|
||||||
|
- `GET /api/_debug/detected_clients`
|
||||||
|
- `POST /api/_debug/clear_history`
|
||||||
|
|
||||||
|
Routes or methods with cleanup risks:
|
||||||
|
|
||||||
|
- `job_thumbnail()` parses `size=big` but never uses the resulting `big_thumb`
|
||||||
|
value.
|
||||||
+577
@@ -0,0 +1,577 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Zordon API Reference</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--surface: #161b22;
|
||||||
|
--surface2: #1c2333;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--blue: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--orange: #d29922;
|
||||||
|
--red: #f85149;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
--cyan: #39d2c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 56px 24px 36px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 2.7rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px minmax(0, 1fr);
|
||||||
|
gap: 32px;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 36px 24px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 18px;
|
||||||
|
align-self: start;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 5px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover { color: var(--blue); }
|
||||||
|
|
||||||
|
section { margin-bottom: 44px; }
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid var(--blue);
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--cyan);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, li { color: var(--text-muted); }
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
background: var(--surface2);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
color: var(--cyan);
|
||||||
|
font-size: .9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 12px 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
td { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel, .endpoint {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h3 { color: var(--text); }
|
||||||
|
.panel ul { margin: 8px 0 0; padding-left: 20px; }
|
||||||
|
|
||||||
|
.endpoint {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: .76rem;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.get { background: rgba(88, 166, 255, .16); color: var(--blue); border: 1px solid rgba(88, 166, 255, .35); }
|
||||||
|
.post { background: rgba(63, 185, 80, .16); color: var(--green); border: 1px solid rgba(63, 185, 80, .35); }
|
||||||
|
.multi { background: rgba(210, 153, 34, .16); color: var(--orange); border: 1px solid rgba(210, 153, 34, .35); }
|
||||||
|
|
||||||
|
.path {
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-weight: 650;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid var(--orange);
|
||||||
|
background: rgba(210, 153, 34, .08);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
border-left-color: var(--red);
|
||||||
|
background: rgba(248, 81, 73, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callers {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callers li { margin: 2px 0; }
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
main { grid-template-columns: 1fr; }
|
||||||
|
nav { position: static; }
|
||||||
|
header h1 { font-size: 2.1rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Zordon API Reference</h1>
|
||||||
|
<p>Flask endpoints exposed by <code>src/api/api_server.py</code>, with the in-repo client wrapper in <code>src/api/server_proxy.py</code>.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<nav aria-label="API sections">
|
||||||
|
<strong>Sections</strong>
|
||||||
|
<a href="#overview">Overview</a>
|
||||||
|
<a href="#jobs">Jobs</a>
|
||||||
|
<a href="#job-lifecycle">Job Lifecycle</a>
|
||||||
|
<a href="#status">Status and Environment</a>
|
||||||
|
<a href="#engines">Engines</a>
|
||||||
|
<a href="#debug">Debug</a>
|
||||||
|
<a href="#cleanup">Cleanup Review</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<section id="overview">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Versioning</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Current API version: <code>0.1</code></li>
|
||||||
|
<li><code>RenderServerProxy.request()</code> sends <code>X-API-Version</code>.</li>
|
||||||
|
<li>The server does not currently validate that header.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Response Conventions</h3>
|
||||||
|
<ul>
|
||||||
|
<li>JSON endpoints return Flask-serialized dictionaries or lists.</li>
|
||||||
|
<li>File endpoints return <code>send_file()</code> responses.</li>
|
||||||
|
<li>Most errors are plain text with <code>400</code>, <code>404</code>, <code>500</code>, or <code>503</code>.</li>
|
||||||
|
<li><code>JobNotFoundError</code> maps to HTTP <code>400</code>.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="jobs">
|
||||||
|
<h2>Jobs</h2>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs</span></div>
|
||||||
|
<p>Returns all render jobs and a cache token.</p>
|
||||||
|
<pre><code>{
|
||||||
|
"jobs": [{"id": "job-id", "name": "job name", "status": "running"}],
|
||||||
|
"token": "cache-token"
|
||||||
|
}</code></pre>
|
||||||
|
<p>Known callers:</p>
|
||||||
|
<ul class="callers">
|
||||||
|
<li><code>RenderServerProxy.get_jobs()</code></li>
|
||||||
|
<li><code>src/ui/main_window.py</code></li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/long_poll</span></div>
|
||||||
|
<p>Long-polls the job list until the supplied cache token changes or 30 seconds elapse.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>token</code></td><td>No</td><td>Cache token returned by <code>/api/jobs</code>.</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>Returns <code>200</code> with job data when changed, or <code>204</code> when no change arrives before timeout.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/status/<status_val></span></div>
|
||||||
|
<p>Returns jobs matching a render status converted by <code>string_to_status()</code>.</p>
|
||||||
|
<div class="note">No <code>RenderServerProxy</code> wrapper or in-repo caller was found.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id></span></div>
|
||||||
|
<p>Returns one job as JSON.</p>
|
||||||
|
<p>Known callers include <code>RenderServerProxy.get_job()</code>, <code>add_job.py</code>, <code>src/ui/main_window.py</code>, <code>src/distributed_job_manager.py</code>, and <code>tests/job_creation_tests.py</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/logs</span></div>
|
||||||
|
<p>Returns the job log file as <code>text/plain</code>. The main window opens this URL directly.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/files</span></div>
|
||||||
|
<p>Returns a list of output filenames for the job.</p>
|
||||||
|
<p>Known callers: <code>RenderServerProxy.get_job_files()</code> and <code>src/utilities/server_helper.py</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/download</span></div>
|
||||||
|
<p>Downloads one output file.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>filename</code></td><td>Yes</td><td>Case-insensitive filename from the job file list.</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>Returns <code>200</code> with an attachment, <code>400</code> when <code>filename</code> is missing, or <code>404</code> when the file is not found.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/download_all</span></div>
|
||||||
|
<p>Creates a temporary zip of the job output directory and downloads it.</p>
|
||||||
|
<p>Known callers: <code>RenderServerProxy.download_all_job_files()</code>, <code>src/ui/main_window.py</code>, and <code>src/utilities/server_helper.py</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/jobs/<job_id>/thumbnail</span></div>
|
||||||
|
<p>Returns a generated preview image or video for a job.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>size</code></td><td>No</td><td><code>big</code> is parsed but not currently applied.</td></tr>
|
||||||
|
<tr><td><code>video_ok</code></td><td>No</td><td>If truthy and a video preview exists, video can be returned.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">Cleanup note: <code>size=big</code> is parsed into <code>big_thumb</code> but not used.</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="job-lifecycle">
|
||||||
|
<h2>Job Lifecycle</h2>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/jobs</span></div>
|
||||||
|
<p>Adds one or more render jobs. Accepts either a JSON request body or multipart form data with a <code>json</code> field and optional <code>file</code> upload.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Common Field</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>name</code></td><td>Display name for the render job.</td></tr>
|
||||||
|
<tr><td><code>renderer</code></td><td>Render engine name such as <code>blender</code> or <code>ffmpeg</code>.</td></tr>
|
||||||
|
<tr><td><code>start_frame</code></td><td>First frame to render.</td></tr>
|
||||||
|
<tr><td><code>end_frame</code></td><td>Last frame to render.</td></tr>
|
||||||
|
<tr><td><code>args</code></td><td>Engine-specific render arguments.</td></tr>
|
||||||
|
<tr><td><code>enable_split_jobs</code></td><td>Whether distributed subjobs may be created.</td></tr>
|
||||||
|
<tr><td><code>child_jobs</code></td><td>Optional subjob definitions.</td></tr>
|
||||||
|
<tr><td><code>local_path</code></td><td>Local file path used when posting to localhost.</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>Known callers include <code>RenderServerProxy.create_job()</code>, <code>add_job.py</code>, <code>src/ui/add_job_window.py</code>, <code>src/distributed_job_manager.py</code>, and integration tests.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/jobs/<job_id>/cancel</span></div>
|
||||||
|
<p>Cancels a job. Requires a truthy <code>confirm</code> query parameter.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>confirm</code></td><td>Yes</td><td>Must be truthy or the request is rejected.</td></tr>
|
||||||
|
<tr><td><code>redirect</code></td><td>No</td><td>If truthy, redirects to <code>index</code>.</td></tr>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/jobs/<job_id>/delete</span></div>
|
||||||
|
<p>Deletes a job, stops it first, deletes previews, and removes owned upload/output directories when safe.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>confirm</code></td><td>Yes</td><td>Must be truthy or the request is rejected.</td></tr>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/jobs/<job_id>/subjob_update</span></div>
|
||||||
|
<p>Notifies a parent job that a child/subjob changed state. The request body is the JSON representation of the subjob.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="status">
|
||||||
|
<h2>Status and Environment</h2>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/heartbeat</span></div>
|
||||||
|
<p>Returns the current timestamp as plain text. Used by <code>RenderServerProxy.check_connection()</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/status</span></div>
|
||||||
|
<p>Returns local system and queue status, including operating system, CPU, memory, job counts, hostname, port, app version, and API version.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/presets</span></div>
|
||||||
|
<p>Returns <code>config/presets.yaml</code>.</p>
|
||||||
|
<div class="note">No in-repo callers were found.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/cpu_benchmark</span></div>
|
||||||
|
<p>Runs a CPU benchmark for 10 seconds and returns the score as plain text. Used by <code>src/utilities/server_helper.py</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/disk_benchmark</span></div>
|
||||||
|
<p>Runs a disk I/O benchmark and returns write/read speeds.</p>
|
||||||
|
<div class="note">No in-repo callers were found.</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="engines">
|
||||||
|
<h2>Engines</h2>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/for_filename</span></div>
|
||||||
|
<p>Returns the engine name suitable for a project filename.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>filename</code></td><td>Yes</td><td>Project filename or path. The client currently sends only the basename.</td></tr>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines</span></div>
|
||||||
|
<p>Returns installed engine data keyed by engine name.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>response_type</code></td><td>No</td><td><code>standard</code> or <code>full</code>; defaults to <code>standard</code>.</td></tr>
|
||||||
|
</table>
|
||||||
|
<p><code>full</code> responses include supported extensions, supported export formats, system info, and UI options.</p>
|
||||||
|
<p>Known callers: <code>RenderServerProxy.get_engines()</code>, <code>src/ui/settings_window.py</code>, and <code>src/ui/engine_browser.py</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/names</span></div>
|
||||||
|
<p>Returns installed engine names as a list without instantiating engine classes. Use this for lightweight selection UIs that only need engine names.</p>
|
||||||
|
<p>Known callers: <code>RenderServerProxy.get_engine_names()</code> and <code>src/ui/add_job_window.py</code>.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/<engine_name></span></div>
|
||||||
|
<p>Returns installed version data for a single engine.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>response_type</code></td><td>No</td><td><code>standard</code> or <code>full</code>; defaults to <code>standard</code>.</td></tr>
|
||||||
|
</table>
|
||||||
|
<p><code>full</code> responses include supported extensions, supported export formats, system info, and UI options.</p>
|
||||||
|
<p>Known caller: <code>RenderServerProxy.get_engine()</code> in the add-job window.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/<engine_name>/availability</span></div>
|
||||||
|
<p>Returns whether an engine can accept jobs on this server, plus CPU count, installed versions, and hostname.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/<engine_name>/args</span></div>
|
||||||
|
<p>Returns engine arguments.</p>
|
||||||
|
<div class="note">No in-repo callers were found.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/<engine_name>/help</span></div>
|
||||||
|
<p>Returns engine help text. The add-job window opens this URL directly.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/download_available</span></div>
|
||||||
|
<p>Checks whether a managed engine version is available to download.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>engine</code></td><td>Yes</td><td>Engine name.</td></tr>
|
||||||
|
<tr><td><code>version</code></td><td>Yes</td><td>Engine version.</td></tr>
|
||||||
|
<tr><td><code>system_os</code></td><td>No</td><td>Target OS.</td></tr>
|
||||||
|
<tr><td><code>cpu</code></td><td>No</td><td>Target CPU architecture.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">No in-repo callers were found.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/engines/most_recent_version</span></div>
|
||||||
|
<p>Finds the most recent downloadable version for an engine.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>engine</code></td><td>Yes</td><td>Engine name.</td></tr>
|
||||||
|
<tr><td><code>system_os</code></td><td>No</td><td>Target OS.</td></tr>
|
||||||
|
<tr><td><code>cpu</code></td><td>No</td><td>Target CPU architecture.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">No in-repo callers were found.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/engines/download</span></div>
|
||||||
|
<p>Downloads a managed engine version.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Query Parameter</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>engine</code></td><td>Yes</td><td>Engine name.</td></tr>
|
||||||
|
<tr><td><code>version</code></td><td>Yes</td><td>Engine version.</td></tr>
|
||||||
|
<tr><td><code>system_os</code></td><td>No</td><td>Target OS.</td></tr>
|
||||||
|
<tr><td><code>cpu</code></td><td>No</td><td>Target CPU architecture.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="note">Settings currently calls <code>EngineManager.download_engine()</code> directly instead of this API route.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/engines/delete</span></div>
|
||||||
|
<p>Deletes a managed engine download.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>JSON Field</th><th>Required</th><th>Description</th></tr>
|
||||||
|
<tr><td><code>engine</code></td><td>Yes</td><td>Engine name.</td></tr>
|
||||||
|
<tr><td><code>version</code></td><td>Yes</td><td>Engine version.</td></tr>
|
||||||
|
<tr><td><code>system_os</code></td><td>No</td><td>Target OS.</td></tr>
|
||||||
|
<tr><td><code>cpu</code></td><td>No</td><td>Target CPU architecture.</td></tr>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="debug">
|
||||||
|
<h2>Debug</h2>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method get">GET</span><span class="path">/api/_debug/detected_clients</span></div>
|
||||||
|
<p>Returns hostnames detected by Zeroconf.</p>
|
||||||
|
<div class="note">Development/debug only, with an inline comment saying it probably should not ship.</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="endpoint">
|
||||||
|
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/_debug/clear_history</span></div>
|
||||||
|
<p>Clears render queue history and returns <code>success</code>.</p>
|
||||||
|
<div class="note">Development/debug only.</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="cleanup">
|
||||||
|
<h2>Cleanup Review</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Routes With No In-Repo Callers Found</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>GET /api/jobs/status/<status_val></code></li>
|
||||||
|
<li><code>GET /api/presets</code></li>
|
||||||
|
<li><code>GET /api/disk_benchmark</code></li>
|
||||||
|
<li><code>GET /api/engines/<engine_name>/args</code></li>
|
||||||
|
<li><code>GET /api/engines/download_available</code></li>
|
||||||
|
<li><code>GET /api/engines/most_recent_version</code></li>
|
||||||
|
<li><code>POST /api/engines/download</code></li>
|
||||||
|
<li><code>GET /api/_debug/detected_clients</code></li>
|
||||||
|
<li><code>POST /api/_debug/clear_history</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top: 14px;">
|
||||||
|
<h3>Cleanup Risks</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>job_thumbnail()</code> parses <code>size=big</code> but never uses the resulting <code>big_thumb</code> value.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
norecursedirs = src/engines/aerender .git build dist *.egg venv .venv env .env __pycache__ .pytest_cache
|
norecursedirs = src/engines/aerender .git build dist *.egg venv .venv env .env __pycache__ .pytest_cache
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py job_creation_tests.py
|
||||||
@@ -11,6 +11,7 @@ from src.api.api_server import API_VERSION
|
|||||||
from src.api.api_server import start_api_server
|
from src.api.api_server import start_api_server
|
||||||
from src.api.preview_manager import PreviewManager
|
from src.api.preview_manager import PreviewManager
|
||||||
from src.api.serverproxy_manager import ServerProxyManager
|
from src.api.serverproxy_manager import ServerProxyManager
|
||||||
|
from src.application_context import ApplicationContext
|
||||||
from src.distributed_job_manager import DistributedJobManager
|
from src.distributed_job_manager import DistributedJobManager
|
||||||
from src.engines.engine_manager import EngineManager
|
from src.engines.engine_manager import EngineManager
|
||||||
from src.render_queue import RenderQueue
|
from src.render_queue import RenderQueue
|
||||||
@@ -26,21 +27,46 @@ logger = logging.getLogger()
|
|||||||
class ZordonServer:
|
class ZordonServer:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.ctx = ApplicationContext()
|
||||||
|
|
||||||
# setup logging
|
# setup logging
|
||||||
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
|
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
|
||||||
level=Config.server_log_level.upper())
|
level=Config.server_log_level.upper())
|
||||||
logging.getLogger("requests").setLevel(logging.WARNING) # suppress noisy requests/urllib3 logging
|
logging.getLogger("requests").setLevel(logging.WARNING) # suppress noisy requests/urllib3 logging
|
||||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||||
|
|
||||||
# Load Config YAML
|
# ---- Bootstrap Config ----
|
||||||
Config.setup_config_dir()
|
Config.setup_config_dir()
|
||||||
config_path = Path(Config.config_dir()) / "config.yaml"
|
config_path = Path(Config.config_dir()) / "config.yaml"
|
||||||
Config.load_config(config_path)
|
self.ctx.config = Config()
|
||||||
|
self.ctx.config.load(config_path)
|
||||||
|
Config._default_instance = self.ctx.config
|
||||||
|
Config._sync_class()
|
||||||
|
|
||||||
# configure default paths
|
# ---- Engine Manager ----
|
||||||
EngineManager.engines_path = str(Path(Config.upload_folder).expanduser()/ "engines")
|
self.ctx.engine_manager = EngineManager()
|
||||||
os.makedirs(EngineManager.engines_path, exist_ok=True)
|
self.ctx.engine_manager.engines_path = Path(Config.upload_folder).expanduser() / "engines"
|
||||||
PreviewManager.storage_path = Path(Config.upload_folder).expanduser() / "previews"
|
os.makedirs(self.ctx.engine_manager.engines_path, exist_ok=True)
|
||||||
|
EngineManager._default_instance = self.ctx.engine_manager
|
||||||
|
EngineManager._sync_class()
|
||||||
|
|
||||||
|
# ---- Preview Manager ----
|
||||||
|
self.ctx.preview_manager = PreviewManager()
|
||||||
|
self.ctx.preview_manager.storage_path = Path(Config.upload_folder).expanduser() / "previews"
|
||||||
|
PreviewManager._default_instance = self.ctx.preview_manager
|
||||||
|
PreviewManager._sync_class()
|
||||||
|
|
||||||
|
# ---- Render Queue ----
|
||||||
|
self.ctx.render_queue = RenderQueue()
|
||||||
|
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()
|
||||||
|
DistributedJobManager._default_instance = self.ctx.distributed_job_manager
|
||||||
|
DistributedJobManager._sync_class()
|
||||||
|
DistributedJobManager.subscribe_to_listener()
|
||||||
|
|
||||||
self.api_server = None
|
self.api_server = None
|
||||||
self.server_hostname: str = socket.gethostname()
|
self.server_hostname: str = socket.gethostname()
|
||||||
@@ -73,10 +99,8 @@ class ZordonServer:
|
|||||||
logger.debug(f"Upload directory: {Path(Config.upload_folder).expanduser()}")
|
logger.debug(f"Upload directory: {Path(Config.upload_folder).expanduser()}")
|
||||||
logger.debug(f"Thumbs directory: {PreviewManager.storage_path}")
|
logger.debug(f"Thumbs directory: {PreviewManager.storage_path}")
|
||||||
logger.debug(f"Engines directory: {EngineManager.engines_path}")
|
logger.debug(f"Engines directory: {EngineManager.engines_path}")
|
||||||
# Set up the RenderQueue object
|
|
||||||
RenderQueue.load_state(database_directory=Path(Config.upload_folder).expanduser())
|
|
||||||
ServerProxyManager.subscribe_to_listener()
|
ServerProxyManager.subscribe_to_listener()
|
||||||
DistributedJobManager.subscribe_to_listener()
|
|
||||||
|
|
||||||
# update hostname
|
# update hostname
|
||||||
self.server_hostname = socket.gethostname()
|
self.server_hostname = socket.gethostname()
|
||||||
@@ -87,16 +111,21 @@ class ZordonServer:
|
|||||||
self.api_server.start()
|
self.api_server.start()
|
||||||
|
|
||||||
# start zeroconf server
|
# start zeroconf server
|
||||||
ZeroconfServer.configure(f"_{APP_NAME.lower()}._tcp.local.", self.server_hostname, Config.port_number)
|
ctx = self.ctx
|
||||||
ZeroconfServer.properties = {'system_cpu': current_system_cpu(),
|
ctx.zeroconf_server = ZeroconfServer()
|
||||||
'system_cpu_brand': current_system_cpu_brand(),
|
ctx.zeroconf_server._configure(f"_{APP_NAME.lower()}._tcp.local.", self.server_hostname, Config.port_number)
|
||||||
'system_cpu_cores': multiprocessing.cpu_count(),
|
ctx.zeroconf_server.properties = {'system_cpu': current_system_cpu(),
|
||||||
'system_os': current_system_os(),
|
'system_cpu_brand': current_system_cpu_brand(),
|
||||||
'system_os_version': current_system_os_version(),
|
'system_cpu_cores': multiprocessing.cpu_count(),
|
||||||
'system_memory': round(psutil.virtual_memory().total / (1024**3)), # in GB
|
'system_os': current_system_os(),
|
||||||
'gpu_info': get_gpu_info(),
|
'system_os_version': current_system_os_version(),
|
||||||
'api_version': API_VERSION}
|
'system_memory': round(psutil.virtual_memory().total / (1024**3)),
|
||||||
ZeroconfServer.start()
|
'gpu_info': get_gpu_info(),
|
||||||
|
'api_version': API_VERSION}
|
||||||
|
ZeroconfServer._default_instance = ctx.zeroconf_server
|
||||||
|
ZeroconfServer._sync_class()
|
||||||
|
ctx.zeroconf_server._start()
|
||||||
|
|
||||||
logger.info(f"{APP_NAME} Render Server started - Hostname: {self.server_hostname}")
|
logger.info(f"{APP_NAME} Render Server started - Hostname: {self.server_hostname}")
|
||||||
RenderQueue.start() # Start evaluating the render queue
|
RenderQueue.start() # Start evaluating the render queue
|
||||||
|
|
||||||
@@ -112,6 +141,7 @@ class ZordonServer:
|
|||||||
logger.exception(f"Exception during prepare for shutdown: {e}")
|
logger.exception(f"Exception during prepare for shutdown: {e}")
|
||||||
logger.info(f"{APP_NAME} Render Server has shut down")
|
logger.info(f"{APP_NAME} Render Server has shut down")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
server = ZordonServer()
|
server = ZordonServer()
|
||||||
try:
|
try:
|
||||||
|
|||||||
+138
-142
@@ -17,6 +17,7 @@ import psutil
|
|||||||
import yaml
|
import yaml
|
||||||
from flask import Flask, request, send_file, after_this_request, Response, redirect, url_for
|
from flask import Flask, request, send_file, after_this_request, Response, redirect, url_for
|
||||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from src.api.job_import_handler import JobImportHandler
|
from src.api.job_import_handler import JobImportHandler
|
||||||
from src.api.preview_manager import PreviewManager
|
from src.api.preview_manager import PreviewManager
|
||||||
@@ -33,9 +34,9 @@ logger = logging.getLogger()
|
|||||||
server = Flask(__name__)
|
server = Flask(__name__)
|
||||||
ssl._create_default_https_context = ssl._create_unverified_context # disable SSL for downloads
|
ssl._create_default_https_context = ssl._create_unverified_context # disable SSL for downloads
|
||||||
|
|
||||||
API_VERSION = "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
|
# get hostname
|
||||||
if not hostname:
|
if not hostname:
|
||||||
@@ -53,9 +54,9 @@ def start_api_server(hostname: Optional[str] = None) -> None:
|
|||||||
flask_log = logging.getLogger('werkzeug')
|
flask_log = logging.getLogger('werkzeug')
|
||||||
flask_log.setLevel(Config.flask_log_level.upper())
|
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:
|
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)
|
threaded=True)
|
||||||
finally:
|
finally:
|
||||||
logger.debug('Stopping API server')
|
logger.debug('Stopping API server')
|
||||||
@@ -83,7 +84,7 @@ def jobs_json() -> Dict[str, Any]:
|
|||||||
return {'jobs': all_jobs, 'token': job_cache_token}
|
return {'jobs': all_jobs, 'token': job_cache_token}
|
||||||
|
|
||||||
|
|
||||||
@server.get('/api/jobs_long_poll')
|
@server.get('/api/jobs/long_poll')
|
||||||
def long_polling_jobs():
|
def long_polling_jobs():
|
||||||
hash_token = request.args.get('token', None)
|
hash_token = request.args.get('token', None)
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -97,7 +98,7 @@ def long_polling_jobs():
|
|||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
@server.get('/api/jobs/<status_val>')
|
@server.get('/api/jobs/status/<status_val>')
|
||||||
def filtered_jobs_json(status_val):
|
def filtered_jobs_json(status_val):
|
||||||
state = string_to_status(status_val)
|
state = string_to_status(status_val)
|
||||||
jobs = [x.json() for x in RenderQueue.jobs_with_status(state)]
|
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
|
# Job Details / File Handling
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@server.get('/api/job/<job_id>')
|
@server.get('/api/jobs/<job_id>')
|
||||||
def get_job_details(job_id):
|
def get_job_details(job_id):
|
||||||
"""Retrieves the details of a requested job in JSON format
|
"""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()
|
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):
|
def get_job_logs(job_id):
|
||||||
"""Retrieves the log file for a specific render job.
|
"""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')
|
return Response(log_data, mimetype='text/plain')
|
||||||
|
|
||||||
|
|
||||||
@server.get('/api/job/<job_id>/file_list')
|
@server.get('/api/jobs/<job_id>/files')
|
||||||
def get_file_list(job_id):
|
def get_job_files(job_id):
|
||||||
return [Path(p).name for p in RenderQueue.job_with_id(job_id).file_list()]
|
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):
|
def download_requested_file(job_id):
|
||||||
requested_filename = request.args.get("filename")
|
requested_filename = request.args.get("filename")
|
||||||
if not requested_filename:
|
if not requested_filename:
|
||||||
@@ -164,7 +165,7 @@ def download_requested_file(job_id):
|
|||||||
return f"File '{requested_filename}' not found", 404
|
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):
|
def download_all_files(job_id):
|
||||||
zip_filename = None
|
zip_filename = None
|
||||||
|
|
||||||
@@ -205,29 +206,6 @@ def presets() -> Dict[str, Any]:
|
|||||||
return loaded_presets
|
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')
|
@server.route('/api/status')
|
||||||
def status():
|
def status():
|
||||||
return {"timestamp": datetime.now().isoformat(),
|
return {"timestamp": datetime.now().isoformat(),
|
||||||
@@ -253,10 +231,10 @@ def status():
|
|||||||
# Job Lifecyle (Create, Cancel, Delete)
|
# Job Lifecyle (Create, Cancel, Delete)
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@server.post('/api/add_job')
|
@server.post('/api/jobs')
|
||||||
def add_job_handler():
|
def create_jobs_handler():
|
||||||
"""
|
"""
|
||||||
POST /api/add_job
|
POST /api/jobs
|
||||||
Add a render job to the queue.
|
Add a render job to the queue.
|
||||||
|
|
||||||
**Request Formats**
|
**Request Formats**
|
||||||
@@ -306,7 +284,7 @@ def add_job_handler():
|
|||||||
return 'unknown error', 500
|
return 'unknown error', 500
|
||||||
|
|
||||||
|
|
||||||
@server.get('/api/job/<job_id>/cancel')
|
@server.post('/api/jobs/<job_id>/cancel')
|
||||||
def cancel_job(job_id):
|
def cancel_job(job_id):
|
||||||
if not request.args.get('confirm', False):
|
if not request.args.get('confirm', False):
|
||||||
return 'Confirmation required to cancel job', 400
|
return 'Confirmation required to cancel job', 400
|
||||||
@@ -320,7 +298,7 @@ def cancel_job(job_id):
|
|||||||
return "Unknown error", 500
|
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):
|
def delete_job(job_id):
|
||||||
try:
|
try:
|
||||||
if not request.args.get("confirm", False):
|
if not request.args.get("confirm", False):
|
||||||
@@ -344,19 +322,11 @@ def delete_job(job_id):
|
|||||||
|
|
||||||
RenderQueue.delete_job(found_job)
|
RenderQueue.delete_job(found_job)
|
||||||
|
|
||||||
# Delete output directory if we own it
|
if _has_remaining_jobs_for_project(project_dir):
|
||||||
if output_dir.exists() and output_dir.is_relative_to(upload_root):
|
_delete_job_output(output_path, project_dir, upload_root)
|
||||||
shutil.rmtree(output_dir)
|
elif project_dir.exists() and project_dir.is_relative_to(upload_root):
|
||||||
|
logger.info(f"Removing project directory: {project_dir}")
|
||||||
# Delete project directory if we own it and it's unused
|
shutil.rmtree(project_dir)
|
||||||
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}")
|
|
||||||
|
|
||||||
return "Job deleted", 200
|
return "Job deleted", 200
|
||||||
|
|
||||||
@@ -365,11 +335,46 @@ def delete_job(job_id):
|
|||||||
return f"Error deleting job: {e}", 500
|
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:
|
# Engine Info and Management:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@server.get('/api/engine_for_filename')
|
@server.get('/api/engines/for_filename')
|
||||||
def get_engine_for_filename():
|
def get_engine_for_filename():
|
||||||
filename = request.args.get("filename")
|
filename = request.args.get("filename")
|
||||||
if not 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 f"Error: cannot find a suitable engine for '{filename}'", 400
|
||||||
return found_engine.name()
|
return found_engine.name()
|
||||||
|
|
||||||
@server.get('/api/installed_engines')
|
def _validated_engine_response_type():
|
||||||
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():
|
|
||||||
response_type = request.args.get('response_type', 'standard')
|
response_type = request.args.get('response_type', 'standard')
|
||||||
if response_type not in ['full', 'standard']:
|
if response_type not in ['full', 'standard']:
|
||||||
raise ValueError(f"Invalid response_type: {response_type}")
|
raise ValueError(f"Invalid response_type: {response_type}")
|
||||||
|
return 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
|
|
||||||
|
|
||||||
|
|
||||||
@server.get('/api/<engine_name>/info')
|
def _engine_info_for_engine(engine_class, response_type='standard'):
|
||||||
def get_engine_info(engine_name):
|
|
||||||
try:
|
try:
|
||||||
response_type = request.args.get('response_type', 'standard')
|
installed_versions = EngineManager.all_version_data_for_engine(engine_class.name())
|
||||||
# Get all installed versions of the engine
|
|
||||||
installed_versions = EngineManager.all_version_data_for_engine(engine_name)
|
|
||||||
if not installed_versions:
|
if not installed_versions:
|
||||||
return {}
|
return None
|
||||||
|
|
||||||
result = { 'is_available': RenderQueue.is_available_for_job(engine_name),
|
system_installed_versions = [v for v in installed_versions if v['type'] == 'system']
|
||||||
'versions': installed_versions
|
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':
|
if response_type == 'full':
|
||||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
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 = {
|
future_results = {
|
||||||
'supported_extensions': executor.submit(en.supported_extensions),
|
'supported_extensions': executor.submit(engine.supported_extensions),
|
||||||
'supported_export_formats': executor.submit(en.get_output_formats),
|
'supported_export_formats': executor.submit(engine.get_output_formats),
|
||||||
'system_info': executor.submit(en.system_info),
|
'system_info': executor.submit(engine.system_info),
|
||||||
'options': executor.submit(en.ui_options)
|
'options': executor.submit(engine.ui_options)
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, future in future_results.items():
|
for key, future in future_results.items():
|
||||||
@@ -473,32 +423,75 @@ def get_engine_info(engine_name):
|
|||||||
|
|
||||||
return result
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching details for engine '{engine_name}': {e}")
|
logger.error(f"Error fetching details for engine '{engine_name}': {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@server.get('/api/<engine_name>/is_available')
|
@server.get('/api/engines/<engine_name>/availability')
|
||||||
def is_engine_available(engine_name):
|
def get_engine_availability(engine_name):
|
||||||
return {'engine': engine_name, 'available': RenderQueue.is_available_for_job(engine_name),
|
return {'engine': engine_name, 'available': RenderQueue.is_available_for_job(engine_name),
|
||||||
'cpu_count': int(psutil.cpu_count(logical=False)),
|
'cpu_count': int(psutil.cpu_count(logical=False)),
|
||||||
'versions': EngineManager.all_version_data_for_engine(engine_name),
|
'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):
|
def get_engine_args(engine_name):
|
||||||
try:
|
try:
|
||||||
engine_class = EngineManager.engine_class_with_name(engine_name)
|
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()
|
return engine_class().get_arguments()
|
||||||
except LookupError:
|
except LookupError:
|
||||||
return f"Cannot find engine '{engine_name}'", 400
|
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):
|
def get_engine_help(engine_name):
|
||||||
try:
|
try:
|
||||||
engine_class = EngineManager.engine_class_with_name(engine_name)
|
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()
|
return engine_class().get_help()
|
||||||
except LookupError:
|
except LookupError:
|
||||||
return f"Cannot find engine '{engine_name}'", 400
|
return f"Cannot find engine '{engine_name}'", 400
|
||||||
@@ -507,7 +500,7 @@ def get_engine_help(engine_name):
|
|||||||
# Engine Downloads and Updates:
|
# Engine Downloads and Updates:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@server.get('/api/is_engine_available_to_download')
|
@server.get('/api/engines/download_available')
|
||||||
def is_engine_available_to_download():
|
def is_engine_available_to_download():
|
||||||
available_result = EngineManager.version_is_available_to_download(request.args.get('engine'),
|
available_result = EngineManager.version_is_available_to_download(request.args.get('engine'),
|
||||||
request.args.get('version'),
|
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)
|
(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():
|
def find_most_recent_version():
|
||||||
most_recent = EngineManager.find_most_recent_version(request.args.get('engine'),
|
most_recent = EngineManager.find_most_recent_version(request.args.get('engine'),
|
||||||
request.args.get('system_os'),
|
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)
|
(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():
|
def download_engine():
|
||||||
download_result = EngineManager.download_engine(request.args.get('engine'),
|
download_result = EngineManager.download_engine(request.args.get('engine'),
|
||||||
request.args.get('version'),
|
request.args.get('version'),
|
||||||
@@ -536,7 +529,7 @@ def download_engine():
|
|||||||
(f"Error downloading {request.args.get('engine')} {request.args.get('version')}", 500)
|
(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():
|
def delete_engine_download():
|
||||||
json_data = request.json
|
json_data = request.json
|
||||||
delete_result = EngineManager.delete_engine_download(json_data.get('engine'),
|
delete_result = EngineManager.delete_engine_download(json_data.get('engine'),
|
||||||
@@ -554,14 +547,14 @@ def delete_engine_download():
|
|||||||
def heartbeat():
|
def heartbeat():
|
||||||
return datetime.now().isoformat(), 200
|
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):
|
def subjob_update_notification(job_id):
|
||||||
subjob_details = request.json
|
subjob_details = request.json
|
||||||
DistributedJobManager.handle_subjob_update_notification(RenderQueue.job_with_id(job_id), subjob_data=subjob_details)
|
DistributedJobManager.handle_subjob_update_notification(RenderQueue.job_with_id(job_id), subjob_data=subjob_details)
|
||||||
return Response(status=200)
|
return Response(status=200)
|
||||||
|
|
||||||
|
|
||||||
@server.route('/api/job/<job_id>/thumbnail')
|
@server.route('/api/jobs/<job_id>/thumbnail')
|
||||||
def job_thumbnail(job_id):
|
def job_thumbnail(job_id):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -632,6 +625,9 @@ def handle_404(error):
|
|||||||
|
|
||||||
@server.errorhandler(Exception)
|
@server.errorhandler(Exception)
|
||||||
def handle_general_error(general_error):
|
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__)
|
traceback.print_exception(type(general_error), general_error, general_error.__traceback__)
|
||||||
err_msg = f"Server error: {general_error}"
|
err_msg = f"Server error: {general_error}"
|
||||||
logger.error(err_msg)
|
logger.error(err_msg)
|
||||||
@@ -649,7 +645,7 @@ def detected_clients():
|
|||||||
return ZeroconfServer.found_hostnames()
|
return ZeroconfServer.found_hostnames()
|
||||||
|
|
||||||
|
|
||||||
@server.get('/api/_debug/clear_history')
|
@server.post('/api/_debug/clear_history')
|
||||||
def clear_history():
|
def clear_history():
|
||||||
RenderQueue.clear_history()
|
RenderQueue.clear_history()
|
||||||
return 'success'
|
return 'success'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import requests
|
|||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from distributed_job_manager import DistributedJobManager
|
from src.distributed_job_manager import DistributedJobManager
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ class JobImportHandler:
|
|||||||
processed_child_job_data = processed_job_data.copy()
|
processed_child_job_data = processed_job_data.copy()
|
||||||
processed_child_job_data.pop("child_jobs")
|
processed_child_job_data.pop("child_jobs")
|
||||||
processed_child_job_data.update(child_job_diffs)
|
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)
|
job_data_to_create.append(processed_child_job_data)
|
||||||
else:
|
else:
|
||||||
job_data_to_create.append(processed_job_data)
|
job_data_to_create.append(processed_job_data)
|
||||||
|
|||||||
+41
-46
@@ -3,6 +3,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from src.utilities.ffmpeg_helper import generate_thumbnail, save_first_frame
|
from src.utilities.ffmpeg_helper import generate_thumbnail, save_first_frame
|
||||||
|
|
||||||
@@ -12,20 +13,20 @@ supported_image_formats = ['.jpg', '.png', '.exr', '.tif', '.tga', '.bmp', '.web
|
|||||||
|
|
||||||
|
|
||||||
class PreviewManager:
|
class PreviewManager:
|
||||||
"""Manages generation, storage, and retrieval of preview images and videos for rendering jobs."""
|
_default_instance: Optional['PreviewManager'] = None
|
||||||
|
|
||||||
storage_path = None
|
storage_path: Optional[str] = None
|
||||||
_running_jobs = {}
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.storage_path = None
|
||||||
|
self._running_jobs: Dict = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __generate_job_preview_worker(cls, job, replace_existing=False, max_width=480):
|
def _sync_class(cls) -> None:
|
||||||
"""Generates image and video previews for a given job.
|
if cls._default_instance is not None:
|
||||||
|
cls.storage_path = cls._default_instance.storage_path
|
||||||
|
|
||||||
Args:
|
def _generate_job_preview_worker(self, job, replace_existing=False, max_width=480):
|
||||||
job: The job object containing file information.
|
|
||||||
replace_existing (bool): Whether to replace existing previews. Defaults to False.
|
|
||||||
max_width (int): Maximum width for the preview images/videos. Defaults to 480.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Determine best source file to use for thumbs
|
# Determine best source file to use for thumbs
|
||||||
job_file_list = job.file_list()
|
job_file_list = job.file_list()
|
||||||
@@ -41,8 +42,8 @@ class PreviewManager:
|
|||||||
logger.warning(f"No valid image or video files found in files from job: {job}")
|
logger.warning(f"No valid image or video files found in files from job: {job}")
|
||||||
return
|
return
|
||||||
|
|
||||||
os.makedirs(cls.storage_path, exist_ok=True)
|
os.makedirs(self.storage_path, exist_ok=True)
|
||||||
base_path = os.path.join(cls.storage_path, f"{job.id}-{preview_label}-{max_width}")
|
base_path = os.path.join(self.storage_path, f"{job.id}-{preview_label}-{max_width}")
|
||||||
preview_video_path = base_path + '.mp4'
|
preview_video_path = base_path + '.mp4'
|
||||||
preview_image_path = base_path + '.jpg'
|
preview_image_path = base_path + '.jpg'
|
||||||
|
|
||||||
@@ -73,41 +74,23 @@ class PreviewManager:
|
|||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error(f"Error generating video preview for {job}: {e}")
|
logger.error(f"Error generating video preview for {job}: {e}")
|
||||||
|
|
||||||
@classmethod
|
def _update_previews_for_job(self, job, replace_existing=False, wait_until_completion=False, timeout=None):
|
||||||
def update_previews_for_job(cls, job, replace_existing=False, wait_until_completion=False, timeout=None):
|
job_thread = self._running_jobs.get(job.id)
|
||||||
"""Updates previews for a given job by starting a background thread.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job: The job object.
|
|
||||||
replace_existing (bool): Whether to replace existing previews. Defaults to False.
|
|
||||||
wait_until_completion (bool): Whether to wait for the thread to complete. Defaults to False.
|
|
||||||
timeout (float): Timeout for waiting, if applicable.
|
|
||||||
"""
|
|
||||||
job_thread = cls._running_jobs.get(job.id)
|
|
||||||
if job_thread and job_thread.is_alive():
|
if job_thread and job_thread.is_alive():
|
||||||
logger.debug(f'Preview generation job already running for {job}')
|
logger.debug(f'Preview generation job already running for {job}')
|
||||||
return
|
else:
|
||||||
|
job_thread = threading.Thread(target=self._generate_job_preview_worker, args=(job, replace_existing,))
|
||||||
job_thread = threading.Thread(target=cls.__generate_job_preview_worker, args=(job, replace_existing,))
|
job_thread.start()
|
||||||
job_thread.start()
|
self._running_jobs[job.id] = job_thread
|
||||||
cls._running_jobs[job.id] = job_thread
|
|
||||||
|
|
||||||
if wait_until_completion:
|
if wait_until_completion:
|
||||||
job_thread.join(timeout=timeout)
|
job_thread.join(timeout=timeout)
|
||||||
|
|
||||||
@classmethod
|
def _get_previews_for_job(self, job):
|
||||||
def get_previews_for_job(cls, job):
|
|
||||||
"""Retrieves previews for a given job.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job: The job object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: A dictionary containing preview information.
|
|
||||||
"""
|
|
||||||
results = {}
|
results = {}
|
||||||
try:
|
try:
|
||||||
directory_path = Path(cls.storage_path)
|
directory_path = Path(self.storage_path)
|
||||||
preview_files_for_job = [f for f in directory_path.iterdir() if f.is_file() and f.name.startswith(job.id)]
|
preview_files_for_job = [f for f in directory_path.iterdir() if f.is_file() and f.name.startswith(job.id)]
|
||||||
|
|
||||||
for preview_filename in preview_files_for_job:
|
for preview_filename in preview_files_for_job:
|
||||||
@@ -125,14 +108,8 @@ class PreviewManager:
|
|||||||
pass
|
pass
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@classmethod
|
def _delete_previews_for_job(self, job):
|
||||||
def delete_previews_for_job(cls, job):
|
all_previews = self.get_previews_for_job(job)
|
||||||
"""Deletes all previews associated with a given job.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job: The job object.
|
|
||||||
"""
|
|
||||||
all_previews = cls.get_previews_for_job(job)
|
|
||||||
flattened_list = [item for sublist in all_previews.values() for item in sublist]
|
flattened_list = [item for sublist in all_previews.values() for item in sublist]
|
||||||
for preview in flattened_list:
|
for preview in flattened_list:
|
||||||
try:
|
try:
|
||||||
@@ -140,3 +117,21 @@ class PreviewManager:
|
|||||||
os.remove(preview['filename'])
|
os.remove(preview['filename'])
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(f"Error removing preview '{preview.get('filename')}': {e}")
|
logger.error(f"Error removing preview '{preview.get('filename')}': {e}")
|
||||||
|
|
||||||
|
# --- Forwarders for backward compatibility ---
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_previews_for_job(cls, job, replace_existing=False, wait_until_completion=False, timeout=None):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._update_previews_for_job(job, replace_existing, wait_until_completion, timeout)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_previews_for_job(cls, job):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._get_previews_for_job(job)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_previews_for_job(cls, job):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._delete_previews_for_job(job)
|
||||||
|
|||||||
+56
-43
@@ -22,6 +22,8 @@ categories = [RenderStatus.RUNNING, RenderStatus.WAITING_FOR_SUBJOBS, RenderStat
|
|||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
OFFLINE_MAX = 4
|
OFFLINE_MAX = 4
|
||||||
|
JOB_UPLOAD_TIMEOUT = (10, 1800)
|
||||||
|
FILE_DOWNLOAD_TIMEOUT = (10, 1800)
|
||||||
|
|
||||||
|
|
||||||
class RenderServerProxy:
|
class RenderServerProxy:
|
||||||
@@ -109,6 +111,11 @@ class RenderServerProxy:
|
|||||||
return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout,
|
return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout,
|
||||||
headers={"X-API-Version": str(API_VERSION)})
|
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:
|
# Background Updates:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
@@ -134,7 +141,7 @@ class RenderServerProxy:
|
|||||||
if self.__offline_flags: # if we're offline, don't bother with the long poll
|
if self.__offline_flags: # if we're offline, don't bother with the long poll
|
||||||
ignore_token = True
|
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'
|
not ignore_token) else 'jobs'
|
||||||
status_result = self.request_data(url, timeout=timeout)
|
status_result = self.request_data(url, timeout=timeout)
|
||||||
if status_result is not None:
|
if status_result is not None:
|
||||||
@@ -153,13 +160,10 @@ class RenderServerProxy:
|
|||||||
# Get System Info:
|
# 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:
|
if not self.__update_in_background or ignore_token:
|
||||||
self.__update_job_cache(timeout, ignore_token)
|
self.__update_job_cache(timeout, ignore_token)
|
||||||
return self.__jobs_cache.copy() if self.__jobs_cache else None
|
return self.__jobs_cache.copy()
|
||||||
|
|
||||||
def get_data(self, timeout=5):
|
|
||||||
return self.request_data('full_status', timeout=timeout)
|
|
||||||
|
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
status = self.request_data('status')
|
status = self.request_data('status')
|
||||||
@@ -175,17 +179,17 @@ class RenderServerProxy:
|
|||||||
# Get Job Info:
|
# Get Job Info:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
def get_job_info(self, job_id, timeout=5):
|
def get_job(self, job_id, timeout=5):
|
||||||
return self.request_data(f'job/{job_id}', timeout=timeout)
|
return self.request_data(f'jobs/{job_id}', timeout=timeout)
|
||||||
|
|
||||||
def get_job_files_list(self, job_id):
|
def get_job_files(self, job_id):
|
||||||
return self.request_data(f"job/{job_id}/file_list")
|
return self.request_data(f'jobs/{job_id}/files')
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Job Lifecycle:
|
# 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.
|
Posts a job to the server.
|
||||||
|
|
||||||
@@ -193,6 +197,8 @@ class RenderServerProxy:
|
|||||||
file_path (Path): The path to the file to upload.
|
file_path (Path): The path to the file to upload.
|
||||||
job_data (dict): A dict of jobs data.
|
job_data (dict): A dict of jobs data.
|
||||||
callback (function, optional): A callback function to call during the upload. Defaults to None.
|
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:
|
Returns:
|
||||||
Response: The response from the server.
|
Response: The response from the server.
|
||||||
@@ -204,9 +210,9 @@ class RenderServerProxy:
|
|||||||
# Bypass uploading file if posting to localhost
|
# Bypass uploading file if posting to localhost
|
||||||
if self.is_localhost:
|
if self.is_localhost:
|
||||||
job_data['local_path'] = str(file_path)
|
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'}
|
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
|
# Prepare the form data for remote host
|
||||||
with open(file_path, 'rb') as file:
|
with open(file_path, 'rb') as file:
|
||||||
@@ -218,19 +224,25 @@ class RenderServerProxy:
|
|||||||
# Create a monitor that will track the upload progress
|
# Create a monitor that will track the upload progress
|
||||||
monitor = MultipartEncoderMonitor(encoder, callback) if callback else MultipartEncoderMonitor(encoder)
|
monitor = MultipartEncoderMonitor(encoder, callback) if callback else MultipartEncoderMonitor(encoder)
|
||||||
headers = {'Content-Type': monitor.content_type}
|
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
|
# 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
|
return response
|
||||||
|
|
||||||
def cancel_job(self, job_id, confirm=False):
|
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):
|
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.
|
Notifies the parent job of an update in a subjob.
|
||||||
|
|
||||||
@@ -241,26 +253,26 @@ class RenderServerProxy:
|
|||||||
Returns:
|
Returns:
|
||||||
Response: The response from the server.
|
Response: The response from the server.
|
||||||
"""
|
"""
|
||||||
return requests.post(f'http://{self.hostname}:{self.port}/api/job/{parent_id}/send_subjob_update_notification',
|
return requests.post(f'http://{self.hostname}:{self.port}/api/jobs/{parent_id}/subjob_update',
|
||||||
json=subjob.json())
|
json=subjob.json(), timeout=timeout)
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Engines:
|
# Engines:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
def get_engine_for_filename(self, filename:str, timeout=5):
|
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
|
return response.text
|
||||||
|
|
||||||
def get_installed_engines(self, timeout=5):
|
def get_engine_availability(self, engine_name:str, timeout=5):
|
||||||
return self.request_data(f'installed_engines', timeout)
|
return self.request_data(f'engines/{engine_name}/availability', timeout)
|
||||||
|
|
||||||
def is_engine_available(self, engine_name:str, timeout=5):
|
def get_engine_names(self, timeout=5):
|
||||||
return self.request_data(f'{engine_name}/is_available', timeout)
|
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:
|
Args:
|
||||||
response_type (str, optional): Returns standard or full version of engine info
|
response_type (str, optional): Returns standard or full version of engine info
|
||||||
@@ -269,10 +281,10 @@ class RenderServerProxy:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary containing the engine information.
|
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
|
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.
|
Fetches specific engine information from the server.
|
||||||
|
|
||||||
@@ -284,38 +296,39 @@ class RenderServerProxy:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary containing the engine information.
|
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:
|
Args:
|
||||||
engine_name (str): The name of the engine to delete.
|
engine_name (str): The name of the engine to delete.
|
||||||
version (str): The version 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:
|
Returns:
|
||||||
Response: The response from the server.
|
Response: The response from the server.
|
||||||
"""
|
"""
|
||||||
form_data = {'engine': engine_name, 'version': version, 'system_cpu': system_cpu}
|
form_data = {'engine': engine_name, 'version': version, 'system_os': system_os, 'cpu': cpu}
|
||||||
return requests.post(f'http://{self.hostname}:{self.port}/api/delete_engine', json=form_data)
|
return self._post('engines/delete', json=form_data)
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Download Files:
|
# Download Files:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
def download_all_job_files(self, job_id, save_path):
|
def download_all_job_files(self, job_id, save_path, timeout=FILE_DOWNLOAD_TIMEOUT):
|
||||||
url = f"http://{self.hostname}:{self.port}/api/job/{job_id}/download_all"
|
url = f'http://{self.hostname}:{self.port}/api/jobs/{job_id}/download_all'
|
||||||
return self.__download_file_from_url(url, output_filepath=save_path)
|
return self.__download_file_from_url(url, output_filepath=save_path, timeout=timeout)
|
||||||
|
|
||||||
def download_job_file(self, job_id, job_filename, 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/job/{job_id}/download?filename={job_filename}"
|
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)
|
return self.__download_file_from_url(url, output_filepath=save_path, timeout=timeout)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __download_file_from_url(url, output_filepath):
|
def __download_file_from_url(url, output_filepath, timeout=FILE_DOWNLOAD_TIMEOUT):
|
||||||
with requests.get(url, stream=True) as r:
|
with requests.get(url, stream=True, timeout=timeout) as r:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
with open(output_filepath, 'wb') as f:
|
with open(output_filepath, 'wb') as f:
|
||||||
for chunk in r.iter_content(chunk_size=8192):
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.api.preview_manager import PreviewManager
|
||||||
|
from src.distributed_job_manager import DistributedJobManager
|
||||||
|
from src.engines.engine_manager import EngineManager
|
||||||
|
from src.render_queue import RenderQueue
|
||||||
|
from src.utilities.config import Config
|
||||||
|
from src.utilities.zeroconf_server import ZeroconfServer
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationContext:
|
||||||
|
"""Holds all service instances. Single source of truth for wiring."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.config: Optional[Config] = None
|
||||||
|
self.engine_manager: Optional[EngineManager] = None
|
||||||
|
self.preview_manager: Optional[PreviewManager] = None
|
||||||
|
self.zeroconf_server: Optional[ZeroconfServer] = None
|
||||||
|
self.render_queue: Optional[RenderQueue] = None
|
||||||
|
self.distributed_job_manager: Optional[DistributedJobManager] = None
|
||||||
+78
-149
@@ -3,8 +3,9 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from click import Path
|
|
||||||
from plyer import notification
|
from plyer import notification
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
|
|
||||||
@@ -21,47 +22,32 @@ logger = logging.getLogger()
|
|||||||
|
|
||||||
|
|
||||||
class DistributedJobManager:
|
class DistributedJobManager:
|
||||||
|
_default_instance: Optional['DistributedJobManager'] = None
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def subscribe_to_listener(cls):
|
def _sync_class(cls) -> None:
|
||||||
"""
|
if cls._default_instance is not None:
|
||||||
Subscribes the private class method '__local_job_status_changed' to the 'status_change' pubsub message.
|
pass # no class-level attributes to sync
|
||||||
This should be called once, typically during the initialization phase.
|
|
||||||
"""
|
|
||||||
pub.subscribe(cls.__local_job_status_changed, 'status_change')
|
|
||||||
pub.subscribe(cls.__local_job_frame_complete, 'frame_complete')
|
|
||||||
|
|
||||||
@classmethod
|
def __init__(self) -> None:
|
||||||
def __local_job_frame_complete(cls, job_id, frame_number, update_interval=5):
|
self.background_worker: Optional[threading.Thread] = None
|
||||||
|
|
||||||
"""
|
def _subscribe_to_listener(self) -> None:
|
||||||
Responds to the 'frame_complete' pubsub message for local jobs.
|
pub.subscribe(self._local_job_status_changed, 'status_change')
|
||||||
|
pub.subscribe(self._local_job_frame_complete, 'frame_complete')
|
||||||
Args:
|
|
||||||
job_id (str): The ID of the job that has changed status.
|
|
||||||
old_status (str): The previous status of the job.
|
|
||||||
new_status (str): The new (current) status of the job.
|
|
||||||
|
|
||||||
Note: Do not call directly. Instead, call via the 'frame_complete' pubsub message.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
def _local_job_frame_complete(self, job_id, frame_number, update_interval=5) -> None:
|
||||||
render_job = RenderQueue.job_with_id(job_id, none_ok=True)
|
render_job = RenderQueue.job_with_id(job_id, none_ok=True)
|
||||||
if not render_job: # ignore jobs not in the queue
|
if not render_job:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f"Job {job_id} has completed frame #{frame_number}")
|
logger.debug(f"Job {job_id} has completed frame #{frame_number}")
|
||||||
replace_existing_previews = (frame_number % update_interval) == 0
|
replace_existing_previews = (frame_number % update_interval) == 0
|
||||||
cls.__job_update_shared(render_job, replace_existing_previews)
|
self._job_update_shared(render_job, replace_existing_previews)
|
||||||
|
|
||||||
@classmethod
|
def _job_update_shared(self, render_job, replace_existing_previews=False) -> None:
|
||||||
def __job_update_shared(cls, render_job, replace_existing_previews=False):
|
|
||||||
# update previews
|
|
||||||
PreviewManager.update_previews_for_job(job=render_job, replace_existing=replace_existing_previews)
|
PreviewManager.update_previews_for_job(job=render_job, replace_existing=replace_existing_previews)
|
||||||
|
|
||||||
# notify parent to allow individual frames to be copied instead of waiting until the end
|
|
||||||
if render_job.parent:
|
if render_job.parent:
|
||||||
parent_id, parent_hostname = render_job.parent.split('@')[0], render_job.parent.split('@')[-1]
|
parent_id, parent_hostname = render_job.parent.split('@')[0], render_job.parent.split('@')[-1]
|
||||||
try:
|
try:
|
||||||
@@ -70,57 +56,41 @@ class DistributedJobManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error notifying parent {parent_hostname} about update in subjob {render_job.id}: {e}")
|
logger.error(f"Error notifying parent {parent_hostname} about update in subjob {render_job.id}: {e}")
|
||||||
|
|
||||||
@classmethod
|
def _local_job_status_changed(self, job_id: str, old_status: str, new_status: str) -> None:
|
||||||
def __local_job_status_changed(cls, job_id: str, old_status: str, new_status: str):
|
|
||||||
"""
|
|
||||||
Responds to the 'status_change' pubsub message for local jobs.
|
|
||||||
If it's a child job, it notifies the parent job about the status change.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id (str): The ID of the job that has changed status.
|
|
||||||
old_status (str): The previous status of the job.
|
|
||||||
new_status (str): The new (current) status of the job.
|
|
||||||
|
|
||||||
Note: Do not call directly. Instead, call via the 'status_change' pubsub message.
|
|
||||||
"""
|
|
||||||
|
|
||||||
render_job = RenderQueue.job_with_id(job_id, none_ok=True)
|
render_job = RenderQueue.job_with_id(job_id, none_ok=True)
|
||||||
if not render_job: # ignore jobs created but not yet added to queue
|
if not render_job:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug(f"Job {job_id} status change: {old_status} -> {new_status}")
|
logger.debug(f"Job {job_id} status change: {old_status} -> {new_status}")
|
||||||
|
self._job_update_shared(render_job, replace_existing_previews=(render_job.status == RenderStatus.COMPLETED))
|
||||||
|
|
||||||
cls.__job_update_shared(render_job, replace_existing_previews=(render_job.status == RenderStatus.COMPLETED))
|
|
||||||
|
|
||||||
# Handle children
|
|
||||||
if render_job.children:
|
if render_job.children:
|
||||||
if new_status in [RenderStatus.CANCELLED, RenderStatus.ERROR]: # Cancel children if necessary
|
if new_status in (RenderStatus.CANCELLED, RenderStatus.ERROR):
|
||||||
for child in render_job.children:
|
for child in render_job.children:
|
||||||
child_id, child_hostname = child.split('@')
|
child_id, child_hostname = child.split('@')
|
||||||
RenderServerProxy(child_hostname).cancel_job(child_id, confirm=True)
|
RenderServerProxy(child_hostname).cancel_job(child_id, confirm=True)
|
||||||
|
|
||||||
# UI Notifications
|
|
||||||
try:
|
try:
|
||||||
if new_status == RenderStatus.COMPLETED:
|
if new_status == RenderStatus.COMPLETED:
|
||||||
logger.debug("Show render complete notification")
|
logger.debug("Show render complete notification")
|
||||||
notification.notify(
|
notification.notify(
|
||||||
title='Render Job Complete',
|
title='Render Job Complete',
|
||||||
message=f'{render_job.name} completed succesfully',
|
message=f'{render_job.name} completed succesfully',
|
||||||
timeout=10 # Display time in seconds
|
timeout=10
|
||||||
)
|
)
|
||||||
elif new_status == RenderStatus.ERROR:
|
elif new_status == RenderStatus.ERROR:
|
||||||
logger.debug("Show render error notification")
|
logger.debug("Show render error notification")
|
||||||
notification.notify(
|
notification.notify(
|
||||||
title='Render Job Failed',
|
title='Render Job Failed',
|
||||||
message=f'{render_job.name} failed rendering',
|
message=f'{render_job.name} failed rendering',
|
||||||
timeout=10 # Display time in seconds
|
timeout=10
|
||||||
)
|
)
|
||||||
elif new_status == RenderStatus.RUNNING:
|
elif new_status == RenderStatus.RUNNING:
|
||||||
logger.debug("Show render started notification")
|
logger.debug("Show render started notification")
|
||||||
notification.notify(
|
notification.notify(
|
||||||
title='Render Job Started',
|
title='Render Job Started',
|
||||||
message=f'{render_job.name} started rendering',
|
message=f'{render_job.name} started rendering',
|
||||||
timeout=10 # Display time in seconds
|
timeout=10
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Unable to show UI notification: {e}")
|
logger.debug(f"Unable to show UI notification: {e}")
|
||||||
@@ -129,30 +99,17 @@ class DistributedJobManager:
|
|||||||
# Create Job
|
# Create Job
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
def _create_render_job(self, new_job_attributes: dict, loaded_project_local_path: Path):
|
||||||
def create_render_job(cls, new_job_attributes: dict, loaded_project_local_path: Path):
|
requested_output_path = new_job_attributes.get('output_path')
|
||||||
"""Creates render jobs. Pass in dict of job_data and the local path to the project. It creates and returns a new
|
output_filename = Path(str(requested_output_path)).name if requested_output_path else loaded_project_local_path.stem
|
||||||
render job.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
new_job_attributes (dict): Dict of desired attributes for new job (frame count, renderer, output path, etc)
|
|
||||||
loaded_project_local_path (Path): The local path to the loaded project.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
worker: Created job worker
|
|
||||||
"""
|
|
||||||
|
|
||||||
# get new output path in output_dir
|
|
||||||
output_path = new_job_attributes.get('output_path')
|
|
||||||
output_filename = loaded_project_local_path.name if output_path else loaded_project_local_path.stem
|
|
||||||
|
|
||||||
# Prepare output path
|
|
||||||
output_dir = loaded_project_local_path.parent.parent / "output"
|
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
|
output_path = output_dir / output_filename
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
logger.debug(f"New job output path: {output_path}")
|
logger.debug(f"New job output path: {output_path}")
|
||||||
|
|
||||||
# create & configure jobs
|
|
||||||
worker = EngineManager.create_worker(engine_name=new_job_attributes['engine_name'],
|
worker = EngineManager.create_worker(engine_name=new_job_attributes['engine_name'],
|
||||||
input_path=loaded_project_local_path,
|
input_path=loaded_project_local_path,
|
||||||
output_path=output_path,
|
output_path=output_path,
|
||||||
@@ -160,16 +117,15 @@ class DistributedJobManager:
|
|||||||
args=new_job_attributes.get('args', {}),
|
args=new_job_attributes.get('args', {}),
|
||||||
parent=new_job_attributes.get('parent'),
|
parent=new_job_attributes.get('parent'),
|
||||||
name=new_job_attributes.get('name'))
|
name=new_job_attributes.get('name'))
|
||||||
worker.status = new_job_attributes.get("initial_status", worker.status) # todo: is this necessary?
|
worker.status = new_job_attributes.get("initial_status", worker.status)
|
||||||
worker.priority = int(new_job_attributes.get('priority', worker.priority))
|
worker.priority = int(new_job_attributes.get('priority', worker.priority))
|
||||||
worker.start_frame = int(new_job_attributes.get("start_frame", worker.start_frame))
|
worker.start_frame = int(new_job_attributes.get("start_frame", worker.start_frame))
|
||||||
worker.end_frame = int(new_job_attributes.get("end_frame", worker.end_frame))
|
worker.end_frame = int(new_job_attributes.get("end_frame", worker.end_frame))
|
||||||
worker.watchdog_timeout = Config.worker_process_timeout
|
worker.watchdog_timeout = Config.worker_process_timeout
|
||||||
worker.hostname = socket.gethostname()
|
worker.hostname = socket.gethostname()
|
||||||
|
|
||||||
# determine if we can / should split the job
|
|
||||||
if new_job_attributes.get("enable_split_jobs", False) and (worker.total_frames > 1) and not worker.parent:
|
if new_job_attributes.get("enable_split_jobs", False) and (worker.total_frames > 1) and not worker.parent:
|
||||||
cls.split_into_subjobs_async(worker, new_job_attributes, loaded_project_local_path)
|
self.split_into_subjobs_async(worker, new_job_attributes, loaded_project_local_path)
|
||||||
else:
|
else:
|
||||||
worker.status = RenderStatus.NOT_STARTED
|
worker.status = RenderStatus.NOT_STARTED
|
||||||
|
|
||||||
@@ -182,15 +138,7 @@ class DistributedJobManager:
|
|||||||
# Handling Subjobs
|
# Handling Subjobs
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
def _handle_subjob_update_notification(self, local_job, subjob_data: dict) -> None:
|
||||||
def handle_subjob_update_notification(cls, local_job, subjob_data: dict):
|
|
||||||
"""Responds to a notification from a remote subjob and the host requests any subsequent updates from the subjob.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
local_job (BaseRenderWorker): The local parent job worker.
|
|
||||||
subjob_data (dict): Subjob data sent from the remote server.
|
|
||||||
"""
|
|
||||||
|
|
||||||
subjob_status = string_to_status(subjob_data['status'])
|
subjob_status = string_to_status(subjob_data['status'])
|
||||||
subjob_id = subjob_data['id']
|
subjob_id = subjob_data['id']
|
||||||
subjob_hostname = subjob_data['hostname']
|
subjob_hostname = subjob_data['hostname']
|
||||||
@@ -206,19 +154,10 @@ class DistributedJobManager:
|
|||||||
if subjob_data['status'] == 'completed' and download_success:
|
if subjob_data['status'] == 'completed' and download_success:
|
||||||
local_job.children[subjob_key]['download_status'] = 'completed'
|
local_job.children[subjob_key]['download_status'] = 'completed'
|
||||||
|
|
||||||
@classmethod
|
def _wait_for_subjobs(self, parent_job) -> None:
|
||||||
def wait_for_subjobs(cls, parent_job):
|
|
||||||
"""Check the status of subjobs and waits until they are all finished. Download rendered frames from subjobs
|
|
||||||
when they are completed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent_job: Worker object that has child jobs
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
"""
|
|
||||||
logger.debug(f"Waiting for subjobs for job {parent_job}")
|
logger.debug(f"Waiting for subjobs for job {parent_job}")
|
||||||
parent_job.status = RenderStatus.WAITING_FOR_SUBJOBS
|
parent_job.status = RenderStatus.WAITING_FOR_SUBJOBS
|
||||||
statuses_to_download = [RenderStatus.CANCELLED, RenderStatus.ERROR, RenderStatus.COMPLETED]
|
statuses_to_download = (RenderStatus.CANCELLED, RenderStatus.ERROR, RenderStatus.COMPLETED)
|
||||||
|
|
||||||
def subjobs_not_downloaded():
|
def subjobs_not_downloaded():
|
||||||
return {k: v for k, v in parent_job.children.items() if 'download_status' not in v or
|
return {k: v for k, v in parent_job.children.items() if 'download_status' not in v or
|
||||||
@@ -230,21 +169,17 @@ class DistributedJobManager:
|
|||||||
sleep_counter = 0
|
sleep_counter = 0
|
||||||
while parent_job.status == RenderStatus.WAITING_FOR_SUBJOBS:
|
while parent_job.status == RenderStatus.WAITING_FOR_SUBJOBS:
|
||||||
|
|
||||||
if sleep_counter % server_delay == 0: # only ping servers every x seconds
|
if sleep_counter % server_delay == 0:
|
||||||
for child_key, subjob_cached_data in subjobs_not_downloaded().items():
|
for child_key in subjobs_not_downloaded():
|
||||||
|
|
||||||
subjob_id = child_key.split('@')[0]
|
subjob_id = child_key.split('@')[0]
|
||||||
subjob_hostname = child_key.split('@')[-1]
|
subjob_hostname = child_key.split('@')[-1]
|
||||||
|
|
||||||
# Fetch info from server and handle failing case
|
subjob_data = RenderServerProxy(subjob_hostname).get_job(subjob_id)
|
||||||
subjob_data = RenderServerProxy(subjob_hostname).get_job_info(subjob_id)
|
|
||||||
if not subjob_data:
|
if not subjob_data:
|
||||||
logger.warning(f"No response from {subjob_hostname}")
|
logger.warning(f"No response from {subjob_hostname}")
|
||||||
# timeout / missing server situations
|
|
||||||
parent_job.children[child_key]['download_status'] = f'error: No response from {subjob_hostname}'
|
parent_job.children[child_key]['download_status'] = f'error: No response from {subjob_hostname}'
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Update parent job cache but keep the download status
|
|
||||||
download_status = parent_job.children[child_key].get('download_status', None)
|
download_status = parent_job.children[child_key].get('download_status', None)
|
||||||
parent_job.children[child_key] = subjob_data
|
parent_job.children[child_key] = subjob_data
|
||||||
parent_job.children[child_key]['download_status'] = download_status
|
parent_job.children[child_key]['download_status'] = download_status
|
||||||
@@ -254,8 +189,7 @@ class DistributedJobManager:
|
|||||||
f"{float(subjob_data.get('percent_complete')) * 100.0}%"
|
f"{float(subjob_data.get('percent_complete')) * 100.0}%"
|
||||||
logger.debug(status_msg)
|
logger.debug(status_msg)
|
||||||
|
|
||||||
# Check if job is finished, but has not had files copied yet over yet
|
if download_status is None and subjob_data.get('file_count') and status in statuses_to_download:
|
||||||
if download_status is None and subjob_data['file_count'] and status in statuses_to_download:
|
|
||||||
try:
|
try:
|
||||||
download_missing_frames_from_subjob(parent_job, subjob_id, subjob_hostname)
|
download_missing_frames_from_subjob(parent_job, subjob_id, subjob_hostname)
|
||||||
parent_job.children[child_key]['download_status'] = 'complete'
|
parent_job.children[child_key]['download_status'] = 'complete'
|
||||||
@@ -263,7 +197,6 @@ class DistributedJobManager:
|
|||||||
logger.error(f"Error downloading missing frames from subjob: {e}")
|
logger.error(f"Error downloading missing frames from subjob: {e}")
|
||||||
parent_job.children[child_key]['download_status'] = 'error: {}'
|
parent_job.children[child_key]['download_status'] = 'error: {}'
|
||||||
|
|
||||||
# Any finished jobs not successfully downloaded at this point are skipped
|
|
||||||
if parent_job.children[child_key].get('download_status', None) is None and \
|
if parent_job.children[child_key].get('download_status', None) is None and \
|
||||||
status in statuses_to_download:
|
status in statuses_to_download:
|
||||||
logger.warning(f"Skipping waiting on downloading from subjob: {child_key}")
|
logger.warning(f"Skipping waiting on downloading from subjob: {child_key}")
|
||||||
@@ -274,42 +207,22 @@ class DistributedJobManager:
|
|||||||
f"{', '.join(list(subjobs_not_downloaded().keys()))}")
|
f"{', '.join(list(subjobs_not_downloaded().keys()))}")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
sleep_counter += 1
|
sleep_counter += 1
|
||||||
else: # exit the loop
|
else:
|
||||||
parent_job.status = RenderStatus.RUNNING
|
parent_job.status = RenderStatus.RUNNING
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Creating Subjobs
|
# Creating Subjobs
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
def _split_into_subjobs_async(self, parent_worker, new_job_attributes, project_path, system_os=None) -> None:
|
||||||
def split_into_subjobs_async(cls, parent_worker, new_job_attributes, project_path, system_os=None):
|
|
||||||
# todo: I don't love this
|
|
||||||
parent_worker.status = RenderStatus.CONFIGURING
|
parent_worker.status = RenderStatus.CONFIGURING
|
||||||
cls.background_worker = threading.Thread(target=cls.split_into_subjobs, args=(parent_worker, new_job_attributes,
|
self.background_worker = threading.Thread(target=self.split_into_subjobs, args=(
|
||||||
project_path, system_os))
|
parent_worker, new_job_attributes, project_path, system_os))
|
||||||
cls.background_worker.start()
|
self.background_worker.start()
|
||||||
|
|
||||||
@classmethod
|
def split_into_subjobs(self, parent_worker, new_job_attributes, project_path, system_os=None, specific_servers=None) -> None:
|
||||||
def split_into_subjobs(cls, parent_worker, new_job_attributes, project_path, system_os=None, specific_servers=None):
|
available_servers = specific_servers if specific_servers else self.find_available_servers(
|
||||||
"""
|
parent_worker.engine_name, system_os)
|
||||||
Splits a job into subjobs and distributes them among available servers.
|
|
||||||
|
|
||||||
This method checks the availability of servers, distributes the work among them, and creates subjobs on each
|
|
||||||
server. If a server is the local host, it adjusts the frame range of the parent job instead of creating a
|
|
||||||
subjob.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent_worker (Worker): The parent job what we're creating the subjobs for.
|
|
||||||
new_job_attributes (dict): Dict of desired attributes for new job (frame count, engine, output path, etc)
|
|
||||||
project_path (str): The path to the project.
|
|
||||||
system_os (str, optional): Required OS. Default is any.
|
|
||||||
specific_servers (list, optional): List of specific servers to split work between. Defaults to all found.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Check availability
|
|
||||||
available_servers = specific_servers if specific_servers else cls.find_available_servers(parent_worker.engine_name,
|
|
||||||
system_os)
|
|
||||||
# skip if theres no external servers found
|
|
||||||
external_servers = [x for x in available_servers if x['hostname'] != parent_worker.hostname]
|
external_servers = [x for x in available_servers if x['hostname'] != parent_worker.hostname]
|
||||||
if not external_servers:
|
if not external_servers:
|
||||||
parent_worker.status = RenderStatus.NOT_STARTED
|
parent_worker.status = RenderStatus.NOT_STARTED
|
||||||
@@ -318,34 +231,29 @@ class DistributedJobManager:
|
|||||||
logger.debug(f"Splitting into subjobs - Available servers: {[x['hostname'] for x in available_servers]}")
|
logger.debug(f"Splitting into subjobs - Available servers: {[x['hostname'] for x in available_servers]}")
|
||||||
all_subjob_server_data = distribute_server_work(parent_worker.start_frame, parent_worker.end_frame, available_servers)
|
all_subjob_server_data = distribute_server_work(parent_worker.start_frame, parent_worker.end_frame, available_servers)
|
||||||
|
|
||||||
# Prep and submit these sub-jobs
|
|
||||||
logger.info(f"Job {parent_worker.id} split plan: {all_subjob_server_data}")
|
logger.info(f"Job {parent_worker.id} split plan: {all_subjob_server_data}")
|
||||||
try:
|
try:
|
||||||
for subjob_data in all_subjob_server_data:
|
for subjob_data in all_subjob_server_data:
|
||||||
subjob_hostname = subjob_data['hostname']
|
subjob_hostname = subjob_data['hostname']
|
||||||
post_results = cls.__create_subjob(new_job_attributes, project_path, subjob_data, subjob_hostname,
|
post_results = self._create_subjob(new_job_attributes, project_path, subjob_data, subjob_hostname,
|
||||||
parent_worker)
|
parent_worker)
|
||||||
if not post_results.ok:
|
if not post_results.ok:
|
||||||
ValueError(f"Failed to create subjob on {subjob_hostname}")
|
ValueError(f"Failed to create subjob on {subjob_hostname}")
|
||||||
|
|
||||||
# save child info
|
|
||||||
submission_results = post_results.json()[0]
|
submission_results = post_results.json()[0]
|
||||||
child_key = f"{submission_results['id']}@{subjob_hostname}"
|
child_key = f"{submission_results['id']}@{subjob_hostname}"
|
||||||
parent_worker.children[child_key] = submission_results
|
parent_worker.children[child_key] = submission_results
|
||||||
|
|
||||||
# start subjobs
|
|
||||||
logger.debug(f"Created {len(all_subjob_server_data)} subjobs successfully")
|
logger.debug(f"Created {len(all_subjob_server_data)} subjobs successfully")
|
||||||
parent_worker.name = f"{parent_worker.name} (Parent)"
|
parent_worker.name = f"{parent_worker.name} (Parent)"
|
||||||
parent_worker.status = RenderStatus.NOT_STARTED # todo: this won't work with scheduled starts
|
parent_worker.status = RenderStatus.NOT_STARTED
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# cancel all the subjobs
|
|
||||||
logger.error(f"Failed to split job into subjobs: {e}")
|
logger.error(f"Failed to split job into subjobs: {e}")
|
||||||
logger.debug(f"Cancelling {len(all_subjob_server_data) - 1} attempted subjobs")
|
logger.debug(f"Cancelling {len(all_subjob_server_data) - 1} attempted subjobs")
|
||||||
RenderServerProxy(parent_worker.hostname).cancel_job(parent_worker.id, confirm=True)
|
RenderServerProxy(parent_worker.hostname).cancel_job(parent_worker.id, confirm=True)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __create_subjob(new_job_attributes: dict, project_path, server_data, server_hostname: str, parent_worker):
|
def _create_subjob(new_job_attributes: dict, project_path, server_data, server_hostname, parent_worker):
|
||||||
"""Convenience method to create subjobs for a parent worker"""
|
|
||||||
subjob = new_job_attributes.copy()
|
subjob = new_job_attributes.copy()
|
||||||
subjob['name'] = f"{parent_worker.name}[{server_data['frame_range'][0]}-{server_data['frame_range'][-1]}]"
|
subjob['name'] = f"{parent_worker.name}[{server_data['frame_range'][0]}-{server_data['frame_range'][-1]}]"
|
||||||
subjob['parent'] = f"{parent_worker.id}@{parent_worker.hostname}"
|
subjob['parent'] = f"{parent_worker.id}@{parent_worker.hostname}"
|
||||||
@@ -354,7 +262,7 @@ class DistributedJobManager:
|
|||||||
subjob['engine_version'] = parent_worker.engine_version
|
subjob['engine_version'] = parent_worker.engine_version
|
||||||
logger.debug(f"Posting subjob with frames {subjob['start_frame']}-"
|
logger.debug(f"Posting subjob with frames {subjob['start_frame']}-"
|
||||||
f"{subjob['end_frame']} to {server_hostname}")
|
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)
|
file_path=project_path, job_data=subjob)
|
||||||
return post_results
|
return post_results
|
||||||
|
|
||||||
@@ -364,25 +272,46 @@ class DistributedJobManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_available_servers(engine_name: str, system_os=None):
|
def find_available_servers(engine_name: str, system_os=None):
|
||||||
"""
|
from src.api.api_server import API_VERSION
|
||||||
Scan the Zeroconf network for currently available render servers supporting a specific engine.
|
|
||||||
|
|
||||||
:param engine_name: str, The engine type to search for
|
|
||||||
:param system_os: str, Restrict results to servers running a specific OS
|
|
||||||
:return: A list of dictionaries with each dict containing hostname and cpu_count of available servers
|
|
||||||
"""
|
|
||||||
from api.api_server import API_VERSION
|
|
||||||
found_available_servers = []
|
found_available_servers = []
|
||||||
for hostname in ZeroconfServer.found_hostnames():
|
for hostname in ZeroconfServer.found_hostnames():
|
||||||
host_properties = ZeroconfServer.get_hostname_properties(hostname)
|
host_properties = ZeroconfServer.get_hostname_properties(hostname)
|
||||||
if host_properties.get('api_version') == API_VERSION:
|
if host_properties.get('api_version') == API_VERSION:
|
||||||
if not system_os or (system_os and system_os == host_properties.get('system_os')):
|
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):
|
if response and response.get('available', False):
|
||||||
found_available_servers.append(response)
|
found_available_servers.append(response)
|
||||||
|
|
||||||
return found_available_servers
|
return found_available_servers
|
||||||
|
|
||||||
|
# --- Forwarders for backward compatibility ---
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def subscribe_to_listener(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._subscribe_to_listener()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_render_job(cls, new_job_attributes, loaded_project_local_path):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._create_render_job(new_job_attributes, loaded_project_local_path)
|
||||||
|
raise RuntimeError("DistributedJobManager is not initialized")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def handle_subjob_update_notification(cls, local_job, subjob_data):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._handle_subjob_update_notification(local_job, subjob_data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def wait_for_subjobs(cls, parent_job):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._wait_for_subjobs(parent_job)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def split_into_subjobs_async(cls, parent_worker, new_job_attributes, project_path, system_os=None):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._split_into_subjobs_async(parent_worker, new_job_attributes, project_path, system_os)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class BlenderDownloader(EngineDownloader):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __find_LTS_versions():
|
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()
|
response.raise_for_status()
|
||||||
|
|
||||||
lts_pattern = r'https://www.blender.org/download/lts/(\d+-\d+)/'
|
lts_pattern = r'https://www.blender.org/download/lts/(\d+-\d+)/'
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ class Blender(BaseRenderEngine):
|
|||||||
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_system_info.py')
|
script_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'scripts', 'get_system_info.py')
|
||||||
results = self.run_python_script(script_path=script_path)
|
results = self.run_python_script(script_path=script_path)
|
||||||
output = results.stdout.decode()
|
output = results.stdout.decode()
|
||||||
match = re.search(r"GPU DATA:(\[[\s\S]*\])", output)
|
match = re.search(r"GPU DATA:(\[.*?\])", output, re.DOTALL)
|
||||||
if match:
|
if match:
|
||||||
gpu_data_json = match.group(1)
|
gpu_data_json = match.group(1)
|
||||||
gpus_info = json.loads(gpu_data_json)
|
gpus_info = json.loads(gpu_data_json)
|
||||||
@@ -193,5 +193,5 @@ class Blender(BaseRenderEngine):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
x = Blender().get_render_devices()
|
x = Blender().system_info()
|
||||||
print(x)
|
print(x)
|
||||||
|
|||||||
+185
-251
@@ -20,128 +20,79 @@ class EngineManager:
|
|||||||
if possible.
|
if possible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_default_instance: Optional['EngineManager'] = None
|
||||||
|
|
||||||
engines_path: Optional[str] = None
|
engines_path: Optional[str] = None
|
||||||
download_tasks: List[Any] = []
|
download_tasks: List[Any] = []
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.engines_path: Optional[str] = None
|
||||||
|
self.download_tasks: List[Any] = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _sync_class(cls) -> None:
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls.engines_path = cls._default_instance.engines_path
|
||||||
|
cls.download_tasks = cls._default_instance.download_tasks
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def supported_engines() -> list[type[BaseRenderEngine]]:
|
def supported_engines() -> list[type[BaseRenderEngine]]:
|
||||||
"""Return list of supported engine classes.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[Type[BaseRenderEngine]]: List of available engine classes.
|
|
||||||
"""
|
|
||||||
return ENGINE_CLASSES
|
return ENGINE_CLASSES
|
||||||
|
|
||||||
# --- Installed Engines ---
|
# --- Installed Engines ---
|
||||||
|
|
||||||
@classmethod
|
def _engine_class_for_project_path(self, path: str) -> Type[BaseRenderEngine]:
|
||||||
def engine_class_for_project_path(cls, path: str) -> Type[BaseRenderEngine]:
|
|
||||||
"""Find engine class that can handle the given project file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Path to project file.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Type[BaseRenderEngine]: Engine class that can handle the file.
|
|
||||||
"""
|
|
||||||
_, extension = os.path.splitext(path)
|
_, extension = os.path.splitext(path)
|
||||||
extension = extension.lower().strip('.')
|
extension = extension.lower().strip('.')
|
||||||
for engine_class in cls.supported_engines():
|
for engine_class in self.supported_engines():
|
||||||
engine = cls.get_latest_engine_instance(engine_class)
|
engine = self.get_latest_engine_instance(engine_class)
|
||||||
if extension in engine.supported_extensions():
|
if extension in engine.supported_extensions():
|
||||||
return engine_class
|
return engine_class
|
||||||
undefined_renderer_support = [x for x in cls.supported_engines() if not cls.get_latest_engine_instance(x).supported_extensions()]
|
undefined_renderer_support = [x for x in self.supported_engines() if not self.get_latest_engine_instance(x).supported_extensions()]
|
||||||
return undefined_renderer_support[0]
|
return undefined_renderer_support[0]
|
||||||
|
|
||||||
@classmethod
|
def _engine_class_with_name(self, engine_name: str) -> Optional[Type[BaseRenderEngine]]:
|
||||||
def engine_class_with_name(cls, engine_name: str) -> Optional[Type[BaseRenderEngine]]:
|
for obj in self.supported_engines():
|
||||||
"""Find engine class by name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
engine_name: Name of engine to find.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Optional[Type[BaseRenderEngine]]: Engine class if found, None otherwise.
|
|
||||||
"""
|
|
||||||
for obj in cls.supported_engines():
|
|
||||||
if obj.name().lower() == engine_name.lower():
|
if obj.name().lower() == engine_name.lower():
|
||||||
return obj
|
return obj
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
def _get_latest_engine_instance(self, engine_class: Type[BaseRenderEngine]) -> BaseRenderEngine:
|
||||||
def get_latest_engine_instance(cls, engine_class: Type[BaseRenderEngine]) -> BaseRenderEngine:
|
newest = self.newest_installed_engine_data(engine_class.name())
|
||||||
"""Create instance of latest installed engine version.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
engine_class: Engine class to instantiate.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BaseRenderEngine: Instance of engine with latest version.
|
|
||||||
"""
|
|
||||||
newest = cls.newest_installed_engine_data(engine_class.name())
|
|
||||||
engine = engine_class(newest["path"])
|
engine = engine_class(newest["path"])
|
||||||
return engine
|
return engine
|
||||||
|
|
||||||
@classmethod
|
def _get_installed_engine_data(self, filter_name: Optional[str] = None, include_corrupt: bool = False,
|
||||||
def get_installed_engine_data(cls, filter_name: Optional[str] = None, include_corrupt: bool = False,
|
|
||||||
ignore_system: bool = False) -> List[Dict[str, Any]]:
|
ignore_system: bool = False) -> List[Dict[str, Any]]:
|
||||||
"""Get data about installed render engines.
|
if not self.engines_path:
|
||||||
|
|
||||||
Args:
|
|
||||||
filter_name: Optional engine name to filter by.
|
|
||||||
include_corrupt: Whether to include potentially corrupted installations.
|
|
||||||
ignore_system: Whether to ignore system-installed engines.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[Dict[str, Any]]: List of installed engine data.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If engines path is not set.
|
|
||||||
"""
|
|
||||||
if not cls.engines_path:
|
|
||||||
raise FileNotFoundError("Engine path is not set")
|
raise FileNotFoundError("Engine path is not set")
|
||||||
|
|
||||||
# Parse downloaded engine directory
|
|
||||||
results = []
|
results = []
|
||||||
try:
|
try:
|
||||||
all_items = os.listdir(cls.engines_path)
|
all_items = os.listdir(self.engines_path)
|
||||||
all_directories = [item for item in all_items if os.path.isdir(os.path.join(cls.engines_path, item))]
|
all_directories = [item for item in all_items if os.path.isdir(os.path.join(self.engines_path, item))]
|
||||||
keys = ["engine", "version", "system_os", "cpu"] # Define keys for result dictionary
|
keys = ["engine", "version", "system_os", "cpu"]
|
||||||
|
|
||||||
for directory in all_directories:
|
for directory in all_directories:
|
||||||
# Split directory name into segments
|
|
||||||
segments = directory.split('-')
|
segments = directory.split('-')
|
||||||
# Create a dictionary mapping keys to corresponding segments
|
|
||||||
result_dict = {keys[i]: segments[i] for i in range(min(len(keys), len(segments)))}
|
result_dict = {keys[i]: segments[i] for i in range(min(len(keys), len(segments)))}
|
||||||
result_dict['type'] = 'managed'
|
result_dict['type'] = 'managed'
|
||||||
|
|
||||||
# Initialize binary_name with engine name
|
|
||||||
binary_name = result_dict['engine'].lower()
|
binary_name = result_dict['engine'].lower()
|
||||||
# Determine the correct binary name based on the engine and system_os
|
eng = self.engine_class_with_name(result_dict['engine'])
|
||||||
eng = cls.engine_class_with_name(result_dict['engine'])
|
if eng:
|
||||||
binary_name = eng.binary_names.get(result_dict['system_os'], binary_name)
|
binary_name = eng.binary_names.get(result_dict['system_os'], binary_name)
|
||||||
|
|
||||||
# Find the path to the binary file
|
search_root = Path(self.engines_path) / directory
|
||||||
search_root = Path(cls.engines_path) / directory
|
|
||||||
match = next((p for p in search_root.rglob(binary_name) if p.is_file()), None)
|
match = next((p for p in search_root.rglob(binary_name) if p.is_file()), None)
|
||||||
path = str(match) if match else None
|
path = str(match) if match else None
|
||||||
result_dict['path'] = path
|
result_dict['path'] = path
|
||||||
|
|
||||||
# fetch version number from binary - helps detect corrupted downloads - disabled due to perf issues
|
|
||||||
# binary_version = eng(path).version()
|
|
||||||
# if not binary_version:
|
|
||||||
# logger.warning(f"Possible corrupt {eng.name()} {result_dict['version']} install detected: {path}")
|
|
||||||
# if not include_corrupt:
|
|
||||||
# continue
|
|
||||||
# result_dict['version'] = binary_version or 'error'
|
|
||||||
|
|
||||||
# Add the result dictionary to results if it matches the filter_name or if no filter is applied
|
|
||||||
if not filter_name or filter_name == result_dict['engine']:
|
if not filter_name or filter_name == result_dict['engine']:
|
||||||
results.append(result_dict)
|
results.append(result_dict)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
logger.warning(f"Cannot find local engines download directory: {e}")
|
logger.warning(f"Cannot find local engines download directory: {e}")
|
||||||
|
|
||||||
# add system installs to this list - use bg thread because it can be slow
|
|
||||||
def fetch_engine_details(eng, include_corrupt=False):
|
def fetch_engine_details(eng, include_corrupt=False):
|
||||||
version = eng().version()
|
version = eng().version()
|
||||||
if not version and not include_corrupt:
|
if not version and not include_corrupt:
|
||||||
@@ -160,7 +111,7 @@ class EngineManager:
|
|||||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
futures = {
|
futures = {
|
||||||
executor.submit(fetch_engine_details, eng, include_corrupt): eng.name()
|
executor.submit(fetch_engine_details, eng, include_corrupt): eng.name()
|
||||||
for eng in cls.supported_engines()
|
for eng in self.supported_engines()
|
||||||
if eng.default_engine_path() and (not filter_name or filter_name == eng.name())
|
if eng.default_engine_path() and (not filter_name or filter_name == eng.name())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,96 +124,55 @@ class EngineManager:
|
|||||||
|
|
||||||
# --- Check for Updates ---
|
# --- Check for Updates ---
|
||||||
|
|
||||||
@classmethod
|
def _update_all_engines(self) -> None:
|
||||||
def update_all_engines(cls) -> None:
|
for engine in self.downloadable_engines():
|
||||||
"""Check for and download updates for all downloadable engines."""
|
update_available = self.is_engine_update_available(engine)
|
||||||
for engine in cls.downloadable_engines():
|
|
||||||
update_available = cls.is_engine_update_available(engine)
|
|
||||||
if update_available:
|
if update_available:
|
||||||
update_available['name'] = engine.name()
|
update_available['name'] = engine.name()
|
||||||
cls.download_engine(engine.name(), update_available['version'], background=True)
|
self.download_engine(engine.name(), update_available['version'], background=True)
|
||||||
|
|
||||||
@classmethod
|
def _all_version_data_for_engine(self, engine_name: str, include_corrupt=False, ignore_system=False) -> list:
|
||||||
def all_version_data_for_engine(cls, engine_name:str, include_corrupt=False, ignore_system=False) -> list:
|
versions = self.get_installed_engine_data(filter_name=engine_name, include_corrupt=include_corrupt, ignore_system=ignore_system)
|
||||||
"""Get all version data for a specific engine.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
engine_name: Name of engine to query.
|
|
||||||
include_corrupt: Whether to include corrupt installations.
|
|
||||||
ignore_system: Whether to ignore system installations.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Sorted list of engine version data (newest first).
|
|
||||||
"""
|
|
||||||
versions = cls.get_installed_engine_data(filter_name=engine_name, include_corrupt=include_corrupt, ignore_system=ignore_system)
|
|
||||||
sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True)
|
sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True)
|
||||||
return sorted_versions
|
return sorted_versions
|
||||||
|
|
||||||
@classmethod
|
def _newest_installed_engine_data(self, engine_name: str, system_os=None, cpu=None, ignore_system=None) -> list:
|
||||||
def newest_installed_engine_data(cls, engine_name:str, system_os=None, cpu=None, ignore_system=None) -> list:
|
|
||||||
"""Get newest installed engine data for specific platform.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
engine_name: Name of engine to query.
|
|
||||||
system_os: Operating system to filter by (defaults to current).
|
|
||||||
cpu: CPU architecture to filter by (defaults to current).
|
|
||||||
ignore_system: Whether to ignore system installations.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Newest engine data or empty list if not found.
|
|
||||||
"""
|
|
||||||
system_os = system_os or current_system_os()
|
system_os = system_os or current_system_os()
|
||||||
cpu = cpu or current_system_cpu()
|
cpu = cpu or current_system_cpu()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
filtered = [x for x in cls.all_version_data_for_engine(engine_name, ignore_system=ignore_system)
|
filtered = [x for x in self.all_version_data_for_engine(engine_name, ignore_system=ignore_system)
|
||||||
if x['system_os'] == system_os and x['cpu'] == cpu]
|
if x['system_os'] == system_os and x['cpu'] == cpu]
|
||||||
return filtered[0]
|
return filtered[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
logger.error(f"Cannot find newest engine version for {engine_name}-{system_os}-{cpu}")
|
logger.error(f"Cannot find newest engine version for {engine_name}-{system_os}-{cpu}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@classmethod
|
def _is_version_installed(self, engine_name: str, version: str, system_os=None, cpu=None, ignore_system=False):
|
||||||
def is_version_installed(cls, engine_name:str, version:str, system_os=None, cpu=None, ignore_system=False):
|
|
||||||
"""Check if specific engine version is installed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
engine_name: Name of engine to check.
|
|
||||||
version: Version string to check.
|
|
||||||
system_os: Operating system to check (defaults to current).
|
|
||||||
cpu: CPU architecture to check (defaults to current).
|
|
||||||
ignore_system: Whether to ignore system installations.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Engine data if found, False otherwise.
|
|
||||||
"""
|
|
||||||
system_os = system_os or current_system_os()
|
system_os = system_os or current_system_os()
|
||||||
cpu = cpu or current_system_cpu()
|
cpu = cpu or current_system_cpu()
|
||||||
|
|
||||||
filtered = [x for x in cls.get_installed_engine_data(filter_name=engine_name, ignore_system=ignore_system) if
|
filtered = [x for x in self.get_installed_engine_data(filter_name=engine_name, ignore_system=ignore_system) if
|
||||||
x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version]
|
x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version]
|
||||||
return filtered[0] if filtered else False
|
return filtered[0] if filtered else False
|
||||||
|
|
||||||
@classmethod
|
def _version_is_available_to_download(self, engine_name: str, version, system_os=None, cpu=None):
|
||||||
def version_is_available_to_download(cls, engine_name:str, version, system_os=None, cpu=None):
|
|
||||||
try:
|
try:
|
||||||
downloader = cls.engine_class_with_name(engine_name).downloader()
|
downloader = self.engine_class_with_name(engine_name).downloader()
|
||||||
return downloader.version_is_available_to_download(version=version, system_os=system_os, cpu=cpu)
|
return downloader.version_is_available_to_download(version=version, system_os=system_os, cpu=cpu)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Exception in version_is_available_to_download: {e}")
|
logger.debug(f"Exception in version_is_available_to_download: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
def _find_most_recent_version(self, engine_name: str, system_os=None, cpu=None, lts_only=False) -> dict:
|
||||||
def find_most_recent_version(cls, engine_name:str, system_os=None, cpu=None, lts_only=False) -> dict:
|
|
||||||
try:
|
try:
|
||||||
downloader = cls.engine_class_with_name(engine_name).downloader()
|
downloader = self.engine_class_with_name(engine_name).downloader()
|
||||||
return downloader.find_most_recent_version(system_os=system_os, cpu=cpu)
|
return downloader.find_most_recent_version(system_os=system_os, cpu=cpu)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Exception in find_most_recent_version: {e}")
|
logger.debug(f"Exception in find_most_recent_version: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@classmethod
|
def _is_engine_update_available(self, engine_class: Type[BaseRenderEngine], ignore_system_installs=False):
|
||||||
def is_engine_update_available(cls, engine_class: Type[BaseRenderEngine], ignore_system_installs=False):
|
|
||||||
logger.debug(f"Checking for updates to {engine_class.name()}")
|
logger.debug(f"Checking for updates to {engine_class.name()}")
|
||||||
latest_version = engine_class.downloader().find_most_recent_version()
|
latest_version = engine_class.downloader().find_most_recent_version()
|
||||||
|
|
||||||
@@ -271,7 +181,7 @@ class EngineManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
version_num = latest_version.get('version')
|
version_num = latest_version.get('version')
|
||||||
if cls.is_version_installed(engine_class.name(), version_num, ignore_system=ignore_system_installs):
|
if self.is_version_installed(engine_class.name(), version_num, ignore_system=ignore_system_installs):
|
||||||
logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded")
|
logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -279,18 +189,11 @@ class EngineManager:
|
|||||||
|
|
||||||
# --- Downloads ---
|
# --- Downloads ---
|
||||||
|
|
||||||
@classmethod
|
def _downloadable_engines(self):
|
||||||
def downloadable_engines(cls):
|
return [engine for engine in self.supported_engines() if hasattr(engine, "downloader") and engine.downloader()]
|
||||||
"""Get list of engines that support downloading.
|
|
||||||
|
|
||||||
Returns:
|
def _get_existing_download_task(self, engine_name, version, system_os=None, cpu=None):
|
||||||
List[Type[BaseRenderEngine]]: Engines with downloader capability.
|
for task in self.download_tasks:
|
||||||
"""
|
|
||||||
return [engine for engine in cls.supported_engines() if hasattr(engine, "downloader") and engine.downloader()]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_existing_download_task(cls, engine_name, version, system_os=None, cpu=None):
|
|
||||||
for task in cls.download_tasks:
|
|
||||||
task_parts = task.name.split('-')
|
task_parts = task.name.split('-')
|
||||||
task_engine, task_version, task_system_os, task_cpu = task_parts[:4]
|
task_engine, task_version, task_system_os, task_cpu = task_parts[:4]
|
||||||
|
|
||||||
@@ -299,50 +202,45 @@ class EngineManager:
|
|||||||
return task
|
return task
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
def _download_engine(self, engine_name, version, system_os=None, cpu=None, background=False, ignore_system=False):
|
||||||
def download_engine(cls, engine_name, version, system_os=None, cpu=None, background=False, ignore_system=False):
|
engine_to_download = self.engine_class_with_name(engine_name)
|
||||||
|
existing_task = self.get_existing_download_task(engine_name, version, system_os, cpu)
|
||||||
engine_to_download = cls.engine_class_with_name(engine_name)
|
|
||||||
existing_task = cls.get_existing_download_task(engine_name, version, system_os, cpu)
|
|
||||||
if existing_task:
|
if existing_task:
|
||||||
logger.debug(f"Already downloading {engine_name} {version}")
|
logger.debug(f"Already downloading {engine_name} {version}")
|
||||||
if not background:
|
if not background:
|
||||||
existing_task.join() # If download task exists, wait until it's done downloading
|
existing_task.join()
|
||||||
return None
|
return None
|
||||||
elif not engine_to_download.downloader():
|
elif not engine_to_download.downloader():
|
||||||
logger.warning("No valid downloader for this engine. Please update this software manually.")
|
logger.warning("No valid downloader for this engine. Please update this software manually.")
|
||||||
return None
|
return None
|
||||||
elif not cls.engines_path:
|
elif not self.engines_path:
|
||||||
raise FileNotFoundError("Engines path must be set before requesting downloads")
|
raise FileNotFoundError("Engines path must be set before requesting downloads")
|
||||||
|
|
||||||
thread = EngineDownloadWorker(engine_name, version, system_os, cpu)
|
thread = EngineDownloadWorker(engine_name, version, system_os, cpu)
|
||||||
cls.download_tasks.append(thread)
|
self.download_tasks.append(thread)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
if background:
|
if background:
|
||||||
return thread
|
return thread
|
||||||
|
|
||||||
thread.join()
|
thread.join()
|
||||||
found_engine = cls.is_version_installed(engine_name, version, system_os, cpu, ignore_system) # Check that engine downloaded
|
found_engine = self.is_version_installed(engine_name, version, system_os, cpu, ignore_system)
|
||||||
if not found_engine:
|
if not found_engine:
|
||||||
logger.error(f"Error downloading {engine_name}")
|
logger.error(f"Error downloading {engine_name}")
|
||||||
return found_engine
|
return found_engine
|
||||||
|
|
||||||
@classmethod
|
def _delete_engine_download(self, engine_name, version, system_os=None, cpu=None):
|
||||||
def delete_engine_download(cls, engine_name, version, system_os=None, cpu=None):
|
|
||||||
logger.info(f"Requested deletion of engine: {engine_name}-{version}")
|
logger.info(f"Requested deletion of engine: {engine_name}-{version}")
|
||||||
|
|
||||||
found = cls.is_version_installed(engine_name, version, system_os, cpu)
|
found = self.is_version_installed(engine_name, version, system_os, cpu)
|
||||||
if found and found['type'] == 'managed': # don't delete system installs
|
if found and found['type'] == 'managed':
|
||||||
# find the root directory of the engine executable
|
|
||||||
root_dir_name = '-'.join([engine_name, version, found['system_os'], found['cpu']])
|
root_dir_name = '-'.join([engine_name, version, found['system_os'], found['cpu']])
|
||||||
remove_path = os.path.join(found['path'].split(root_dir_name)[0], root_dir_name)
|
remove_path = os.path.join(found['path'].split(root_dir_name)[0], root_dir_name)
|
||||||
# delete the file path
|
|
||||||
logger.info(f"Deleting engine at path: {remove_path}")
|
logger.info(f"Deleting engine at path: {remove_path}")
|
||||||
shutil.rmtree(remove_path, ignore_errors=False)
|
shutil.rmtree(remove_path, ignore_errors=False)
|
||||||
logger.info(f"Engine {engine_name}-{version}-{found['system_os']}-{found['cpu']} successfully deleted")
|
logger.info(f"Engine {engine_name}-{version}-{found['system_os']}-{found['cpu']} successfully deleted")
|
||||||
return True
|
return True
|
||||||
elif found: # these are managed by the system / user. Don't delete these.
|
elif found:
|
||||||
logger.error(f'Cannot delete requested {engine_name} {version}. Managed externally.')
|
logger.error(f'Cannot delete requested {engine_name} {version}. Managed externally.')
|
||||||
else:
|
else:
|
||||||
logger.error(f"Cannot find engine: {engine_name}-{version}")
|
logger.error(f"Cannot find engine: {engine_name}-{version}")
|
||||||
@@ -350,52 +248,16 @@ class EngineManager:
|
|||||||
|
|
||||||
# --- Background Tasks ---
|
# --- Background Tasks ---
|
||||||
|
|
||||||
@classmethod
|
def _active_downloads(self) -> list:
|
||||||
def active_downloads(cls) -> list:
|
return [x for x in self.download_tasks if x.is_alive()]
|
||||||
"""Get list of currently active download tasks.
|
|
||||||
|
|
||||||
Returns:
|
def _create_worker(self, engine_name: str, input_path: Path, output_path: Path, engine_version=None, args=None, parent=None, name=None):
|
||||||
list: List of active EngineDownloadWorker threads.
|
worker_class = self.engine_class_with_name(engine_name).worker_class()
|
||||||
"""
|
|
||||||
return [x for x in cls.download_tasks if x.is_alive()]
|
|
||||||
|
|
||||||
@classmethod
|
all_versions = self.all_version_data_for_engine(engine_name)
|
||||||
def create_worker(cls, engine_name: str, input_path: Path, output_path: Path, engine_version=None, args=None, parent=None, name=None):
|
|
||||||
"""
|
|
||||||
Create and return a worker instance for a specific engine.
|
|
||||||
|
|
||||||
This resolves the appropriate engine binary/path for the requested engine and version,
|
|
||||||
downloading the engine if necessary (when a specific version is requested and not found
|
|
||||||
locally). The returned worker is constructed with string paths for compatibility with
|
|
||||||
worker implementations that expect `str` rather than `Path`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
engine_name: The engine name used to resolve an engine class and its worker.
|
|
||||||
input_path: Path to the input file/folder for the worker to process.
|
|
||||||
output_path: Path where the worker should write output.
|
|
||||||
engine_version: Optional engine version to use. If `None` or `'latest'`, the newest
|
|
||||||
installed version is used. If a specific version is provided and not installed,
|
|
||||||
the engine will be downloaded.
|
|
||||||
args: Optional arguments passed through to the worker (engine-specific).
|
|
||||||
parent: Optional Qt/GUI parent object passed through to the worker constructor.
|
|
||||||
name: Optional name/label passed through to the worker constructor.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An instance of the engine-specific worker class.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
FileNotFoundError: If no versions of the engine are installed, if the requested
|
|
||||||
version cannot be found or downloaded, or if the engine path cannot be resolved.
|
|
||||||
"""
|
|
||||||
|
|
||||||
worker_class = cls.engine_class_with_name(engine_name).worker_class()
|
|
||||||
|
|
||||||
# check to make sure we have versions installed
|
|
||||||
all_versions = cls.all_version_data_for_engine(engine_name)
|
|
||||||
if not all_versions:
|
if not all_versions:
|
||||||
raise FileNotFoundError(f"Cannot find any installed '{engine_name}' engines")
|
raise FileNotFoundError(f"Cannot find any installed '{engine_name}' engines")
|
||||||
|
|
||||||
# Find the path to the requested engine version or use default
|
|
||||||
engine_path = None
|
engine_path = None
|
||||||
if engine_version and engine_version != 'latest':
|
if engine_version and engine_version != 'latest':
|
||||||
for ver in all_versions:
|
for ver in all_versions:
|
||||||
@@ -403,9 +265,8 @@ class EngineManager:
|
|||||||
engine_path = ver['path']
|
engine_path = ver['path']
|
||||||
break
|
break
|
||||||
|
|
||||||
# Download the required engine if not found locally
|
|
||||||
if not engine_path:
|
if not engine_path:
|
||||||
download_result = cls.download_engine(engine_name, engine_version)
|
download_result = self.download_engine(engine_name, engine_version)
|
||||||
if not download_result:
|
if not download_result:
|
||||||
raise FileNotFoundError(f"Cannot download requested version: {engine_name} {engine_version}")
|
raise FileNotFoundError(f"Cannot download requested version: {engine_name} {engine_version}")
|
||||||
engine_path = download_result['path']
|
engine_path = download_result['path']
|
||||||
@@ -420,28 +281,109 @@ class EngineManager:
|
|||||||
return worker_class(input_path=str(input_path), output_path=str(output_path), engine_path=engine_path, args=args,
|
return worker_class(input_path=str(input_path), output_path=str(output_path), engine_path=engine_path, args=args,
|
||||||
parent=parent, name=name)
|
parent=parent, name=name)
|
||||||
|
|
||||||
|
# --- Forwarders for backward compatibility ---
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def engine_class_for_project_path(cls, path):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._engine_class_for_project_path(path)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def engine_class_with_name(cls, engine_name):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._engine_class_with_name(engine_name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_latest_engine_instance(cls, engine_class):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._get_latest_engine_instance(engine_class)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_installed_engine_data(cls, filter_name=None, include_corrupt=False, ignore_system=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._get_installed_engine_data(filter_name, include_corrupt, ignore_system)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_all_engines(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._update_all_engines()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def all_version_data_for_engine(cls, engine_name, include_corrupt=False, ignore_system=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._all_version_data_for_engine(engine_name, include_corrupt, ignore_system)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def newest_installed_engine_data(cls, engine_name, system_os=None, cpu=None, ignore_system=None):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._newest_installed_engine_data(engine_name, system_os, cpu, ignore_system)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_version_installed(cls, engine_name, version, system_os=None, cpu=None, ignore_system=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._is_version_installed(engine_name, version, system_os, cpu, ignore_system)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def version_is_available_to_download(cls, engine_name, version, system_os=None, cpu=None):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._version_is_available_to_download(engine_name, version, system_os, cpu)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_most_recent_version(cls, engine_name, system_os=None, cpu=None, lts_only=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._find_most_recent_version(engine_name, system_os, cpu, lts_only)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_engine_update_available(cls, engine_class, ignore_system_installs=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._is_engine_update_available(engine_class, ignore_system_installs)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def downloadable_engines(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._downloadable_engines()
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_existing_download_task(cls, engine_name, version, system_os=None, cpu=None):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._get_existing_download_task(engine_name, version, system_os, cpu)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def download_engine(cls, engine_name, version, system_os=None, cpu=None, background=False, ignore_system=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._download_engine(engine_name, version, system_os, cpu, background, ignore_system)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_engine_download(cls, engine_name, version, system_os=None, cpu=None):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._delete_engine_download(engine_name, version, system_os, cpu)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def active_downloads(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._active_downloads()
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_worker(cls, engine_name, input_path, output_path, engine_version=None, args=None, parent=None, name=None):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._create_worker(engine_name, input_path, output_path, engine_version, args, parent, name)
|
||||||
|
raise RuntimeError("EngineManager is not initialized")
|
||||||
|
|
||||||
|
|
||||||
class EngineDownloadWorker(threading.Thread):
|
class EngineDownloadWorker(threading.Thread):
|
||||||
"""A thread worker for downloading a specific version of a rendering engine.
|
|
||||||
|
|
||||||
This class handles the process of downloading a rendering engine in a separate thread,
|
|
||||||
ensuring that the download process does not block the main application.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
engine (str): The name of the rendering engine to download.
|
|
||||||
version (str): The version of the rendering engine to download.
|
|
||||||
system_os (str, optional): The operating system for which to download the engine. Defaults to current OS type.
|
|
||||||
cpu (str, optional): Requested CPU architecture. Defaults to system CPU type.
|
|
||||||
"""
|
|
||||||
def __init__(self, engine, version, system_os=None, cpu=None):
|
def __init__(self, engine, version, system_os=None, cpu=None):
|
||||||
"""Initialize download worker for specific engine version.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
engine: Name of engine to download.
|
|
||||||
version: Version of engine to download.
|
|
||||||
system_os: Target operating system (defaults to current).
|
|
||||||
cpu: Target CPU architecture (defaults to current).
|
|
||||||
"""
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
self.version = version
|
self.version = version
|
||||||
@@ -450,35 +392,27 @@ class EngineDownloadWorker(threading.Thread):
|
|||||||
self.percent_complete = 0
|
self.percent_complete = 0
|
||||||
|
|
||||||
def _update_progress(self, current_progress):
|
def _update_progress(self, current_progress):
|
||||||
"""Update download progress.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current_progress: Current download progress percentage (0-100).
|
|
||||||
"""
|
|
||||||
self.percent_complete = current_progress
|
self.percent_complete = current_progress
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Execute the download process.
|
try:
|
||||||
|
existing_download = EngineManager.is_version_installed(self.engine, self.version, self.system_os, self.cpu,
|
||||||
|
ignore_system=True)
|
||||||
|
if existing_download:
|
||||||
|
logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists")
|
||||||
|
return existing_download
|
||||||
|
|
||||||
Checks if engine version already exists, then downloads if not found.
|
downloader = EngineManager.engine_class_with_name(self.engine).downloader()
|
||||||
Handles cleanup and error reporting.
|
downloader.download_engine(self.version, download_location=EngineManager.engines_path,
|
||||||
"""
|
system_os=self.system_os, cpu=self.cpu, timeout=300, progress_callback=self._update_progress)
|
||||||
try:
|
except Exception as e:
|
||||||
existing_download = EngineManager.is_version_installed(self.engine, self.version, self.system_os, self.cpu,
|
logger.error(f"Error in download worker: {e}")
|
||||||
ignore_system=True)
|
finally:
|
||||||
if existing_download:
|
try:
|
||||||
logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists")
|
if EngineManager._default_instance is not None:
|
||||||
return existing_download
|
EngineManager._default_instance.download_tasks.remove(self)
|
||||||
|
except ValueError:
|
||||||
# Get the appropriate downloader class based on the engine type
|
pass
|
||||||
downloader = EngineManager.engine_class_with_name(self.engine).downloader()
|
|
||||||
downloader.download_engine( self.version, download_location=EngineManager.engines_path,
|
|
||||||
system_os=self.system_os, cpu=self.cpu, timeout=300, progress_callback=self._update_progress)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in download worker: {e}")
|
|
||||||
finally:
|
|
||||||
# remove itself from the downloader list
|
|
||||||
EngineManager.download_tasks.remove(self)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
+199
-106
@@ -1,12 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
from sqlalchemy.orm.exc import DetachedInstanceError
|
||||||
|
|
||||||
from src.engines.core.base_worker import Base, BaseRenderWorker
|
from src.engines.core.base_worker import Base, BaseRenderWorker
|
||||||
@@ -25,182 +26,274 @@ class JobNotFoundError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class RenderQueue:
|
class RenderQueue:
|
||||||
engine: Optional[create_engine] = None
|
_default_instance: Optional['RenderQueue'] = None
|
||||||
session: Optional[sessionmaker] = None
|
|
||||||
job_queue: List[BaseRenderWorker] = []
|
@classmethod
|
||||||
maximum_renderer_instances: Dict[str, int] = {'blender': 1, 'aerender': 1, 'ffmpeg': 4}
|
def _sync_class(cls) -> None:
|
||||||
last_saved_counts: Dict[str, int] = {}
|
if cls._default_instance is not None:
|
||||||
is_running: bool = False
|
pass # no class-level attributes to sync
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.engine: Optional[create_engine] = None
|
||||||
|
self.session: Optional[Session] = None
|
||||||
|
self.job_queue: List[BaseRenderWorker] = []
|
||||||
|
self.maximum_renderer_instances: Dict[str, int] = {'blender': 1, 'aerender': 1, 'ffmpeg': 4}
|
||||||
|
self.last_saved_counts: Dict[str, int] = {}
|
||||||
|
self.is_running: bool = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Render Queue Evaluation:
|
# Render Queue Evaluation:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
def _start(self) -> None:
|
||||||
def start(cls):
|
|
||||||
"""Start evaluating the render queue"""
|
|
||||||
logger.debug("Starting render queue updates")
|
logger.debug("Starting render queue updates")
|
||||||
cls.is_running = True
|
self.is_running = True
|
||||||
cls.evaluate_queue()
|
self.evaluate_queue()
|
||||||
|
|
||||||
@classmethod
|
def _evaluate_queue(self) -> None:
|
||||||
def evaluate_queue(cls):
|
|
||||||
try:
|
try:
|
||||||
not_started = cls.jobs_with_status(RenderStatus.NOT_STARTED, priority_sorted=True)
|
not_started = self.jobs_with_status(RenderStatus.NOT_STARTED, priority_sorted=True)
|
||||||
for job in not_started:
|
for job in not_started:
|
||||||
if cls.is_available_for_job(job.engine_name, job.priority):
|
if self.is_available_for_job(job.engine_name, job.priority):
|
||||||
cls.start_job(job)
|
self.start_job(job)
|
||||||
|
|
||||||
scheduled = cls.jobs_with_status(RenderStatus.SCHEDULED, priority_sorted=True)
|
scheduled = self.jobs_with_status(RenderStatus.SCHEDULED, priority_sorted=True)
|
||||||
for job in scheduled:
|
for job in scheduled:
|
||||||
if job.scheduled_start <= datetime.now():
|
if job.scheduled_start <= datetime.now():
|
||||||
logger.debug(f"Starting scheduled job: {job}")
|
logger.debug(f"Starting scheduled job: {job}")
|
||||||
cls.start_job(job)
|
self.start_job(job)
|
||||||
|
|
||||||
if cls.last_saved_counts != cls.job_counts():
|
if self.last_saved_counts != self.job_counts():
|
||||||
cls.save_state()
|
self.save_state()
|
||||||
except DetachedInstanceError:
|
except DetachedInstanceError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
def _local_job_status_changed(self, job_id: str, old_status: str, new_status: str) -> None:
|
||||||
def __local_job_status_changed(cls, job_id, old_status, new_status):
|
render_job = self.job_with_id(job_id, none_ok=True)
|
||||||
render_job = RenderQueue.job_with_id(job_id, none_ok=True)
|
if render_job and self.is_running:
|
||||||
if render_job and cls.is_running: # ignore changes from render jobs not in the queue yet
|
|
||||||
logger.debug(f"RenderQueue detected job {job_id} has changed from {old_status} -> {new_status}")
|
logger.debug(f"RenderQueue detected job {job_id} has changed from {old_status} -> {new_status}")
|
||||||
RenderQueue.evaluate_queue()
|
self.evaluate_queue()
|
||||||
|
|
||||||
@classmethod
|
def _stop(self) -> None:
|
||||||
def stop(cls):
|
|
||||||
logger.debug("Stopping render queue updates")
|
logger.debug("Stopping render queue updates")
|
||||||
cls.is_running = False
|
self.is_running = False
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Fetch Jobs:
|
# Fetch Jobs:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
def _all_jobs(self) -> List[BaseRenderWorker]:
|
||||||
def all_jobs(cls):
|
return self.job_queue
|
||||||
return cls.job_queue
|
|
||||||
|
|
||||||
@classmethod
|
def _running_jobs(self) -> List[BaseRenderWorker]:
|
||||||
def running_jobs(cls):
|
return self.jobs_with_status(RenderStatus.RUNNING)
|
||||||
return cls.jobs_with_status(RenderStatus.RUNNING)
|
|
||||||
|
|
||||||
@classmethod
|
def _pending_jobs(self) -> List[BaseRenderWorker]:
|
||||||
def pending_jobs(cls):
|
pending = self.jobs_with_status(RenderStatus.NOT_STARTED)
|
||||||
pending_jobs = cls.jobs_with_status(RenderStatus.NOT_STARTED)
|
pending.extend(self.jobs_with_status(RenderStatus.SCHEDULED))
|
||||||
pending_jobs.extend(cls.jobs_with_status(RenderStatus.SCHEDULED))
|
return pending
|
||||||
return pending_jobs
|
|
||||||
|
|
||||||
@classmethod
|
def _jobs_with_status(self, status: RenderStatus, priority_sorted: bool = False) -> List[BaseRenderWorker]:
|
||||||
def jobs_with_status(cls, status, priority_sorted=False):
|
found_jobs = [x for x in self.all_jobs() if x.status == status]
|
||||||
found_jobs = [x for x in cls.all_jobs() if x.status == status]
|
|
||||||
if priority_sorted:
|
if priority_sorted:
|
||||||
found_jobs = sorted(found_jobs, key=lambda a: a.priority, reverse=False)
|
found_jobs = sorted(found_jobs, key=lambda a: a.priority, reverse=False)
|
||||||
return found_jobs
|
return found_jobs
|
||||||
|
|
||||||
@classmethod
|
def _job_with_id(self, job_id: str, none_ok: bool = False) -> Optional[BaseRenderWorker]:
|
||||||
def job_with_id(cls, job_id, none_ok=False):
|
found_job = next((x for x in self.all_jobs() if x.id == job_id), None)
|
||||||
found_job = next((x for x in cls.all_jobs() if x.id == job_id), None)
|
|
||||||
if not found_job and not none_ok:
|
if not found_job and not none_ok:
|
||||||
raise JobNotFoundError(job_id)
|
raise JobNotFoundError(job_id)
|
||||||
return found_job
|
return found_job
|
||||||
|
|
||||||
@classmethod
|
def _job_counts(self) -> Dict[str, int]:
|
||||||
def job_counts(cls):
|
counts = Counter(x.status for x in self.all_jobs())
|
||||||
job_counts = {}
|
return {s.value: counts.get(s, 0) for s in RenderStatus}
|
||||||
for job_status in RenderStatus:
|
|
||||||
job_counts[job_status.value] = len(cls.jobs_with_status(job_status))
|
|
||||||
return job_counts
|
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Startup / Shutdown:
|
# Startup / Shutdown:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
def _load_state(self, database_directory: Path) -> None:
|
||||||
def load_state(cls, database_directory: Path):
|
self.engine = create_engine(f"sqlite:///{database_directory / 'database.db'}")
|
||||||
if not cls.engine:
|
Base.metadata.create_all(self.engine)
|
||||||
cls.engine = create_engine(f"sqlite:///{database_directory / 'database.db'}")
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
Base.metadata.create_all(cls.engine)
|
from src.engines.core.base_worker import BaseRenderWorker
|
||||||
cls.session = sessionmaker(bind=cls.engine)()
|
self.job_queue = self.session.query(BaseRenderWorker).all()
|
||||||
cls.job_queue = cls.session.query(BaseRenderWorker).all()
|
pub.subscribe(self._local_job_status_changed, 'status_change')
|
||||||
pub.subscribe(cls.__local_job_status_changed, 'status_change')
|
|
||||||
|
|
||||||
@classmethod
|
def _save_state(self) -> None:
|
||||||
def save_state(cls):
|
if self.session:
|
||||||
cls.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
@classmethod
|
def _prepare_for_shutdown(self) -> None:
|
||||||
def prepare_for_shutdown(cls):
|
|
||||||
logger.debug("Closing session")
|
logger.debug("Closing session")
|
||||||
cls.stop()
|
self.stop()
|
||||||
running_jobs = cls.jobs_with_status(RenderStatus.RUNNING) # cancel all running jobs
|
running_jobs = self.jobs_with_status(RenderStatus.RUNNING)
|
||||||
_ = [cls.cancel_job(job) for job in running_jobs]
|
for job in running_jobs:
|
||||||
cls.save_state()
|
self.cancel_job(job)
|
||||||
cls.session.close()
|
self.save_state()
|
||||||
|
if self.session:
|
||||||
|
self.session.close()
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Renderer Availability:
|
# Renderer Availability:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
def renderer_instances(self) -> Counter:
|
||||||
def renderer_instances(cls):
|
all_instances = [x.engine_name for x in self.running_jobs()]
|
||||||
all_instances = [x.engine_name for x in cls.running_jobs()]
|
|
||||||
return Counter(all_instances)
|
return Counter(all_instances)
|
||||||
|
|
||||||
@classmethod
|
def _is_available_for_job(self, renderer: str, priority: int = 2) -> bool:
|
||||||
def is_available_for_job(cls, renderer, priority=2):
|
instances = self.renderer_instances()
|
||||||
|
higher_priority_jobs = [x for x in self.running_jobs() if x.priority < priority]
|
||||||
instances = cls.renderer_instances()
|
max_allowed_instances = self.maximum_renderer_instances.get(renderer, 1)
|
||||||
higher_priority_jobs = [x for x in cls.running_jobs() if x.priority < priority]
|
maxed_out_instances = renderer in instances and instances[renderer] >= max_allowed_instances
|
||||||
max_allowed_instances = cls.maximum_renderer_instances.get(renderer, 1)
|
|
||||||
maxed_out_instances = renderer in instances.keys() and instances[renderer] >= max_allowed_instances
|
|
||||||
return not maxed_out_instances and not higher_priority_jobs
|
return not maxed_out_instances and not higher_priority_jobs
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Job Lifecycle Management:
|
# Job Lifecycle Management:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
@classmethod
|
def _add_to_render_queue(self, render_job: BaseRenderWorker, force_start: bool = False) -> None:
|
||||||
def add_to_render_queue(cls, render_job, force_start=False):
|
|
||||||
logger.info(f"Adding job to render queue: {render_job}")
|
logger.info(f"Adding job to render queue: {render_job}")
|
||||||
cls.job_queue.append(render_job)
|
with self._lock:
|
||||||
if cls.is_running and force_start and render_job.status in (RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED):
|
self.job_queue.append(render_job)
|
||||||
cls.start_job(render_job)
|
if force_start and render_job.status in (RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED):
|
||||||
cls.session.add(render_job)
|
self.start_job(render_job)
|
||||||
cls.save_state()
|
self.session.add(render_job)
|
||||||
if cls.is_running:
|
self.save_state()
|
||||||
cls.evaluate_queue()
|
if self.is_running:
|
||||||
|
self.evaluate_queue()
|
||||||
|
|
||||||
@classmethod
|
def _start_job(self, job: BaseRenderWorker) -> None:
|
||||||
def start_job(cls, job):
|
|
||||||
logger.info(f'Starting job: {job}')
|
logger.info(f'Starting job: {job}')
|
||||||
job.start()
|
job.start()
|
||||||
cls.save_state()
|
self.save_state()
|
||||||
|
|
||||||
@classmethod
|
def _cancel_job(self, job: BaseRenderWorker) -> bool:
|
||||||
def cancel_job(cls, job):
|
|
||||||
logger.info(f'Cancelling job: {job}')
|
logger.info(f'Cancelling job: {job}')
|
||||||
job.stop()
|
job.stop()
|
||||||
return job.status == RenderStatus.CANCELLED
|
return job.status == RenderStatus.CANCELLED
|
||||||
|
|
||||||
@classmethod
|
def _delete_job(self, job: BaseRenderWorker) -> bool:
|
||||||
def delete_job(cls, job):
|
|
||||||
logger.info(f"Deleting job: {job}")
|
logger.info(f"Deleting job: {job}")
|
||||||
job.stop()
|
with self._lock:
|
||||||
cls.job_queue.remove(job)
|
job.stop()
|
||||||
cls.session.delete(job)
|
self.job_queue.remove(job)
|
||||||
cls.save_state()
|
self.session.delete(job)
|
||||||
|
self.save_state()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Miscellaneous:
|
# Miscellaneous:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
|
def _clear_history(self) -> None:
|
||||||
|
for job in list(self.all_jobs()):
|
||||||
|
if job.status in (RenderStatus.CANCELLED, RenderStatus.COMPLETED, RenderStatus.ERROR):
|
||||||
|
self.delete_job(job)
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
|
# --- Forwarders for backward compatibility ---
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def start(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def evaluate_queue(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._evaluate_queue()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def stop(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._stop()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def all_jobs(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance.job_queue
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def running_jobs(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._running_jobs()
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def pending_jobs(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._pending_jobs()
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def jobs_with_status(cls, status, priority_sorted=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._jobs_with_status(status, priority_sorted)
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def job_with_id(cls, job_id, none_ok=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._job_with_id(job_id, none_ok)
|
||||||
|
if not none_ok:
|
||||||
|
raise JobNotFoundError(job_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def job_counts(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._job_counts()
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_state(cls, database_directory):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._load_state(database_directory)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def save_state(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._save_state()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def prepare_for_shutdown(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._prepare_for_shutdown()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available_for_job(cls, renderer, priority=2):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._is_available_for_job(renderer, priority)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_to_render_queue(cls, render_job, force_start=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._add_to_render_queue(render_job, force_start)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def start_job(cls, job):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._start_job(job)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cancel_job(cls, job):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._cancel_job(job)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_job(cls, job):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._delete_job(job)
|
||||||
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_history(cls):
|
def clear_history(cls):
|
||||||
to_remove = [x for x in cls.all_jobs() if x.status in [RenderStatus.CANCELLED,
|
if cls._default_instance is not None:
|
||||||
RenderStatus.COMPLETED, RenderStatus.ERROR]]
|
cls._default_instance._clear_history()
|
||||||
for job_to_remove in to_remove:
|
|
||||||
cls.delete_job(job_to_remove)
|
|
||||||
cls.save_state()
|
|
||||||
|
|||||||
+33
-12
@@ -1,8 +1,11 @@
|
|||||||
|
import logging
|
||||||
import socket
|
import socket
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal, Qt, pyqtSlot
|
from PyQt6.QtCore import QThread, pyqtSignal, Qt, pyqtSlot
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox,
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox,
|
||||||
QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem,
|
QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit, QDoubleSpinBox, QMessageBox, QListWidget, QListWidgetItem,
|
||||||
@@ -63,7 +66,7 @@ class NewRenderJobForm(QWidget):
|
|||||||
# Job / Server Data
|
# Job / Server Data
|
||||||
self.server_proxy = RenderServerProxy(socket.gethostname())
|
self.server_proxy = RenderServerProxy(socket.gethostname())
|
||||||
self.project_info = None
|
self.project_info = None
|
||||||
self.installed_engines = {}
|
self.installed_engines = []
|
||||||
self.preferred_engine = None
|
self.preferred_engine = None
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
@@ -130,6 +133,7 @@ class NewRenderJobForm(QWidget):
|
|||||||
server_list_layout = QHBoxLayout()
|
server_list_layout = QHBoxLayout()
|
||||||
server_list_layout.addWidget(QLabel("Render Target:"))
|
server_list_layout.addWidget(QLabel("Render Target:"))
|
||||||
self.server_input = QComboBox()
|
self.server_input = QComboBox()
|
||||||
|
self.server_input.currentTextChanged.connect(self.server_changed)
|
||||||
server_list_layout.addWidget(self.server_input)
|
server_list_layout.addWidget(self.server_input)
|
||||||
project_layout.addLayout(server_list_layout)
|
project_layout.addLayout(server_list_layout)
|
||||||
|
|
||||||
@@ -306,7 +310,7 @@ class NewRenderJobForm(QWidget):
|
|||||||
|
|
||||||
def update_job_count(self, changed_item=None):
|
def update_job_count(self, changed_item=None):
|
||||||
checked = 1
|
checked = 1
|
||||||
if self.cameras_group.enabled:
|
if self.cameras_group.isEnabled():
|
||||||
checked = 0
|
checked = 0
|
||||||
total = self.cameras_list.count()
|
total = self.cameras_list.count()
|
||||||
|
|
||||||
@@ -342,7 +346,7 @@ class NewRenderJobForm(QWidget):
|
|||||||
self.engine_version_combo.addItem('latest')
|
self.engine_version_combo.addItem('latest')
|
||||||
self.file_format_combo.clear()
|
self.file_format_combo.clear()
|
||||||
if current_engine:
|
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', [])
|
self.current_engine_options = engine_info.get('options', [])
|
||||||
if not engine_info:
|
if not engine_info:
|
||||||
raise FileNotFoundError(f"Cannot get information about engine '{current_engine}'")
|
raise FileNotFoundError(f"Cannot get information about engine '{current_engine}'")
|
||||||
@@ -350,10 +354,27 @@ class NewRenderJobForm(QWidget):
|
|||||||
self.engine_version_combo.addItems(engine_vers)
|
self.engine_version_combo.addItems(engine_vers)
|
||||||
self.file_format_combo.addItems(engine_info.get('supported_export_formats'))
|
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):
|
def update_server_list(self):
|
||||||
|
current_hostname = self.server_input.currentText()
|
||||||
clients = ZeroconfServer.found_hostnames()
|
clients = ZeroconfServer.found_hostnames()
|
||||||
self.server_input.clear()
|
self.server_input.clear()
|
||||||
self.server_input.addItems(clients)
|
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):
|
def browse_scene_file(self):
|
||||||
file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
|
file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File")
|
||||||
@@ -383,7 +404,7 @@ class NewRenderJobForm(QWidget):
|
|||||||
self.job_name_input.setText(directory)
|
self.job_name_input.setText(directory)
|
||||||
|
|
||||||
def args_help_button_clicked(self):
|
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')
|
f'{self.engine_type.currentText()}/help')
|
||||||
self.engine_help_viewer = EngineHelpViewer(url)
|
self.engine_help_viewer = EngineHelpViewer(url)
|
||||||
self.engine_help_viewer.show()
|
self.engine_help_viewer.show()
|
||||||
@@ -401,7 +422,7 @@ class NewRenderJobForm(QWidget):
|
|||||||
"""Called by the GetProjectInfoWorker - Do not call directly."""
|
"""Called by the GetProjectInfoWorker - Do not call directly."""
|
||||||
try:
|
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_type.setCurrentText(self.preferred_engine)
|
||||||
self.engine_changed()
|
self.engine_changed()
|
||||||
|
|
||||||
@@ -463,8 +484,8 @@ class NewRenderJobForm(QWidget):
|
|||||||
text_box = QLineEdit()
|
text_box = QLineEdit()
|
||||||
h_layout.addWidget(text_box)
|
h_layout.addWidget(text_box)
|
||||||
self.engine_options_layout.addLayout(h_layout)
|
self.engine_options_layout.addLayout(h_layout)
|
||||||
except AttributeError:
|
except AttributeError as e:
|
||||||
pass
|
logger.error(f"AttributeError in post_get_project_info_update: {e}")
|
||||||
|
|
||||||
def toggle_engine_enablement(self, enabled=False):
|
def toggle_engine_enablement(self, enabled=False):
|
||||||
"""Toggle on/off all the render settings"""
|
"""Toggle on/off all the render settings"""
|
||||||
@@ -580,7 +601,7 @@ class SubmitWorker(QThread):
|
|||||||
|
|
||||||
# determine if any cameras are checked
|
# determine if any cameras are checked
|
||||||
selected_cameras = []
|
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()):
|
for index in range(self.window.cameras_list.count()):
|
||||||
item = self.window.cameras_list.item(index)
|
item = self.window.cameras_list.item(index)
|
||||||
if item.checkState() == Qt.CheckState.Checked:
|
if item.checkState() == Qt.CheckState.Checked:
|
||||||
@@ -592,7 +613,7 @@ class SubmitWorker(QThread):
|
|||||||
children_jobs = []
|
children_jobs = []
|
||||||
for cam in selected_cameras:
|
for cam in selected_cameras:
|
||||||
child_job_data = dict()
|
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['args']['camera'] = cam
|
||||||
child_job_data['name'] = job_json['name'].replace(' ', '-') + "_" + cam.replace(' ', '')
|
child_job_data['name'] = job_json['name'].replace(' ', '-') + "_" + cam.replace(' ', '')
|
||||||
child_job_data['output_path'] = child_job_data['name']
|
child_job_data['output_path'] = child_job_data['name']
|
||||||
@@ -605,8 +626,8 @@ class SubmitWorker(QThread):
|
|||||||
input_path = Path(latest_engine.perform_presubmission_tasks(input_path))
|
input_path = Path(latest_engine.perform_presubmission_tasks(input_path))
|
||||||
# submit
|
# submit
|
||||||
err_msg = ""
|
err_msg = ""
|
||||||
result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_data=job_json,
|
result = self.window.server_proxy.create_job(file_path=input_path, job_data=job_json,
|
||||||
callback=create_callback)
|
callback=create_callback)
|
||||||
if not (result and result.ok):
|
if not (result and result.ok):
|
||||||
err_msg = f"Error posting job to server: {result.text}"
|
err_msg = f"Error posting job to server: {result.text}"
|
||||||
|
|
||||||
@@ -630,7 +651,7 @@ class GetProjectInfoWorker(QThread):
|
|||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
# get the engine info and add them all to the ui
|
# 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
|
# select the best engine for the file type
|
||||||
self.window.preferred_engine = self.window.server_proxy.get_engine_for_filename(self.project_path)
|
self.window.preferred_engine = self.window.server_proxy.get_engine_for_filename(self.project_path)
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class EngineBrowserWindow(QMainWindow):
|
|||||||
def update_table(self):
|
def update_table(self):
|
||||||
|
|
||||||
def update_table_worker():
|
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:
|
if not raw_server_data:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -158,7 +158,9 @@ class EngineBrowserWindow(QMainWindow):
|
|||||||
if reply is not QMessageBox.StandardButton.Yes:
|
if reply is not QMessageBox.StandardButton.Yes:
|
||||||
return
|
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:
|
if result.ok:
|
||||||
self.update_table()
|
self.update_table()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ class EngineHelpViewer(QMainWindow):
|
|||||||
self.fetch_help()
|
self.fetch_help()
|
||||||
|
|
||||||
def fetch_help(self):
|
def fetch_help(self):
|
||||||
result = requests.get(self.help_path)
|
result = requests.get(self.help_path, timeout=10)
|
||||||
self.text_edit.setPlainText(result.text)
|
self.text_edit.setPlainText(result.text)
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ class LogViewer(QMainWindow):
|
|||||||
self.fetch_logs()
|
self.fetch_logs()
|
||||||
|
|
||||||
def fetch_logs(self):
|
def fetch_logs(self):
|
||||||
result = requests.get(self.log_path)
|
result = requests.get(self.log_path, timeout=10)
|
||||||
self.text_edit.setPlainText(result.text)
|
self.text_edit.setPlainText(result.text)
|
||||||
|
|||||||
+50
-48
@@ -229,27 +229,22 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def server_picked(self):
|
def server_picked(self):
|
||||||
"""Update the UI elements relevant to the server selection."""
|
"""Update the UI elements relevant to the server selection."""
|
||||||
try:
|
current_item = self.server_list_view.currentItem()
|
||||||
# Retrieve the new hostname selected by the user
|
if current_item is None:
|
||||||
new_hostname = self.server_list_view.currentItem().text()
|
return
|
||||||
|
|
||||||
# Check if the hostname has changed to avoid unnecessary updates
|
new_hostname = current_item.text()
|
||||||
if new_hostname != self.current_hostname:
|
if new_hostname == self.current_hostname:
|
||||||
# Update the current hostname and clear the job list
|
return
|
||||||
self.current_hostname = new_hostname
|
|
||||||
self.job_list_view.setRowCount(0)
|
|
||||||
self.refresh_job_list()
|
|
||||||
|
|
||||||
# Select the first row if there are jobs listed
|
self.current_hostname = new_hostname
|
||||||
if self.job_list_view.rowCount():
|
self.job_list_view.setRowCount(0)
|
||||||
self.job_list_view.selectRow(0)
|
self.refresh_job_list()
|
||||||
|
|
||||||
# Update server information display
|
if self.job_list_view.rowCount():
|
||||||
self.update_server_info_display(new_hostname)
|
self.job_list_view.selectRow(0)
|
||||||
|
|
||||||
except AttributeError:
|
self.update_server_info_display(new_hostname)
|
||||||
# Handle cases where the server list view might not be properly initialized
|
|
||||||
pass
|
|
||||||
|
|
||||||
def update_server_info_display(self, hostname):
|
def update_server_info_display(self, hostname):
|
||||||
"""Updates the server information section of the UI."""
|
"""Updates the server information section of the UI."""
|
||||||
@@ -298,34 +293,36 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
server_job_data = self.job_data.get(self.current_server_proxy.hostname)
|
server_job_data = self.job_data.get(self.current_server_proxy.hostname)
|
||||||
if server_job_data:
|
if server_job_data is None:
|
||||||
num_jobs = len(server_job_data)
|
return
|
||||||
self.job_list_view.setRowCount(num_jobs)
|
|
||||||
|
|
||||||
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 \
|
for row, job in enumerate(server_job_data):
|
||||||
('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status
|
|
||||||
tags = (job['status'],)
|
|
||||||
start_time = datetime.datetime.fromisoformat(job['start_time']) if job['start_time'] else None
|
|
||||||
end_time = datetime.datetime.fromisoformat(job['end_time']) if job['end_time'] else None
|
|
||||||
|
|
||||||
time_elapsed = "" if (job['status'] != RenderStatus.RUNNING.value and not end_time) else \
|
display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \
|
||||||
get_time_elapsed(start_time, end_time)
|
('%.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', ''))
|
time_elapsed = "" if (job['status'] != RenderStatus.RUNNING.value and not end_time) else \
|
||||||
engine_name = f"{job.get('engine', '')}-{job.get('engine_version')}"
|
get_time_elapsed(start_time, end_time)
|
||||||
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)
|
|
||||||
|
|
||||||
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(engine_name),
|
name = job.get('name') or os.path.basename(job.get('input_path', ''))
|
||||||
QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed),
|
engine_name = f"{job.get('engine', '')}-{job.get('engine_version')}"
|
||||||
QTableWidgetItem(total_frames), QTableWidgetItem(humanized_time)]
|
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):
|
items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(engine_name),
|
||||||
self.job_list_view.setItem(row, col, item)
|
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 -- #
|
# -- Job Code -- #
|
||||||
def job_picked(self):
|
def job_picked(self):
|
||||||
@@ -335,7 +332,7 @@ class MainWindow(QMainWindow):
|
|||||||
default_image_path = "error.png"
|
default_image_path = "error.png"
|
||||||
before_fetch_hostname = self.current_server_proxy.hostname
|
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:
|
if response.ok:
|
||||||
try:
|
try:
|
||||||
with io.BytesIO(response.content) as image_data_stream:
|
with io.BytesIO(response.content) as image_data_stream:
|
||||||
@@ -405,7 +402,8 @@ class MainWindow(QMainWindow):
|
|||||||
id_item = self.job_list_view.item(selected_row.row(), 0)
|
id_item = self.job_list_view.item(selected_row.row(), 0)
|
||||||
job_ids.append(id_item.text())
|
job_ids.append(id_item.text())
|
||||||
return job_ids
|
return job_ids
|
||||||
except AttributeError:
|
except AttributeError as e:
|
||||||
|
logger.error(f"AttributeError in selected_job_ids: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -494,6 +492,9 @@ class MainWindow(QMainWindow):
|
|||||||
if not old_count and self.server_list_view.count():
|
if not old_count and self.server_list_view.count():
|
||||||
self.server_list_view.setCurrentRow(0)
|
self.server_list_view.setCurrentRow(0)
|
||||||
self.server_picked()
|
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:
|
def create_toolbars(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -547,7 +548,8 @@ class MainWindow(QMainWindow):
|
|||||||
"""
|
"""
|
||||||
selected_job_ids = self.selected_job_ids()
|
selected_job_ids = self.selected_job_ids()
|
||||||
if 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 = LogViewer(url)
|
||||||
self.log_viewer_window.show()
|
self.log_viewer_window.show()
|
||||||
|
|
||||||
@@ -562,7 +564,7 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if len(job_ids) == 1:
|
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:
|
if job:
|
||||||
display_name = job.get('name', os.path.basename(job.get('input_path', '')))
|
display_name = job.get('name', os.path.basename(job.get('input_path', '')))
|
||||||
message = f"Are you sure you want to stop job: {display_name}?"
|
message = f"Are you sure you want to stop job: {display_name}?"
|
||||||
@@ -591,7 +593,7 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if len(job_ids) == 1:
|
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:
|
if job:
|
||||||
display_name = job.get('name', os.path.basename(job.get('input_path', '')))
|
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}?"
|
message = f"Are you sure you want to delete the job:\n{display_name}?"
|
||||||
@@ -616,8 +618,8 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
import webbrowser
|
import webbrowser
|
||||||
download_url = (f"http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}"
|
download_url = (f'http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}'
|
||||||
f"/api/job/{job_ids[0]}/download_all")
|
f'/api/jobs/{job_ids[0]}/download_all')
|
||||||
webbrowser.open(download_url)
|
webbrowser.open(download_url)
|
||||||
|
|
||||||
def open_files(self, event):
|
def open_files(self, event):
|
||||||
@@ -626,7 +628,7 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for job_id in job_ids:
|
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'])
|
path = os.path.dirname(job_info['output_path'])
|
||||||
launch_url(path)
|
launch_url(path)
|
||||||
|
|
||||||
@@ -665,7 +667,7 @@ class BackgroundUpdater(QThread):
|
|||||||
ZeroconfServer.get_hostname_properties(x)['api_version'] == API_VERSION]
|
ZeroconfServer.get_hostname_properties(x)['api_version'] == API_VERSION]
|
||||||
if self.window.current_server_proxy:
|
if self.window.current_server_proxy:
|
||||||
self.window.job_data[self.window.current_server_proxy.hostname] = \
|
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.needs_update = False
|
||||||
self.updated_signal.emit()
|
self.updated_signal.emit()
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class GetEngineInfoWorker(QThread):
|
|||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
data = RenderServerProxy(socket.gethostname()).get_all_engine_info()
|
data = RenderServerProxy(socket.gethostname()).get_engines()
|
||||||
self.done.emit(data)
|
self.done.emit(data)
|
||||||
|
|
||||||
class SettingsWindow(QMainWindow):
|
class SettingsWindow(QMainWindow):
|
||||||
|
|||||||
+34
-15
@@ -1,12 +1,24 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from src.utilities.misc_helper import current_system_os, copy_directory_contents
|
from src.utilities.misc_helper import current_system_os, copy_directory_contents
|
||||||
|
|
||||||
|
_CONFIG_ATTRS = [
|
||||||
|
'upload_folder', 'update_engines_on_launch', 'max_content_path',
|
||||||
|
'server_log_level', 'log_buffer_length', 'worker_process_timeout',
|
||||||
|
'flask_log_level', 'flask_debug_enable', 'queue_eval_seconds',
|
||||||
|
'port_number', 'enable_split_jobs', 'download_timeout_seconds',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# Initialize class variables with default values
|
_default_instance: Optional['Config'] = None
|
||||||
|
|
||||||
|
# Class-level defaults — mutated by _sync_class() so existing
|
||||||
|
# callers (Config.upload_folder) continue to work during the
|
||||||
|
# migration to instance-based access.
|
||||||
upload_folder = "~/zordon-uploads/"
|
upload_folder = "~/zordon-uploads/"
|
||||||
update_engines_on_launch = True
|
update_engines_on_launch = True
|
||||||
max_content_path = 100000000
|
max_content_path = 100000000
|
||||||
@@ -20,23 +32,30 @@ class Config:
|
|||||||
enable_split_jobs = True
|
enable_split_jobs = True
|
||||||
download_timeout_seconds = 120
|
download_timeout_seconds = 120
|
||||||
|
|
||||||
@classmethod
|
def __init__(self) -> None:
|
||||||
def load_config(cls, config_path):
|
for attr in _CONFIG_ATTRS:
|
||||||
|
setattr(self, attr, getattr(Config, attr))
|
||||||
|
|
||||||
|
def load(self, config_path: Path) -> None:
|
||||||
with open(config_path, 'r') as ymlfile:
|
with open(config_path, 'r') as ymlfile:
|
||||||
cfg = yaml.safe_load(ymlfile)
|
cfg = yaml.safe_load(ymlfile)
|
||||||
|
for attr in _CONFIG_ATTRS:
|
||||||
|
if attr in cfg:
|
||||||
|
setattr(self, attr, cfg[attr])
|
||||||
|
self.upload_folder = str(Path(self.upload_folder).expanduser())
|
||||||
|
|
||||||
cls.upload_folder = str(Path(cfg.get('upload_folder', cls.upload_folder)).expanduser())
|
@classmethod
|
||||||
cls.update_engines_on_launch = cfg.get('update_engines_on_launch', cls.update_engines_on_launch)
|
def _sync_class(cls) -> None:
|
||||||
cls.max_content_path = cfg.get('max_content_path', cls.max_content_path)
|
if cls._default_instance is not None:
|
||||||
cls.server_log_level = cfg.get('server_log_level', cls.server_log_level)
|
for attr in _CONFIG_ATTRS:
|
||||||
cls.log_buffer_length = cfg.get('log_buffer_length', cls.log_buffer_length)
|
setattr(cls, attr, getattr(cls._default_instance, attr))
|
||||||
cls.worker_process_timeout = cfg.get('worker_process_timeout', cls.worker_process_timeout)
|
|
||||||
cls.flask_log_level = cfg.get('flask_log_level', cls.flask_log_level)
|
@classmethod
|
||||||
cls.flask_debug_enable = cfg.get('flask_debug_enable', cls.flask_debug_enable)
|
def load_config(cls, config_path: Path) -> None:
|
||||||
cls.queue_eval_seconds = cfg.get('queue_eval_seconds', cls.queue_eval_seconds)
|
instance = Config()
|
||||||
cls.port_number = cfg.get('port_number', cls.port_number)
|
instance.load(config_path)
|
||||||
cls.enable_split_jobs = cfg.get('enable_split_jobs', cls.enable_split_jobs)
|
cls._default_instance = instance
|
||||||
cls.download_timeout_seconds = cfg.get('download_timeout_seconds', cls.download_timeout_seconds)
|
cls._sync_class()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def config_dir(cls) -> Path:
|
def config_dir(cls) -> Path:
|
||||||
|
|||||||
@@ -340,55 +340,56 @@ def get_gpu_info() -> List[Dict[str, Any]]:
|
|||||||
else: # Assume Linux or other
|
else: # Assume Linux or other
|
||||||
return get_linux_gpu_info()
|
return get_linux_gpu_info()
|
||||||
|
|
||||||
|
|
||||||
COMMON_RESOLUTIONS = {
|
COMMON_RESOLUTIONS = {
|
||||||
# SD
|
# SD
|
||||||
"SD_480p": (640, 480),
|
"SD_480p": (640, 480),
|
||||||
"NTSC_DVD": (720, 480),
|
"NTSC_DVD": (720, 480),
|
||||||
"PAL_DVD": (720, 576),
|
"PAL_DVD": (720, 576),
|
||||||
|
|
||||||
# HD
|
# HD
|
||||||
"HD_720p": (1280, 720),
|
"HD_720p": (1280, 720),
|
||||||
"HD_900p": (1600, 900),
|
"HD_900p": (1600, 900),
|
||||||
"HD_1080p": (1920, 1080),
|
"HD_1080p": (1920, 1080),
|
||||||
|
|
||||||
# Cinema / Film
|
# Cinema / Film
|
||||||
"2K_DCI": (2048, 1080),
|
"2K_DCI": (2048, 1080),
|
||||||
"4K_DCI": (4096, 2160),
|
"4K_DCI": (4096, 2160),
|
||||||
|
|
||||||
# UHD / Consumer
|
# UHD / Consumer
|
||||||
"UHD_4K": (3840, 2160),
|
"UHD_4K": (3840, 2160),
|
||||||
"UHD_5K": (5120, 2880),
|
"UHD_5K": (5120, 2880),
|
||||||
"UHD_8K": (7680, 4320),
|
"UHD_8K": (7680, 4320),
|
||||||
|
|
||||||
# Ultrawide / Aspect Variants
|
# Ultrawide / Aspect Variants
|
||||||
"UW_1080p": (2560, 1080),
|
"UW_1080p": (2560, 1080),
|
||||||
"UW_1440p": (3440, 1440),
|
"UW_1440p": (3440, 1440),
|
||||||
"UW_5K": (5120, 2160),
|
"UW_5K": (5120, 2160),
|
||||||
|
|
||||||
# Mobile / Social
|
# Mobile / Social
|
||||||
"VERTICAL_1080x1920": (1080, 1920),
|
"VERTICAL_1080x1920": (1080, 1920),
|
||||||
"SQUARE_1080": (1080, 1080),
|
"SQUARE_1080": (1080, 1080),
|
||||||
|
|
||||||
# Classic / Legacy
|
# Classic / Legacy
|
||||||
"VGA": (640, 480),
|
"VGA": (640, 480),
|
||||||
"SVGA": (800, 600),
|
"SVGA": (800, 600),
|
||||||
"XGA": (1024, 768),
|
"XGA": (1024, 768),
|
||||||
"WXGA": (1280, 800),
|
"WXGA": (1280, 800),
|
||||||
}
|
}
|
||||||
|
|
||||||
COMMON_FRAME_RATES = {
|
COMMON_FRAME_RATES = {
|
||||||
"23.976 (NTSC Film)": 23.976,
|
"23.976 (NTSC Film)": 23.976,
|
||||||
"24 (Cinema)": 24.0,
|
"24 (Cinema)": 24.0,
|
||||||
"25 (PAL)": 25.0,
|
"25 (PAL)": 25.0,
|
||||||
"29.97 (NTSC)": 29.97,
|
"29.97 (NTSC)": 29.97,
|
||||||
"30": 30.0,
|
"30": 30.0,
|
||||||
"48 (HFR Film)": 48.0,
|
"48 (HFR Film)": 48.0,
|
||||||
"50 (PAL HFR)": 50.0,
|
"50 (PAL HFR)": 50.0,
|
||||||
"59.94": 59.94,
|
"59.94": 59.94,
|
||||||
"60": 60.0,
|
"60": 60.0,
|
||||||
"72": 72.0,
|
"72": 72.0,
|
||||||
"90 (VR)": 90.0,
|
"90 (VR)": 90.0,
|
||||||
"120": 120.0,
|
"120": 120.0,
|
||||||
"144 (Gaming)": 144.0,
|
"144 (Gaming)": 144.0,
|
||||||
"240 (HFR)": 240.0,
|
"240 (HFR)": 240.0,
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ def download_missing_frames_from_subjob(local_job, subjob_id, subjob_hostname):
|
|||||||
try:
|
try:
|
||||||
local_files = [os.path.basename(x) for x in local_job.file_list()]
|
local_files = [os.path.basename(x) for x in local_job.file_list()]
|
||||||
subjob_proxy = RenderServerProxy(subjob_hostname)
|
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:
|
for subjob_filename in subjob_files:
|
||||||
if subjob_filename not in local_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):
|
def fetch_benchmark(server):
|
||||||
try:
|
try:
|
||||||
benchmark = requests.get(f'http://{server["hostname"]}:{ZeroconfServer.server_port}'
|
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
|
server['cpu_benchmark'] = benchmark
|
||||||
logger.debug(f'Benchmark for {server["hostname"]}: {benchmark}')
|
logger.debug(f'Benchmark for {server["hostname"]}: {benchmark}')
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange, NonUniqueNameException, \
|
from zeroconf import Zeroconf, ServiceInfo, ServiceBrowser, ServiceStateChange, NonUniqueNameException, \
|
||||||
@@ -9,105 +10,144 @@ logger = logging.getLogger()
|
|||||||
|
|
||||||
|
|
||||||
class ZeroconfServer:
|
class ZeroconfServer:
|
||||||
service_type = None
|
_default_instance: Optional['ZeroconfServer'] = None
|
||||||
server_name = None
|
|
||||||
server_port = None
|
|
||||||
server_ip = None
|
|
||||||
zeroconf = Zeroconf()
|
|
||||||
service_info = None
|
|
||||||
client_cache = {}
|
|
||||||
properties = {}
|
|
||||||
|
|
||||||
@classmethod
|
service_type: Optional[str] = None
|
||||||
def configure(cls, service_type, server_name, server_port):
|
server_name: Optional[str] = None
|
||||||
cls.service_type = service_type
|
server_port: Optional[int] = None
|
||||||
cls.server_name = server_name
|
properties: Dict = {}
|
||||||
cls.server_port = server_port
|
|
||||||
try: # Stop any previously running instances
|
def __init__(self) -> None:
|
||||||
|
self.service_type: Optional[str] = None
|
||||||
|
self.server_name: Optional[str] = None
|
||||||
|
self.server_port: Optional[int] = None
|
||||||
|
self.server_ip: Optional[str] = None
|
||||||
|
self.zeroconf: Zeroconf = Zeroconf()
|
||||||
|
self.service_info: Optional[ServiceInfo] = None
|
||||||
|
self.client_cache: Dict = {}
|
||||||
|
self.properties: Dict = {}
|
||||||
|
|
||||||
|
def _configure(self, service_type: str, server_name: str, server_port: int) -> None:
|
||||||
|
self.service_type = service_type
|
||||||
|
self.server_name = server_name
|
||||||
|
self.server_port = server_port
|
||||||
|
try:
|
||||||
socket.gethostbyname(socket.gethostname())
|
socket.gethostbyname(socket.gethostname())
|
||||||
except socket.gaierror:
|
except socket.gaierror:
|
||||||
cls.stop()
|
self.stop()
|
||||||
|
|
||||||
@classmethod
|
def _start(self, listen_only: bool = False) -> None:
|
||||||
def start(cls, listen_only=False):
|
if not self.service_type:
|
||||||
if not cls.service_type:
|
|
||||||
raise RuntimeError("The 'configure' method must be run before starting the zeroconf server")
|
raise RuntimeError("The 'configure' method must be run before starting the zeroconf server")
|
||||||
elif not listen_only:
|
if not listen_only:
|
||||||
logger.debug(f"Starting zeroconf service")
|
logger.debug("Starting zeroconf service")
|
||||||
cls._register_service()
|
self._register_service()
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Starting zeroconf service - Listen only mode")
|
logger.debug("Starting zeroconf service - Listen only mode")
|
||||||
cls._browse_services()
|
self._browse_services()
|
||||||
|
|
||||||
@classmethod
|
def _stop(self) -> None:
|
||||||
def stop(cls):
|
|
||||||
logger.debug("Stopping zeroconf service")
|
logger.debug("Stopping zeroconf service")
|
||||||
cls._unregister_service()
|
self._unregister_service()
|
||||||
cls.zeroconf.close()
|
if self.zeroconf:
|
||||||
|
self.zeroconf.close()
|
||||||
|
|
||||||
@classmethod
|
def _register_service(self) -> None:
|
||||||
def _register_service(cls):
|
|
||||||
try:
|
try:
|
||||||
cls.server_ip = socket.gethostbyname(socket.gethostname())
|
self.server_ip = socket.gethostbyname(socket.gethostname())
|
||||||
|
|
||||||
info = ServiceInfo(
|
info = ServiceInfo(
|
||||||
cls.service_type,
|
self.service_type,
|
||||||
f"{cls.server_name}.{cls.service_type}",
|
f"{self.server_name}.{self.service_type}",
|
||||||
addresses=[socket.inet_aton(cls.server_ip)],
|
addresses=[socket.inet_aton(self.server_ip)],
|
||||||
port=cls.server_port,
|
port=self.server_port,
|
||||||
properties=cls.properties,
|
properties=self.properties,
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.service_info = info
|
self.service_info = info
|
||||||
cls.zeroconf.register_service(info)
|
self.zeroconf.register_service(info)
|
||||||
logger.info(f"Registered zeroconf service: {cls.service_info.name}")
|
logger.info(f"Registered zeroconf service: {self.service_info.name}")
|
||||||
except (NonUniqueNameException, socket.gaierror) as e:
|
except (NonUniqueNameException, socket.gaierror) as e:
|
||||||
logger.error(f"Error establishing zeroconf: {e}")
|
logger.error(f"Error establishing zeroconf: {e}")
|
||||||
|
|
||||||
@classmethod
|
def _unregister_service(self) -> None:
|
||||||
def _unregister_service(cls):
|
if self.service_info:
|
||||||
if cls.service_info:
|
self.zeroconf.unregister_service(self.service_info)
|
||||||
cls.zeroconf.unregister_service(cls.service_info)
|
logger.info(f"Unregistered zeroconf service: {self.service_info.name}")
|
||||||
logger.info(f"Unregistered zeroconf service: {cls.service_info.name}")
|
self.service_info = None
|
||||||
cls.service_info = None
|
|
||||||
|
|
||||||
@classmethod
|
def _browse_services(self) -> None:
|
||||||
def _browse_services(cls):
|
ServiceBrowser(self.zeroconf, self.service_type, [self._on_service_discovered])
|
||||||
browser = ServiceBrowser(cls.zeroconf, cls.service_type, [cls._on_service_discovered])
|
|
||||||
browser.is_alive()
|
|
||||||
|
|
||||||
@classmethod
|
def _on_service_discovered(self, zeroconf, service_type, name, state_change) -> None:
|
||||||
def _on_service_discovered(cls, zeroconf, service_type, name, state_change):
|
|
||||||
try:
|
try:
|
||||||
info = zeroconf.get_service_info(service_type, name)
|
info = zeroconf.get_service_info(service_type, name)
|
||||||
hostname = name.split(f'.{cls.service_type}')[0]
|
hostname = name.split(f'.{self.service_type}')[0]
|
||||||
logger.debug(f"Zeroconf: {hostname} {state_change}")
|
logger.debug(f"Zeroconf: {hostname} {state_change}")
|
||||||
if service_type == cls.service_type:
|
if service_type == self.service_type:
|
||||||
if state_change == ServiceStateChange.Added or state_change == ServiceStateChange.Updated:
|
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
||||||
cls.client_cache[hostname] = info
|
self.client_cache[hostname] = info
|
||||||
else:
|
else:
|
||||||
cls.client_cache.pop(hostname)
|
self.client_cache.pop(hostname, None)
|
||||||
pub.sendMessage('zeroconf_state_change', hostname=hostname, state_change=state_change)
|
pub.sendMessage('zeroconf_state_change', hostname=hostname, state_change=state_change)
|
||||||
except NotRunningException:
|
except NotRunningException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def found_hostnames(cls):
|
def _sync_class(cls) -> None:
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
inst = cls._default_instance
|
||||||
|
cls.service_type = inst.service_type
|
||||||
|
cls.server_name = inst.server_name
|
||||||
|
cls.server_port = inst.server_port
|
||||||
|
cls.server_ip = inst.server_ip
|
||||||
|
cls.properties = inst.properties
|
||||||
|
|
||||||
|
def _found_hostnames(self) -> List[str]:
|
||||||
local_hostname = socket.gethostname()
|
local_hostname = socket.gethostname()
|
||||||
|
|
||||||
def sort_key(hostname):
|
def sort_key(hostname):
|
||||||
# Return 0 if it's the local hostname so it comes first, else return 1
|
|
||||||
return False if hostname == local_hostname else True
|
return False if hostname == local_hostname else True
|
||||||
|
|
||||||
# Sort the list with the local hostname first
|
sorted_hostnames = sorted(self.client_cache.keys(), key=sort_key)
|
||||||
sorted_hostnames = sorted(cls.client_cache.keys(), key=sort_key)
|
|
||||||
return sorted_hostnames
|
return sorted_hostnames
|
||||||
|
|
||||||
|
def _get_hostname_properties(self, hostname: str) -> Dict:
|
||||||
|
server_info = self.client_cache.get(hostname)
|
||||||
|
if server_info is None:
|
||||||
|
return {}
|
||||||
|
decoded_server_info = {key.decode('utf-8'): value.decode('utf-8') for key, value in server_info.properties.items()}
|
||||||
|
return decoded_server_info
|
||||||
|
|
||||||
|
# --- Forwarders for backward compatibility ---
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def configure(cls, service_type, server_name, server_port):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._configure(service_type, server_name, server_port)
|
||||||
|
cls._sync_class()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def start(cls, listen_only=False):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._start(listen_only)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def stop(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
cls._default_instance._stop()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def found_hostnames(cls):
|
||||||
|
if cls._default_instance is not None:
|
||||||
|
return cls._default_instance._found_hostnames()
|
||||||
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_hostname_properties(cls, hostname):
|
def get_hostname_properties(cls, hostname):
|
||||||
server_info = cls.client_cache.get(hostname).properties
|
if cls._default_instance is not None:
|
||||||
decoded_server_info = {key.decode('utf-8'): value.decode('utf-8') for key, value in server_info.items()}
|
return cls._default_instance._get_hostname_properties(hostname)
|
||||||
return decoded_server_info
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# Example usage:
|
# Example usage:
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
APP_NAME = "Zordon"
|
APP_NAME = "Zordon"
|
||||||
APP_VERSION = "0.0.1"
|
APP_VERSION = "0.8.0"
|
||||||
APP_AUTHOR = "Brett Williams"
|
APP_AUTHOR = "Brett Williams"
|
||||||
APP_DESCRIPTION = "Distributed Render Farm Tools"
|
APP_DESCRIPTION = "Distributed Render Farm Tools"
|
||||||
APP_COPYRIGHT_YEAR = "2024"
|
APP_COPYRIGHT_YEAR = "2026"
|
||||||
APP_LICENSE = "MIT License"
|
APP_LICENSE = "MIT License"
|
||||||
APP_REPO_NAME = APP_NAME
|
APP_REPO_NAME = APP_NAME
|
||||||
APP_REPO_OWNER = "blw1138"
|
APP_REPO_OWNER = "blw1138"
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.render_queue import RenderQueue
|
||||||
|
from src.engines.engine_manager import EngineManager
|
||||||
|
from src.api.preview_manager import PreviewManager
|
||||||
|
from src.utilities.config import Config
|
||||||
|
from src.distributed_job_manager import DistributedJobManager
|
||||||
|
from src.utilities.zeroconf_server import ZeroconfServer
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Global pubsub patch – real pubsub can fire callbacks across tests.
|
||||||
|
# Each service module does `from pubsub import pub` at import time, so we
|
||||||
|
# must patch each module-level reference individually.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_PUBSUB_TARGETS = [
|
||||||
|
'pubsub.pub',
|
||||||
|
'src.render_queue.pub',
|
||||||
|
'src.distributed_job_manager.pub',
|
||||||
|
'src.utilities.zeroconf_server.pub',
|
||||||
|
'src.engines.core.base_worker.pub',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _patch_pubsub():
|
||||||
|
mocks = {}
|
||||||
|
patchers = []
|
||||||
|
for target in _PUBSUB_TARGETS:
|
||||||
|
p = patch(target)
|
||||||
|
patchers.append(p)
|
||||||
|
mocks[target] = p.start()
|
||||||
|
yield mocks.get('pubsub.pub')
|
||||||
|
for p in reversed(patchers):
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Temp directory for file-system-dependent tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_workspace(tmp_path: Path) -> Path:
|
||||||
|
ws = tmp_path / 'zordon_test'
|
||||||
|
ws.mkdir()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def config_instance(tmp_workspace: Path) -> Config:
|
||||||
|
orig = Config._default_instance
|
||||||
|
cfg = Config()
|
||||||
|
cfg.upload_folder = str(tmp_workspace / 'uploads')
|
||||||
|
Config._default_instance = cfg
|
||||||
|
Config._sync_class()
|
||||||
|
yield cfg
|
||||||
|
Config._default_instance = orig
|
||||||
|
Config._sync_class()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EngineManager fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def engine_manager_instance(tmp_workspace: Path) -> EngineManager:
|
||||||
|
orig = EngineManager._default_instance
|
||||||
|
em = EngineManager()
|
||||||
|
em.engines_path = str(tmp_workspace / 'engines')
|
||||||
|
EngineManager._default_instance = em
|
||||||
|
EngineManager._sync_class()
|
||||||
|
yield em
|
||||||
|
EngineManager._default_instance = orig
|
||||||
|
EngineManager._sync_class()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RenderQueue fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def render_queue_instance() -> RenderQueue:
|
||||||
|
orig = RenderQueue._default_instance
|
||||||
|
rq = RenderQueue()
|
||||||
|
RenderQueue._default_instance = rq
|
||||||
|
RenderQueue._sync_class()
|
||||||
|
yield rq
|
||||||
|
RenderQueue._default_instance = orig
|
||||||
|
RenderQueue._sync_class()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DistributedJobManager fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def distributed_job_manager_instance() -> DistributedJobManager:
|
||||||
|
orig = DistributedJobManager._default_instance
|
||||||
|
djm = DistributedJobManager()
|
||||||
|
DistributedJobManager._default_instance = djm
|
||||||
|
DistributedJobManager._sync_class()
|
||||||
|
yield djm
|
||||||
|
DistributedJobManager._default_instance = orig
|
||||||
|
DistributedJobManager._sync_class()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PreviewManager fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def preview_manager_instance(tmp_workspace: Path) -> PreviewManager:
|
||||||
|
orig = PreviewManager._default_instance
|
||||||
|
pm = PreviewManager()
|
||||||
|
pm.storage_path = str(tmp_workspace / 'previews')
|
||||||
|
PreviewManager._default_instance = pm
|
||||||
|
PreviewManager._sync_class()
|
||||||
|
yield pm
|
||||||
|
PreviewManager._default_instance = orig
|
||||||
|
PreviewManager._sync_class()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ZeroconfServer fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def zeroconf_server_instance() -> ZeroconfServer:
|
||||||
|
orig = ZeroconfServer._default_instance
|
||||||
|
zs = ZeroconfServer()
|
||||||
|
ZeroconfServer._default_instance = zs
|
||||||
|
ZeroconfServer._sync_class()
|
||||||
|
yield zs
|
||||||
|
ZeroconfServer._default_instance = orig
|
||||||
|
ZeroconfServer._sync_class()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.api.server_proxy import RenderServerProxy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SERVER_HOST = os.environ.get('ZORDON_TEST_HOST', '127.0.0.1')
|
||||||
|
SERVER_PORT = os.environ.get('ZORDON_TEST_PORT', '8080')
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(os.environ.get('ZORDON_SKIP_INTEGRATION'),
|
||||||
|
'set ZORDON_SKIP_INTEGRATION to skip integration tests')
|
||||||
|
class SubmissionTestCase(unittest.TestCase):
|
||||||
|
"""Integration tests requiring a running Zordon server.
|
||||||
|
|
||||||
|
Start the server: python server.py
|
||||||
|
Run tests: ZORDON_TEST_HOST=127.0.0.1 python -m pytest tests/job_creation_tests.py
|
||||||
|
|
||||||
|
Override host/port via ZORDON_TEST_HOST and ZORDON_TEST_PORT env vars.
|
||||||
|
"""
|
||||||
|
|
||||||
|
render_server = None
|
||||||
|
test_job_id = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.render_server = RenderServerProxy(SERVER_HOST, SERVER_PORT)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.render_server = None
|
||||||
|
|
||||||
|
def test_connection_ok(self):
|
||||||
|
self.assertTrue(self.render_server.is_online(),
|
||||||
|
msg=f'Server not reachable at {SERVER_HOST}:{SERVER_PORT}')
|
||||||
|
|
||||||
|
def test_submit_job(self):
|
||||||
|
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 = {
|
||||||
|
'name': 'sample_blender_job',
|
||||||
|
'renderer': 'blender',
|
||||||
|
'start_frame': 1,
|
||||||
|
'end_frame': 5,
|
||||||
|
'args': {'engine': 'CYCLES'},
|
||||||
|
'enable_split_jobs': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
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}')
|
||||||
|
|
||||||
|
logger.info('Submitted to server ok!')
|
||||||
|
job_data = response.json()
|
||||||
|
self.__class__.test_job_id = job_data[0]['id']
|
||||||
|
|
||||||
|
def test_wait_for_job_to_complete(self):
|
||||||
|
if not self.__class__.test_job_id:
|
||||||
|
self.skipTest('No job was submitted in test_submit_job')
|
||||||
|
|
||||||
|
file_count = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
update_response = self.render_server.get_job(self.__class__.test_job_id)
|
||||||
|
if update_response:
|
||||||
|
print(f"Status: {update_response['status']}")
|
||||||
|
|
||||||
|
if update_response['status'] not in [
|
||||||
|
'not_started', 'running', 'waiting_for_subjobs', 'configuring'
|
||||||
|
]:
|
||||||
|
self.assertEqual(update_response['status'], 'completed',
|
||||||
|
msg=f"Job ended with status: {update_response['status']}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if update_response['file_count'] > file_count:
|
||||||
|
file_count = update_response['file_count']
|
||||||
|
print(f"File count is now {file_count}")
|
||||||
|
time.sleep(1)
|
||||||
Binary file not shown.
@@ -0,0 +1,172 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from src.utilities.config import Config, _CONFIG_ATTRS
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigDefaults:
|
||||||
|
"""Default attribute values."""
|
||||||
|
|
||||||
|
def test_default_upload_folder(self):
|
||||||
|
assert Config.upload_folder == '~/zordon-uploads/'
|
||||||
|
|
||||||
|
def test_default_port(self):
|
||||||
|
assert Config.port_number == 8080
|
||||||
|
|
||||||
|
def test_default_server_log_level(self):
|
||||||
|
assert Config.server_log_level == 'debug'
|
||||||
|
|
||||||
|
def test_default_enable_split_jobs(self):
|
||||||
|
assert Config.enable_split_jobs is True
|
||||||
|
|
||||||
|
def test_default_worker_timeout(self):
|
||||||
|
assert Config.worker_process_timeout == 120
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigInstance:
|
||||||
|
"""Instance creation and attribute initialisation."""
|
||||||
|
|
||||||
|
def test_init_copies_class_attrs(self):
|
||||||
|
cfg = Config()
|
||||||
|
assert cfg.upload_folder == '~/zordon-uploads/'
|
||||||
|
assert cfg.port_number == 8080
|
||||||
|
|
||||||
|
def test_init_has_all_attrs(self):
|
||||||
|
cfg = Config()
|
||||||
|
for attr in _CONFIG_ATTRS:
|
||||||
|
assert hasattr(cfg, attr), f'missing attr: {attr}'
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigLoad:
|
||||||
|
"""Loading configuration from YAML."""
|
||||||
|
|
||||||
|
def test_load_sets_attributes(self, tmp_path):
|
||||||
|
config_file = tmp_path / 'config.yaml'
|
||||||
|
config_file.write_text('port_number: 9090\nserver_log_level: info\n')
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
cfg.load(config_file)
|
||||||
|
|
||||||
|
assert cfg.port_number == 9090
|
||||||
|
assert cfg.server_log_level == 'info'
|
||||||
|
|
||||||
|
def test_load_expands_upload_folder(self, tmp_path):
|
||||||
|
config_file = tmp_path / 'config.yaml'
|
||||||
|
config_file.write_text('upload_folder: ~/custom-uploads/\n')
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
cfg.load(config_file)
|
||||||
|
|
||||||
|
# expanduser strips trailing slash
|
||||||
|
assert cfg.upload_folder.endswith('/custom-uploads')
|
||||||
|
|
||||||
|
def test_load_ignores_unknown_keys(self, tmp_path):
|
||||||
|
config_file = tmp_path / 'config.yaml'
|
||||||
|
config_file.write_text('nonexistent_key: value\nport_number: 7070\n')
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
cfg.load(config_file)
|
||||||
|
|
||||||
|
assert cfg.port_number == 7070
|
||||||
|
|
||||||
|
def test_load_raises_on_missing_file(self):
|
||||||
|
cfg = Config()
|
||||||
|
try:
|
||||||
|
cfg.load(Path('/nonexistent/path.yaml'))
|
||||||
|
assert False, 'expected FileNotFoundError'
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSyncClass:
|
||||||
|
"""_sync_class propagates instance attrs to class level."""
|
||||||
|
|
||||||
|
def test_sync_class_propagates_attrs(self):
|
||||||
|
orig = Config._default_instance
|
||||||
|
try:
|
||||||
|
cfg = Config()
|
||||||
|
cfg.port_number = 7777
|
||||||
|
Config._default_instance = cfg
|
||||||
|
|
||||||
|
Config._sync_class()
|
||||||
|
|
||||||
|
assert Config.port_number == 7777
|
||||||
|
finally:
|
||||||
|
Config._default_instance = orig
|
||||||
|
Config._sync_class()
|
||||||
|
|
||||||
|
def test_sync_class_noop_when_no_instance(self):
|
||||||
|
orig = Config._default_instance
|
||||||
|
try:
|
||||||
|
Config._default_instance = None
|
||||||
|
Config._sync_class()
|
||||||
|
finally:
|
||||||
|
Config._default_instance = orig
|
||||||
|
Config._sync_class()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigLoadConfig:
|
||||||
|
"""Classmethod load_config — full bootstrap."""
|
||||||
|
|
||||||
|
def test_load_config_sets_default_instance(self, tmp_path):
|
||||||
|
orig = Config._default_instance
|
||||||
|
try:
|
||||||
|
config_file = tmp_path / 'config.yaml'
|
||||||
|
config_file.write_text('port_number: 9999\n')
|
||||||
|
|
||||||
|
Config._default_instance = None
|
||||||
|
Config.load_config(config_file)
|
||||||
|
|
||||||
|
assert Config._default_instance is not None
|
||||||
|
assert Config.port_number == 9999
|
||||||
|
finally:
|
||||||
|
Config._default_instance = orig
|
||||||
|
Config._sync_class()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigDir:
|
||||||
|
"""config_dir() returns OS-specific path."""
|
||||||
|
|
||||||
|
@patch('src.utilities.config.current_system_os')
|
||||||
|
def test_config_dir_macos(self, mock_os):
|
||||||
|
mock_os.return_value = 'macos'
|
||||||
|
result = Config.config_dir()
|
||||||
|
assert 'Library/Application Support/Zordon' in str(result)
|
||||||
|
|
||||||
|
@patch('src.utilities.config.current_system_os')
|
||||||
|
def test_config_dir_windows(self, mock_os):
|
||||||
|
mock_os.return_value = 'windows'
|
||||||
|
with patch.dict('os.environ', {'APPDATA': 'C:\\Users\\Test\\AppData\\Roaming'}):
|
||||||
|
result = Config.config_dir()
|
||||||
|
assert 'Zordon' in str(result)
|
||||||
|
|
||||||
|
@patch('src.utilities.config.current_system_os')
|
||||||
|
def test_config_dir_linux(self, mock_os):
|
||||||
|
mock_os.return_value = 'linux'
|
||||||
|
result = Config.config_dir()
|
||||||
|
assert '.config/Zordon' in str(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupConfigDir:
|
||||||
|
"""setup_config_dir creates dir and copies template."""
|
||||||
|
|
||||||
|
@patch('src.utilities.config.copy_directory_contents')
|
||||||
|
@patch('src.utilities.config.os.makedirs')
|
||||||
|
@patch('src.utilities.config.os.path.exists')
|
||||||
|
def test_creates_dir_when_missing(self, mock_exists, mock_makedirs, mock_copy):
|
||||||
|
mock_exists.return_value = False
|
||||||
|
|
||||||
|
Config.setup_config_dir()
|
||||||
|
|
||||||
|
mock_makedirs.assert_called_once()
|
||||||
|
|
||||||
|
@patch('src.utilities.config.copy_directory_contents')
|
||||||
|
@patch('src.utilities.config.os.makedirs')
|
||||||
|
@patch('src.utilities.config.os.path.exists')
|
||||||
|
def test_skips_when_dir_exists(self, mock_exists, mock_makedirs, mock_copy):
|
||||||
|
mock_exists.return_value = True
|
||||||
|
|
||||||
|
Config.setup_config_dir()
|
||||||
|
|
||||||
|
mock_makedirs.assert_not_called()
|
||||||
|
mock_copy.assert_not_called()
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import src.distributed_job_manager as djm_module
|
||||||
|
from src.distributed_job_manager import DistributedJobManager
|
||||||
|
from src.utilities.status_utils import RenderStatus
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubscribeToListener:
|
||||||
|
"""PubSub subscription."""
|
||||||
|
|
||||||
|
def test_subscribes_to_status_change(self, distributed_job_manager_instance):
|
||||||
|
distributed_job_manager_instance._subscribe_to_listener()
|
||||||
|
|
||||||
|
# Check via the module-level reference (the one _subscribe_to_listener uses)
|
||||||
|
djm_module.pub.subscribe.assert_any_call(
|
||||||
|
distributed_job_manager_instance._local_job_status_changed,
|
||||||
|
'status_change',
|
||||||
|
)
|
||||||
|
djm_module.pub.subscribe.assert_any_call(
|
||||||
|
distributed_job_manager_instance._local_job_frame_complete,
|
||||||
|
'frame_complete',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateRenderJob:
|
||||||
|
"""Creating a render job."""
|
||||||
|
|
||||||
|
@patch('src.distributed_job_manager.os.makedirs')
|
||||||
|
@patch('src.distributed_job_manager.EngineManager.create_worker')
|
||||||
|
def test_creates_worker_and_adds_to_queue(
|
||||||
|
self, mock_create_worker, mock_makedirs, distributed_job_manager_instance,
|
||||||
|
config_instance, tmp_path,
|
||||||
|
):
|
||||||
|
worker = MagicMock()
|
||||||
|
worker.total_frames = 10
|
||||||
|
worker.parent = None
|
||||||
|
worker.id = 'job-1'
|
||||||
|
mock_create_worker.return_value = worker
|
||||||
|
|
||||||
|
project_path = tmp_path / 'test_project.blend'
|
||||||
|
project_path.write_text('fake')
|
||||||
|
|
||||||
|
attrs = {
|
||||||
|
'engine_name': 'blender',
|
||||||
|
'args': {'engine': 'CYCLES'},
|
||||||
|
'name': 'Test Job',
|
||||||
|
'start_frame': 1,
|
||||||
|
'end_frame': 10,
|
||||||
|
'priority': 2,
|
||||||
|
'enable_split_jobs': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('src.distributed_job_manager.RenderQueue.add_to_render_queue') as mock_add:
|
||||||
|
with patch('src.distributed_job_manager.PreviewManager.update_previews_for_job'):
|
||||||
|
result = DistributedJobManager.create_render_job(attrs, project_path)
|
||||||
|
|
||||||
|
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(
|
||||||
|
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': 'Split Job',
|
||||||
|
'start_frame': 1,
|
||||||
|
'end_frame': 10,
|
||||||
|
'priority': 2,
|
||||||
|
'enable_split_jobs': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The forwarder passes system_os=None by default
|
||||||
|
with patch.object(distributed_job_manager_instance, '_split_into_subjobs_async') as mock_split:
|
||||||
|
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)
|
||||||
|
|
||||||
|
mock_split.assert_called_once_with(worker, attrs, project_path, None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleSubjobUpdate:
|
||||||
|
"""Processing subjob update notifications."""
|
||||||
|
|
||||||
|
def test_updates_child_info(self, distributed_job_manager_instance):
|
||||||
|
parent_job = MagicMock()
|
||||||
|
parent_job.children = {}
|
||||||
|
|
||||||
|
subjob_data = {
|
||||||
|
'id': 'sub-1',
|
||||||
|
'hostname': 'worker-1',
|
||||||
|
'status': 'completed',
|
||||||
|
'percent_complete': 1.0,
|
||||||
|
'file_count': 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('src.distributed_job_manager.download_missing_frames_from_subjob',
|
||||||
|
return_value=True):
|
||||||
|
DistributedJobManager.handle_subjob_update_notification(parent_job, subjob_data)
|
||||||
|
|
||||||
|
assert 'sub-1@worker-1' in parent_job.children
|
||||||
|
assert parent_job.children['sub-1@worker-1']['download_status'] == 'completed'
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindAvailableServers:
|
||||||
|
"""Discovering remote servers."""
|
||||||
|
|
||||||
|
@patch('src.distributed_job_manager.ZeroconfServer.found_hostnames')
|
||||||
|
@patch('src.distributed_job_manager.ZeroconfServer.get_hostname_properties')
|
||||||
|
@patch('src.distributed_job_manager.RenderServerProxy')
|
||||||
|
def test_finds_matching_server(
|
||||||
|
self, mock_proxy_class, mock_get_props, mock_found_hostnames,
|
||||||
|
):
|
||||||
|
mock_found_hostnames.return_value = ['server-1.local']
|
||||||
|
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.get_engine_availability.return_value = {
|
||||||
|
'available': True,
|
||||||
|
'hostname': 'server-1.local',
|
||||||
|
}
|
||||||
|
mock_proxy_class.return_value = mock_proxy
|
||||||
|
|
||||||
|
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')
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.engines.engine_manager import EngineManager, EngineDownloadWorker
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngineManagerSyncClass:
|
||||||
|
"""_sync_class propagates instance attrs to class level."""
|
||||||
|
|
||||||
|
def test_sync_class_propagates_engines_path(self, engine_manager_instance):
|
||||||
|
assert EngineManager.engines_path == engine_manager_instance.engines_path
|
||||||
|
|
||||||
|
def test_sync_class_noop_when_no_instance(self):
|
||||||
|
orig = EngineManager._default_instance
|
||||||
|
try:
|
||||||
|
EngineManager._default_instance = None
|
||||||
|
EngineManager.engines_path = 'original'
|
||||||
|
EngineManager._sync_class()
|
||||||
|
assert EngineManager.engines_path == 'original'
|
||||||
|
finally:
|
||||||
|
EngineManager._default_instance = orig
|
||||||
|
EngineManager._sync_class()
|
||||||
|
|
||||||
|
def test_supported_engines_returns_list(self):
|
||||||
|
engines = EngineManager.supported_engines()
|
||||||
|
assert len(engines) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestEngineClassMapping:
|
||||||
|
"""Mapping file extensions and names to engine classes."""
|
||||||
|
|
||||||
|
def test_engine_class_with_name_finds_blender(self, engine_manager_instance):
|
||||||
|
cls = EngineManager.engine_class_with_name('blender')
|
||||||
|
assert cls is not None
|
||||||
|
assert cls.__name__ == 'Blender'
|
||||||
|
|
||||||
|
def test_engine_class_with_name_finds_ffmpeg(self, engine_manager_instance):
|
||||||
|
cls = EngineManager.engine_class_with_name('ffmpeg')
|
||||||
|
assert cls is not None
|
||||||
|
assert cls.__name__ == 'FFMPEG'
|
||||||
|
|
||||||
|
def test_engine_class_with_name_returns_none_for_unknown(self, engine_manager_instance):
|
||||||
|
cls = EngineManager.engine_class_with_name('nonexistent')
|
||||||
|
assert cls is None
|
||||||
|
|
||||||
|
def test_engine_class_with_name_case_insensitive(self, engine_manager_instance):
|
||||||
|
cls = EngineManager.engine_class_with_name('BLENDER')
|
||||||
|
assert cls is not None
|
||||||
|
assert cls.__name__ == 'Blender'
|
||||||
|
|
||||||
|
def test_engine_class_for_project_path_no_engines_path(self, engine_manager_instance):
|
||||||
|
engine_manager_instance.engines_path = None
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
EngineManager.engine_class_for_project_path('test.blend')
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetInstalledEngineData:
|
||||||
|
"""Parsing directory listings for managed engines."""
|
||||||
|
|
||||||
|
def test_get_installed_engine_data_no_path(self, engine_manager_instance):
|
||||||
|
engine_manager_instance.engines_path = None
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
EngineManager.get_installed_engine_data()
|
||||||
|
|
||||||
|
def test_get_installed_engine_data_empty_dir(self, engine_manager_instance, tmp_path):
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
result = EngineManager.get_installed_engine_data(ignore_system=True)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch('src.engines.engine_manager.os.listdir')
|
||||||
|
@patch('src.engines.engine_manager.os.path.isdir')
|
||||||
|
def test_parse_managed_engine_directory(
|
||||||
|
self, mock_isdir, mock_listdir, engine_manager_instance, tmp_path,
|
||||||
|
):
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
mock_listdir.return_value = ['blender-3.6.0-macos-arm64']
|
||||||
|
mock_isdir.return_value = True
|
||||||
|
|
||||||
|
result = EngineManager.get_installed_engine_data(ignore_system=True)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]['engine'] == 'blender'
|
||||||
|
assert result[0]['version'] == '3.6.0'
|
||||||
|
assert result[0]['system_os'] == 'macos'
|
||||||
|
assert result[0]['cpu'] == 'arm64'
|
||||||
|
assert result[0]['type'] == 'managed'
|
||||||
|
|
||||||
|
@patch('src.engines.engine_manager.os.listdir')
|
||||||
|
@patch('src.engines.engine_manager.os.path.isdir')
|
||||||
|
def test_filter_by_engine_name(
|
||||||
|
self, mock_isdir, mock_listdir, engine_manager_instance, tmp_path,
|
||||||
|
):
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
mock_listdir.return_value = ['blender-3.6.0-macos-arm64', 'ffmpeg-6.0-macos-arm64']
|
||||||
|
mock_isdir.return_value = True
|
||||||
|
|
||||||
|
blender_only = EngineManager.get_installed_engine_data(
|
||||||
|
filter_name='blender', ignore_system=True)
|
||||||
|
assert len(blender_only) == 1
|
||||||
|
assert blender_only[0]['engine'] == 'blender'
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewestInstalledEngineData:
|
||||||
|
"""Filtering by system and CPU."""
|
||||||
|
|
||||||
|
@patch('src.engines.engine_manager.current_system_os')
|
||||||
|
@patch('src.engines.engine_manager.current_system_cpu')
|
||||||
|
def test_newest_filters_by_system_and_cpu(
|
||||||
|
self, mock_cpu, mock_os, engine_manager_instance, tmp_path,
|
||||||
|
):
|
||||||
|
mock_os.return_value = 'macos'
|
||||||
|
mock_cpu.return_value = 'arm64'
|
||||||
|
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
dirs = [
|
||||||
|
'blender-3.6.0-macos-arm64',
|
||||||
|
'blender-4.0.0-linux-x86_64',
|
||||||
|
'blender-4.1.0-macos-arm64',
|
||||||
|
]
|
||||||
|
|
||||||
|
with (patch('src.engines.engine_manager.os.listdir', return_value=dirs),
|
||||||
|
patch('src.engines.engine_manager.os.path.isdir', return_value=True)):
|
||||||
|
result = EngineManager.newest_installed_engine_data(
|
||||||
|
'blender', ignore_system=True)
|
||||||
|
|
||||||
|
assert result['version'] == '4.1.0'
|
||||||
|
assert result['system_os'] == 'macos'
|
||||||
|
assert result['cpu'] == 'arm64'
|
||||||
|
|
||||||
|
def test_newest_returns_empty_on_no_match(self, engine_manager_instance, tmp_path):
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
|
||||||
|
result = EngineManager.newest_installed_engine_data('nonexistent')
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsVersionInstalled:
|
||||||
|
"""Checking whether a specific version is installed."""
|
||||||
|
|
||||||
|
@patch('src.engines.engine_manager.current_system_os')
|
||||||
|
@patch('src.engines.engine_manager.current_system_cpu')
|
||||||
|
def test_finds_matching_version(
|
||||||
|
self, mock_cpu, mock_os, engine_manager_instance, tmp_path,
|
||||||
|
):
|
||||||
|
mock_os.return_value = 'macos'
|
||||||
|
mock_cpu.return_value = 'arm64'
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
|
||||||
|
dirs = ['blender-3.6.0-macos-arm64', 'blender-4.1.0-macos-arm64']
|
||||||
|
|
||||||
|
with (patch('src.engines.engine_manager.os.listdir', return_value=dirs),
|
||||||
|
patch('src.engines.engine_manager.os.path.isdir', return_value=True)):
|
||||||
|
result = EngineManager.is_version_installed('blender', '3.6.0')
|
||||||
|
|
||||||
|
assert result is not False
|
||||||
|
|
||||||
|
@patch('src.engines.engine_manager.current_system_os')
|
||||||
|
@patch('src.engines.engine_manager.current_system_cpu')
|
||||||
|
def test_returns_false_on_no_match(
|
||||||
|
self, mock_cpu, mock_os, engine_manager_instance, tmp_path,
|
||||||
|
):
|
||||||
|
mock_os.return_value = 'macos'
|
||||||
|
mock_cpu.return_value = 'arm64'
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
|
||||||
|
result = EngineManager.is_version_installed('blender', '99.0.0')
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteEngineDownload:
|
||||||
|
"""Deleting a managed engine directory."""
|
||||||
|
|
||||||
|
@patch('src.engines.engine_manager.shutil.rmtree')
|
||||||
|
@patch('src.engines.engine_manager.current_system_os', return_value='macos')
|
||||||
|
@patch('src.engines.engine_manager.current_system_cpu', return_value='arm64')
|
||||||
|
def test_delete_managed_engine(
|
||||||
|
self, mock_cpu, mock_os, mock_rmtree, engine_manager_instance, tmp_path,
|
||||||
|
):
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
engines_dir = tmp_path / 'engines' / 'blender-3.6.0-macos-arm64'
|
||||||
|
engines_dir.mkdir(parents=True)
|
||||||
|
(engines_dir / 'Blender').write_text('fake binary')
|
||||||
|
result = EngineManager.delete_engine_download('blender', '3.6.0')
|
||||||
|
assert result is True
|
||||||
|
mock_rmtree.assert_called_once_with(str(engines_dir), ignore_errors=False)
|
||||||
|
|
||||||
|
def test_delete_nonexistent_engine(self, engine_manager_instance, tmp_path):
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
|
||||||
|
result = EngineManager.delete_engine_download('nonexistent', '1.0.0')
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestActiveDownloads:
|
||||||
|
"""Background download tracking."""
|
||||||
|
|
||||||
|
def test_active_downloads_empty_initially(self, engine_manager_instance):
|
||||||
|
assert EngineManager.active_downloads() == []
|
||||||
|
|
||||||
|
def test_download_tasks_tracked(self, engine_manager_instance):
|
||||||
|
task = MagicMock(spec=EngineDownloadWorker)
|
||||||
|
task.is_alive.return_value = True
|
||||||
|
task.name = 'blender-4.0.0-macos-arm64'
|
||||||
|
engine_manager_instance.download_tasks.append(task)
|
||||||
|
|
||||||
|
active = EngineManager.active_downloads()
|
||||||
|
assert len(active) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateWorker:
|
||||||
|
"""Creating worker instances for render jobs."""
|
||||||
|
|
||||||
|
def test_create_worker_raises_when_no_engines_installed(self, engine_manager_instance, tmp_path):
|
||||||
|
engine_manager_instance.engines_path = str(tmp_path / 'engines')
|
||||||
|
|
||||||
|
with patch.object(engine_manager_instance, '_get_installed_engine_data', return_value=[]):
|
||||||
|
with pytest.raises(FileNotFoundError, match='Cannot find any installed'):
|
||||||
|
EngineManager.create_worker(
|
||||||
|
'blender',
|
||||||
|
input_path=tmp_path / 'test.blend',
|
||||||
|
output_path=tmp_path / 'output',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_worker_raises_for_unknown_engine(self, engine_manager_instance):
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
EngineManager.create_worker(
|
||||||
|
'nonexistent',
|
||||||
|
input_path='/tmp/test.blend',
|
||||||
|
output_path='/tmp/output',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadableEngines:
|
||||||
|
"""Engines with a downloader."""
|
||||||
|
|
||||||
|
def test_downloadable_engines_returns_list(self, engine_manager_instance):
|
||||||
|
engines = EngineManager.downloadable_engines()
|
||||||
|
assert isinstance(engines, list)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from src.api.preview_manager import PreviewManager
|
||||||
|
|
||||||
|
|
||||||
|
def make_mock_job(job_id='test-job-1', input_path='/tmp/test.blend',
|
||||||
|
file_list=None, **kwargs):
|
||||||
|
job = MagicMock()
|
||||||
|
job.id = job_id
|
||||||
|
job.input_path = input_path
|
||||||
|
job.file_list.return_value = file_list or []
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
setattr(job, k, v)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreviewManagerDefaults:
|
||||||
|
"""Default state."""
|
||||||
|
|
||||||
|
def test_storage_path_none_initially(self, preview_manager_instance):
|
||||||
|
assert preview_manager_instance.storage_path is not None
|
||||||
|
|
||||||
|
def test_running_jobs_empty_initially(self, preview_manager_instance):
|
||||||
|
assert preview_manager_instance._running_jobs == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeneratePreviewWorker:
|
||||||
|
"""Core preview generation logic."""
|
||||||
|
|
||||||
|
def test_skips_when_no_supported_files(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job(file_list=[str(tmp_path / 'output.txt')])
|
||||||
|
|
||||||
|
with patch.object(preview_manager_instance, '_generate_job_preview_worker') as mock_gen:
|
||||||
|
preview_manager_instance._update_previews_for_job(job)
|
||||||
|
mock_gen.assert_called_once_with(job, False)
|
||||||
|
|
||||||
|
def test_uses_input_path_when_no_file_list(self, preview_manager_instance, tmp_path):
|
||||||
|
"""When file_list is empty, falls back to input_path."""
|
||||||
|
job = make_mock_job(input_path=str(tmp_path / 'output.mp4'))
|
||||||
|
|
||||||
|
with patch.object(preview_manager_instance, '_generate_job_preview_worker') as mock_gen:
|
||||||
|
preview_manager_instance._update_previews_for_job(job)
|
||||||
|
mock_gen.assert_called_once()
|
||||||
|
|
||||||
|
def test_generate_preview_checks_existing_files(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job(input_path=str(tmp_path / 'test.jpg'))
|
||||||
|
|
||||||
|
with patch('src.api.preview_manager.save_first_frame') as mock_save:
|
||||||
|
with patch('src.api.preview_manager.os.path.exists', return_value=False):
|
||||||
|
preview_manager_instance._generate_job_preview_worker(job)
|
||||||
|
|
||||||
|
# No file_list → falls back to input_path → label is "input"
|
||||||
|
expected_img = str(tmp_path / f'{job.id}-input-480.jpg')
|
||||||
|
mock_save.assert_called_once_with(
|
||||||
|
source_path=job.input_path,
|
||||||
|
dest_path=expected_img,
|
||||||
|
max_width=480,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdatePreviewsForJob:
|
||||||
|
"""Dispatch of preview generation."""
|
||||||
|
|
||||||
|
def test_starts_new_thread(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job(file_list=[str(tmp_path / 'test.mp4')])
|
||||||
|
|
||||||
|
with patch('src.api.preview_manager.threading.Thread') as mock_thread:
|
||||||
|
thread_instance = MagicMock()
|
||||||
|
mock_thread.return_value = thread_instance
|
||||||
|
|
||||||
|
preview_manager_instance._update_previews_for_job(job)
|
||||||
|
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
thread_instance.start.assert_called_once()
|
||||||
|
assert preview_manager_instance._running_jobs[job.id] == thread_instance
|
||||||
|
|
||||||
|
def test_reuses_existing_thread(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job()
|
||||||
|
|
||||||
|
existing_thread = MagicMock()
|
||||||
|
existing_thread.is_alive.return_value = True
|
||||||
|
preview_manager_instance._running_jobs[job.id] = existing_thread
|
||||||
|
|
||||||
|
with patch('src.api.preview_manager.threading.Thread') as mock_thread:
|
||||||
|
preview_manager_instance._update_previews_for_job(job)
|
||||||
|
mock_thread.assert_not_called()
|
||||||
|
|
||||||
|
def test_join_when_wait_requested(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job()
|
||||||
|
|
||||||
|
with patch('src.api.preview_manager.threading.Thread') as mock_thread:
|
||||||
|
thread_instance = MagicMock()
|
||||||
|
mock_thread.return_value = thread_instance
|
||||||
|
|
||||||
|
preview_manager_instance._update_previews_for_job(
|
||||||
|
job, wait_until_completion=True, timeout=30,
|
||||||
|
)
|
||||||
|
thread_instance.join.assert_called_once_with(timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPreviewsForJob:
|
||||||
|
"""Reading preview files."""
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_previews(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job()
|
||||||
|
|
||||||
|
result = preview_manager_instance._get_previews_for_job(job)
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_returns_preview_files(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job(job_id='abc')
|
||||||
|
|
||||||
|
# Create a fake preview file
|
||||||
|
(tmp_path / 'abc-output-480.jpg').write_text('preview')
|
||||||
|
|
||||||
|
result = preview_manager_instance._get_previews_for_job(job)
|
||||||
|
assert 'output' in result
|
||||||
|
assert len(result['output']) == 1
|
||||||
|
assert result['output'][0]['kind'] == 'image'
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeletePreviewsForJob:
|
||||||
|
"""Cleaning up preview files."""
|
||||||
|
|
||||||
|
def test_deletes_existing_previews(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job(job_id='abc')
|
||||||
|
|
||||||
|
preview_file = tmp_path / 'abc-output-480.jpg'
|
||||||
|
preview_file.write_text('preview')
|
||||||
|
|
||||||
|
preview_manager_instance._delete_previews_for_job(job)
|
||||||
|
assert not preview_file.exists()
|
||||||
|
|
||||||
|
def test_no_error_when_no_previews(self, preview_manager_instance, tmp_path):
|
||||||
|
preview_manager_instance.storage_path = str(tmp_path)
|
||||||
|
job = make_mock_job()
|
||||||
|
preview_manager_instance._delete_previews_for_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwarders:
|
||||||
|
"""Classmethod forwarders delegate to instance."""
|
||||||
|
|
||||||
|
def test_update_previews_for_job_forwarder(self, preview_manager_instance):
|
||||||
|
job = make_mock_job()
|
||||||
|
with patch.object(preview_manager_instance, '_update_previews_for_job') as mock_method:
|
||||||
|
PreviewManager.update_previews_for_job(job)
|
||||||
|
mock_method.assert_called_once_with(job, False, False, None)
|
||||||
|
|
||||||
|
def test_get_previews_for_job_forwarder(self, preview_manager_instance):
|
||||||
|
job = make_mock_job()
|
||||||
|
with patch.object(preview_manager_instance, '_get_previews_for_job',
|
||||||
|
return_value={'input': []}) as mock_method:
|
||||||
|
result = PreviewManager.get_previews_for_job(job)
|
||||||
|
assert result == {'input': []}
|
||||||
|
mock_method.assert_called_once_with(job)
|
||||||
|
|
||||||
|
def test_delete_previews_for_job_forwarder(self, preview_manager_instance):
|
||||||
|
job = make_mock_job()
|
||||||
|
with patch.object(preview_manager_instance, '_delete_previews_for_job') as mock_method:
|
||||||
|
PreviewManager.delete_previews_for_job(job)
|
||||||
|
mock_method.assert_called_once_with(job)
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.render_queue import RenderQueue, JobNotFoundError
|
||||||
|
from src.utilities.status_utils import RenderStatus
|
||||||
|
|
||||||
|
|
||||||
|
def make_mock_job(job_id='test-1', status=RenderStatus.NOT_STARTED,
|
||||||
|
engine_name='blender', priority=2, **kwargs):
|
||||||
|
job = MagicMock()
|
||||||
|
job.id = job_id
|
||||||
|
job.status = status
|
||||||
|
job.engine_name = engine_name
|
||||||
|
job.priority = priority
|
||||||
|
job.name = kwargs.get('name', 'Test Job')
|
||||||
|
job.scheduled_start = kwargs.get('scheduled_start', None)
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
setattr(job, k, v)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderQueueDefaults:
|
||||||
|
"""Default state."""
|
||||||
|
|
||||||
|
def test_init_sets_empty_queue(self, render_queue_instance):
|
||||||
|
assert render_queue_instance.job_queue == []
|
||||||
|
|
||||||
|
def test_init_sets_max_instances(self, render_queue_instance):
|
||||||
|
assert render_queue_instance.maximum_renderer_instances == {'blender': 1, 'aerender': 1, 'ffmpeg': 4}
|
||||||
|
|
||||||
|
def test_init_is_not_running(self, render_queue_instance):
|
||||||
|
assert render_queue_instance.is_running is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllJobs:
|
||||||
|
"""Returning all jobs."""
|
||||||
|
|
||||||
|
def test_all_jobs_empty_initially(self, render_queue_instance):
|
||||||
|
assert RenderQueue.all_jobs() == []
|
||||||
|
|
||||||
|
def test_all_jobs_returns_job_list(self, render_queue_instance):
|
||||||
|
job = make_mock_job()
|
||||||
|
render_queue_instance.job_queue.append(job)
|
||||||
|
assert len(RenderQueue.all_jobs()) == 1
|
||||||
|
assert RenderQueue.all_jobs()[0] == job
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobsWithStatus:
|
||||||
|
"""Filtering jobs by status."""
|
||||||
|
|
||||||
|
def test_jobs_with_status_returns_matching(self, render_queue_instance):
|
||||||
|
running = make_mock_job('job-1', RenderStatus.RUNNING)
|
||||||
|
pending = make_mock_job('job-2', RenderStatus.NOT_STARTED)
|
||||||
|
render_queue_instance.job_queue.extend([running, pending])
|
||||||
|
|
||||||
|
result = RenderQueue.jobs_with_status(RenderStatus.RUNNING)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == 'job-1'
|
||||||
|
|
||||||
|
def test_jobs_with_status_empty_when_no_match(self, render_queue_instance):
|
||||||
|
job = make_mock_job('job-1', RenderStatus.COMPLETED)
|
||||||
|
render_queue_instance.job_queue.append(job)
|
||||||
|
|
||||||
|
result = RenderQueue.jobs_with_status(RenderStatus.RUNNING)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_jobs_with_status_sorts_by_priority(self, render_queue_instance):
|
||||||
|
high = make_mock_job('job-1', RenderStatus.NOT_STARTED, priority=1)
|
||||||
|
low = make_mock_job('job-2', RenderStatus.NOT_STARTED, priority=5)
|
||||||
|
render_queue_instance.job_queue.extend([low, high])
|
||||||
|
|
||||||
|
result = RenderQueue.jobs_with_status(RenderStatus.NOT_STARTED, priority_sorted=True)
|
||||||
|
assert result[0].id == 'job-1'
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunningAndPending:
|
||||||
|
"""Convenience methods for running/pending."""
|
||||||
|
|
||||||
|
def test_running_jobs(self, render_queue_instance):
|
||||||
|
running = make_mock_job('job-1', RenderStatus.RUNNING)
|
||||||
|
pending = make_mock_job('job-2', RenderStatus.NOT_STARTED)
|
||||||
|
render_queue_instance.job_queue.extend([running, pending])
|
||||||
|
|
||||||
|
result = RenderQueue.running_jobs()
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == 'job-1'
|
||||||
|
|
||||||
|
def test_pending_jobs_includes_not_started_and_scheduled(self, render_queue_instance):
|
||||||
|
ns = make_mock_job('job-1', RenderStatus.NOT_STARTED)
|
||||||
|
sched = make_mock_job('job-2', RenderStatus.SCHEDULED)
|
||||||
|
running = make_mock_job('job-3', RenderStatus.RUNNING)
|
||||||
|
render_queue_instance.job_queue.extend([ns, sched, running])
|
||||||
|
|
||||||
|
result = RenderQueue.pending_jobs()
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobWithId:
|
||||||
|
"""Lookup by job ID."""
|
||||||
|
|
||||||
|
def test_finds_job(self, render_queue_instance):
|
||||||
|
job = make_mock_job('abc-123')
|
||||||
|
render_queue_instance.job_queue.append(job)
|
||||||
|
|
||||||
|
found = RenderQueue.job_with_id('abc-123')
|
||||||
|
assert found == job
|
||||||
|
|
||||||
|
def test_raises_when_not_found(self, render_queue_instance):
|
||||||
|
with pytest.raises(JobNotFoundError, match='abc-123'):
|
||||||
|
RenderQueue.job_with_id('abc-123')
|
||||||
|
|
||||||
|
def test_returns_none_when_not_found_with_none_ok(self, render_queue_instance):
|
||||||
|
result = RenderQueue.job_with_id('abc-123', none_ok=True)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobCounts:
|
||||||
|
"""Counting jobs by status."""
|
||||||
|
|
||||||
|
def test_job_counts_returns_all_statuses(self, render_queue_instance):
|
||||||
|
render_queue_instance.job_queue.extend([
|
||||||
|
make_mock_job('j1', RenderStatus.RUNNING),
|
||||||
|
make_mock_job('j2', RenderStatus.COMPLETED),
|
||||||
|
make_mock_job('j3', RenderStatus.NOT_STARTED),
|
||||||
|
])
|
||||||
|
|
||||||
|
counts = RenderQueue.job_counts()
|
||||||
|
assert counts[RenderStatus.RUNNING.value] == 1
|
||||||
|
assert counts[RenderStatus.COMPLETED.value] == 1
|
||||||
|
assert counts[RenderStatus.NOT_STARTED.value] == 1
|
||||||
|
assert counts[RenderStatus.ERROR.value] == 0
|
||||||
|
assert counts[RenderStatus.CANCELLED.value] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsAvailableForJob:
|
||||||
|
"""Renderer availability checks."""
|
||||||
|
|
||||||
|
def test_available_when_no_running_jobs(self, render_queue_instance):
|
||||||
|
assert RenderQueue.is_available_for_job('blender') is True
|
||||||
|
|
||||||
|
def test_not_available_when_maxed_out(self, render_queue_instance):
|
||||||
|
render_queue_instance.job_queue.append(
|
||||||
|
make_mock_job('j1', RenderStatus.RUNNING, engine_name='blender')
|
||||||
|
)
|
||||||
|
assert RenderQueue.is_available_for_job('blender') is False
|
||||||
|
|
||||||
|
def test_available_for_different_renderer(self, render_queue_instance):
|
||||||
|
render_queue_instance.job_queue.append(
|
||||||
|
make_mock_job('j1', RenderStatus.RUNNING, engine_name='blender')
|
||||||
|
)
|
||||||
|
assert RenderQueue.is_available_for_job('ffmpeg') is True
|
||||||
|
|
||||||
|
def test_blocked_by_higher_priority(self, render_queue_instance):
|
||||||
|
render_queue_instance.job_queue.append(
|
||||||
|
make_mock_job('j1', RenderStatus.RUNNING, engine_name='blender', priority=0)
|
||||||
|
)
|
||||||
|
assert RenderQueue.is_available_for_job('blender', priority=2) is False
|
||||||
|
|
||||||
|
def test_ffmpeg_allows_multiple_instances(self, render_queue_instance):
|
||||||
|
for i in range(4):
|
||||||
|
render_queue_instance.job_queue.append(
|
||||||
|
make_mock_job(f'j{i}', RenderStatus.RUNNING, engine_name='ffmpeg')
|
||||||
|
)
|
||||||
|
assert RenderQueue.is_available_for_job('ffmpeg') is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddToRenderQueue:
|
||||||
|
"""Adding jobs."""
|
||||||
|
|
||||||
|
def test_add_job_appends_to_queue(self, render_queue_instance):
|
||||||
|
render_queue_instance.session = MagicMock()
|
||||||
|
job = make_mock_job()
|
||||||
|
RenderQueue.add_to_render_queue(job)
|
||||||
|
|
||||||
|
assert job in render_queue_instance.job_queue
|
||||||
|
|
||||||
|
def test_add_job_saves_state(self, render_queue_instance):
|
||||||
|
render_queue_instance.session = MagicMock()
|
||||||
|
|
||||||
|
job = make_mock_job()
|
||||||
|
RenderQueue.add_to_render_queue(job)
|
||||||
|
|
||||||
|
render_queue_instance.session.add.assert_called_once_with(job)
|
||||||
|
|
||||||
|
def test_add_job_force_start_calls_start(self, render_queue_instance):
|
||||||
|
render_queue_instance.session = MagicMock()
|
||||||
|
job = make_mock_job(status=RenderStatus.NOT_STARTED)
|
||||||
|
with patch.object(render_queue_instance, '_start_job') as mock_start:
|
||||||
|
RenderQueue.add_to_render_queue(job, force_start=True)
|
||||||
|
mock_start.assert_called_once_with(job)
|
||||||
|
|
||||||
|
def test_add_job_force_start_skips_completed(self, render_queue_instance):
|
||||||
|
render_queue_instance.session = MagicMock()
|
||||||
|
job = make_mock_job(status=RenderStatus.COMPLETED)
|
||||||
|
with patch.object(render_queue_instance, '_start_job') as mock_start:
|
||||||
|
RenderQueue.add_to_render_queue(job, force_start=True)
|
||||||
|
mock_start.assert_not_called()
|
||||||
|
|
||||||
|
def test_add_job_evaluates_queue_when_running(self, render_queue_instance):
|
||||||
|
render_queue_instance.session = MagicMock()
|
||||||
|
render_queue_instance.is_running = True
|
||||||
|
job = make_mock_job()
|
||||||
|
|
||||||
|
with patch.object(render_queue_instance, '_evaluate_queue') as mock_eval:
|
||||||
|
RenderQueue.add_to_render_queue(job)
|
||||||
|
mock_eval.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartCancelDelete:
|
||||||
|
"""Job lifecycle."""
|
||||||
|
|
||||||
|
def test_start_job_calls_job_start(self, render_queue_instance):
|
||||||
|
job = make_mock_job()
|
||||||
|
with patch.object(render_queue_instance, '_save_state'):
|
||||||
|
RenderQueue.start_job(job)
|
||||||
|
job.start.assert_called_once()
|
||||||
|
|
||||||
|
def test_cancel_job_calls_job_stop(self, render_queue_instance):
|
||||||
|
job = make_mock_job()
|
||||||
|
job.stop.return_value = None
|
||||||
|
job.status = RenderStatus.CANCELLED
|
||||||
|
|
||||||
|
result = RenderQueue.cancel_job(job)
|
||||||
|
assert result is True
|
||||||
|
job.stop.assert_called_once()
|
||||||
|
|
||||||
|
def test_delete_job_removes_from_queue(self, render_queue_instance):
|
||||||
|
job = make_mock_job()
|
||||||
|
render_queue_instance.job_queue.append(job)
|
||||||
|
render_queue_instance.session = MagicMock()
|
||||||
|
|
||||||
|
result = RenderQueue.delete_job(job)
|
||||||
|
assert result is True
|
||||||
|
assert job not in render_queue_instance.job_queue
|
||||||
|
render_queue_instance.session.delete.assert_called_once_with(job)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartStop:
|
||||||
|
"""Starting and stopping the queue evaluation loop."""
|
||||||
|
|
||||||
|
def test_start_sets_running_and_evaluates(self, render_queue_instance):
|
||||||
|
with patch.object(render_queue_instance, '_evaluate_queue') as mock_eval:
|
||||||
|
RenderQueue.start()
|
||||||
|
assert render_queue_instance.is_running is True
|
||||||
|
mock_eval.assert_called_once()
|
||||||
|
|
||||||
|
def test_stop_clears_running(self, render_queue_instance):
|
||||||
|
render_queue_instance.is_running = True
|
||||||
|
RenderQueue.stop()
|
||||||
|
assert render_queue_instance.is_running is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvaluateQueue:
|
||||||
|
"""Queue evaluation dispatches jobs."""
|
||||||
|
|
||||||
|
def test_evaluate_starts_not_started_jobs(self, render_queue_instance):
|
||||||
|
job = make_mock_job()
|
||||||
|
render_queue_instance.job_queue.append(job)
|
||||||
|
|
||||||
|
with patch.object(render_queue_instance, '_start_job') as mock_start:
|
||||||
|
with patch.object(render_queue_instance, '_save_state'):
|
||||||
|
RenderQueue.evaluate_queue()
|
||||||
|
mock_start.assert_called_once_with(job)
|
||||||
|
|
||||||
|
def test_evaluate_respects_max_instances(self, render_queue_instance):
|
||||||
|
# One already running, only 1 slot for blender
|
||||||
|
render_queue_instance.job_queue.append(
|
||||||
|
make_mock_job('running-1', RenderStatus.RUNNING, engine_name='blender')
|
||||||
|
)
|
||||||
|
waiting = make_mock_job('waiting-1', RenderStatus.NOT_STARTED, engine_name='blender')
|
||||||
|
render_queue_instance.job_queue.append(waiting)
|
||||||
|
|
||||||
|
with patch.object(render_queue_instance, '_start_job') as mock_start:
|
||||||
|
with patch.object(render_queue_instance, '_save_state'):
|
||||||
|
RenderQueue.evaluate_queue()
|
||||||
|
mock_start.assert_not_called()
|
||||||
|
|
||||||
|
def test_evaluate_starts_scheduled_jobs(self, render_queue_instance):
|
||||||
|
past = datetime(2020, 1, 1)
|
||||||
|
job = make_mock_job(status=RenderStatus.SCHEDULED, scheduled_start=past)
|
||||||
|
render_queue_instance.job_queue.append(job)
|
||||||
|
|
||||||
|
with patch.object(render_queue_instance, '_start_job') as mock_start:
|
||||||
|
with patch.object(render_queue_instance, '_save_state'):
|
||||||
|
RenderQueue.evaluate_queue()
|
||||||
|
mock_start.assert_called_once_with(job)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearHistory:
|
||||||
|
"""Clearing completed/cancelled/error jobs."""
|
||||||
|
|
||||||
|
def test_clear_history_removes_finished_jobs(self, render_queue_instance):
|
||||||
|
render_queue_instance.session = MagicMock()
|
||||||
|
jobs = [
|
||||||
|
make_mock_job('c', RenderStatus.COMPLETED),
|
||||||
|
make_mock_job('e', RenderStatus.ERROR),
|
||||||
|
make_mock_job('x', RenderStatus.CANCELLED),
|
||||||
|
make_mock_job('r', RenderStatus.RUNNING),
|
||||||
|
]
|
||||||
|
render_queue_instance.job_queue.extend(jobs)
|
||||||
|
|
||||||
|
RenderQueue.clear_history()
|
||||||
|
|
||||||
|
assert len(render_queue_instance.job_queue) == 1
|
||||||
|
assert render_queue_instance.job_queue[0].id == 'r'
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.utilities.zeroconf_server import ZeroconfServer
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigure:
|
||||||
|
"""Configuring service parameters."""
|
||||||
|
|
||||||
|
def test_configure_sets_attributes(self, zeroconf_server_instance):
|
||||||
|
with patch('socket.gethostbyname', return_value='192.168.1.1'):
|
||||||
|
ZeroconfServer.configure('_zordon._tcp.local.', 'test-server', 8080)
|
||||||
|
|
||||||
|
assert zeroconf_server_instance.service_type == '_zordon._tcp.local.'
|
||||||
|
assert zeroconf_server_instance.server_name == 'test-server'
|
||||||
|
assert zeroconf_server_instance.server_port == 8080
|
||||||
|
|
||||||
|
def test_configure_calls_sync_class(self, zeroconf_server_instance):
|
||||||
|
with patch('socket.gethostbyname', return_value='192.168.1.1'):
|
||||||
|
ZeroconfServer.configure('_zordon._tcp.local.', 'test-server', 8080)
|
||||||
|
|
||||||
|
assert ZeroconfServer.service_type == '_zordon._tcp.local.'
|
||||||
|
assert ZeroconfServer.server_port == 8080
|
||||||
|
|
||||||
|
def test_configure_stops_on_gaierror(self, zeroconf_server_instance):
|
||||||
|
import socket
|
||||||
|
with patch('socket.gethostbyname', side_effect=socket.gaierror):
|
||||||
|
with patch.object(zeroconf_server_instance, '_stop') as mock_stop:
|
||||||
|
ZeroconfServer.configure('_zordon._tcp.local.', 'test-server', 8080)
|
||||||
|
mock_stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncClass:
|
||||||
|
"""_sync_class propagates instance attrs to class level."""
|
||||||
|
|
||||||
|
def test_sync_class_propagates_all_attrs(self, zeroconf_server_instance):
|
||||||
|
zeroconf_server_instance.service_type = '_test._tcp.'
|
||||||
|
zeroconf_server_instance.server_name = 'foo'
|
||||||
|
zeroconf_server_instance.server_port = 9999
|
||||||
|
zeroconf_server_instance.server_ip = '10.0.0.1'
|
||||||
|
zeroconf_server_instance.properties = {'key': 'val'}
|
||||||
|
|
||||||
|
ZeroconfServer._sync_class()
|
||||||
|
|
||||||
|
assert ZeroconfServer.service_type == '_test._tcp.'
|
||||||
|
assert ZeroconfServer.server_name == 'foo'
|
||||||
|
assert ZeroconfServer.server_port == 9999
|
||||||
|
assert ZeroconfServer.server_ip == '10.0.0.1'
|
||||||
|
assert ZeroconfServer.properties == {'key': 'val'}
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartStop:
|
||||||
|
"""Service lifecycle."""
|
||||||
|
|
||||||
|
def test_start_raises_without_configure(self, zeroconf_server_instance):
|
||||||
|
with pytest.raises(RuntimeError, match='configure'):
|
||||||
|
ZeroconfServer.start()
|
||||||
|
|
||||||
|
@patch('src.utilities.zeroconf_server.ServiceBrowser')
|
||||||
|
def test_start_listen_only_skips_register(self, mock_browser, zeroconf_server_instance):
|
||||||
|
with patch('socket.gethostbyname', return_value='192.168.1.1'):
|
||||||
|
ZeroconfServer.configure('_zordon._tcp.local.', 'test', 8080)
|
||||||
|
|
||||||
|
with patch.object(zeroconf_server_instance, '_register_service') as mock_register:
|
||||||
|
ZeroconfServer.start(listen_only=True)
|
||||||
|
mock_register.assert_not_called()
|
||||||
|
|
||||||
|
@patch('src.utilities.zeroconf_server.ServiceBrowser')
|
||||||
|
def test_start_registers_service(self, mock_browser, zeroconf_server_instance):
|
||||||
|
with patch('socket.gethostbyname', return_value='192.168.1.1'):
|
||||||
|
ZeroconfServer.configure('_zordon._tcp.local.', 'test', 8080)
|
||||||
|
|
||||||
|
with patch.object(zeroconf_server_instance, '_register_service') as mock_register:
|
||||||
|
ZeroconfServer.start(listen_only=False)
|
||||||
|
mock_register.assert_called_once()
|
||||||
|
|
||||||
|
def test_stop_unregisters_and_closes(self, zeroconf_server_instance):
|
||||||
|
zeroconf_server_instance.service_info = MagicMock()
|
||||||
|
|
||||||
|
with patch.object(zeroconf_server_instance, '_unregister_service') as mock_unreg:
|
||||||
|
with patch.object(zeroconf_server_instance.zeroconf, 'close') as mock_close:
|
||||||
|
ZeroconfServer.stop()
|
||||||
|
mock_unreg.assert_called_once()
|
||||||
|
mock_close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegisterService:
|
||||||
|
"""Service registration with Zeroconf."""
|
||||||
|
|
||||||
|
@patch('src.utilities.zeroconf_server.ServiceInfo')
|
||||||
|
@patch('socket.gethostbyname')
|
||||||
|
def test_registers_service_info(self, mock_gethostbyname, mock_service_info,
|
||||||
|
zeroconf_server_instance):
|
||||||
|
mock_gethostbyname.return_value = '192.168.1.1'
|
||||||
|
zeroconf_server_instance.service_type = '_zordon._tcp.local.'
|
||||||
|
zeroconf_server_instance.server_name = 'test'
|
||||||
|
zeroconf_server_instance.server_port = 8080
|
||||||
|
zeroconf_server_instance.properties = {}
|
||||||
|
|
||||||
|
# Replace real Zeroconf with a mock so we don't actually register
|
||||||
|
zeroconf_server_instance.zeroconf = MagicMock()
|
||||||
|
|
||||||
|
mock_info = MagicMock()
|
||||||
|
mock_service_info.return_value = mock_info
|
||||||
|
|
||||||
|
with patch('socket.inet_aton', return_value=b'\xc0\xa8\x01\x01'):
|
||||||
|
zeroconf_server_instance._register_service()
|
||||||
|
|
||||||
|
zeroconf_server_instance.zeroconf.register_service.assert_called_once_with(mock_info)
|
||||||
|
assert zeroconf_server_instance.service_info == mock_info
|
||||||
|
|
||||||
|
|
||||||
|
class TestFoundHostnames:
|
||||||
|
"""Discovery cache."""
|
||||||
|
|
||||||
|
def test_found_hostnames_empty_initially(self, zeroconf_server_instance):
|
||||||
|
result = zeroconf_server_instance._found_hostnames()
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch('socket.gethostname', return_value='my-machine')
|
||||||
|
def test_hostnames_sorted_with_local_first(self, mock_hostname, zeroconf_server_instance):
|
||||||
|
zeroconf_server_instance.client_cache = {
|
||||||
|
'other-machine': MagicMock(),
|
||||||
|
'my-machine': MagicMock(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = zeroconf_server_instance._found_hostnames()
|
||||||
|
# sort_key returns False (0) for local → sorted first
|
||||||
|
assert result[0] == 'my-machine'
|
||||||
|
|
||||||
|
def test_get_hostname_properties_returns_decoded(self, zeroconf_server_instance):
|
||||||
|
info = MagicMock()
|
||||||
|
info.properties = {b'key': b'value', b'num': b'42'}
|
||||||
|
zeroconf_server_instance.client_cache['server-1'] = info
|
||||||
|
|
||||||
|
result = zeroconf_server_instance._get_hostname_properties('server-1')
|
||||||
|
assert result == {'key': 'value', 'num': '42'}
|
||||||
|
|
||||||
|
def test_get_hostname_properties_returns_empty_for_unknown(self, zeroconf_server_instance):
|
||||||
|
result = zeroconf_server_instance._get_hostname_properties('unknown')
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwarders:
|
||||||
|
"""Classmethod forwarders delegate to instance."""
|
||||||
|
|
||||||
|
def test_found_hostnames_forwarder(self, zeroconf_server_instance):
|
||||||
|
zeroconf_server_instance.client_cache['svr'] = MagicMock()
|
||||||
|
result = ZeroconfServer.found_hostnames()
|
||||||
|
assert 'svr' in result
|
||||||
|
|
||||||
|
def test_get_hostname_properties_forwarder(self, zeroconf_server_instance):
|
||||||
|
info = MagicMock()
|
||||||
|
info.properties = {}
|
||||||
|
zeroconf_server_instance.client_cache['svr'] = info
|
||||||
|
|
||||||
|
result = ZeroconfServer.get_hostname_properties('svr')
|
||||||
|
assert result == {}
|
||||||
Reference in New Issue
Block a user