mirror of
https://github.com/blw1138/Zordon.git
synced 2026-06-09 21:49:23 -05:00
Compare commits
7 Commits
d60340f129
..
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 44e6b4332f | |||
| c38213fb58 | |||
| 3486feaaf4 | |||
| 141843c916 | |||
| 472c7968b3 | |||
| b8b71d1e16 | |||
| f0be78adcc |
@@ -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'
|
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ 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/add_job` 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:8080/api/add_job \
|
curl -X POST http://localhost:8080/api/jobs \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "example-render",
|
"name": "example-render",
|
||||||
@@ -109,6 +109,7 @@ curl -X POST http://localhost:8080/api/add_job \
|
|||||||
- **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
|
||||||
|
- `POST /api/jobs`: Submit a new job
|
||||||
- `GET /api/jobs/<job_id>`: Get job details
|
- `GET /api/jobs/<job_id>`: Get job details
|
||||||
- `POST /api/jobs/<job_id>/cancel`: Cancel a job
|
- `POST /api/jobs/<job_id>/cancel`: Cancel a job
|
||||||
- `POST /api/jobs/<job_id>/delete`: Delete a job
|
- `POST /api/jobs/<job_id>/delete`: Delete a job
|
||||||
@@ -118,6 +119,18 @@ curl -X POST http://localhost:8080/api/add_job \
|
|||||||
For the full endpoint reference, see [`docs/api.html`](docs/api.html) or
|
For the full endpoint reference, see [`docs/api.html`](docs/api.html) or
|
||||||
[`docs/API.md`](docs/API.md).
|
[`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
|
||||||
|
|
||||||
Workers automatically connect to the server when started. You can:
|
Workers automatically connect to the server when started. You can:
|
||||||
|
|||||||
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()
|
||||||
+1
-1
@@ -171,7 +171,7 @@ Review note: `size=big` is parsed into `big_thumb` but not used.
|
|||||||
|
|
||||||
## Job Lifecycle
|
## Job Lifecycle
|
||||||
|
|
||||||
### `POST /api/add_job`
|
### `POST /api/jobs`
|
||||||
|
|
||||||
Adds one or more render jobs.
|
Adds one or more render jobs.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -352,7 +352,7 @@
|
|||||||
<h2>Job Lifecycle</h2>
|
<h2>Job Lifecycle</h2>
|
||||||
|
|
||||||
<article class="endpoint">
|
<article class="endpoint">
|
||||||
<div class="endpoint-header"><span class="method post">POST</span><span class="path">/api/add_job</span></div>
|
<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>
|
<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>
|
<table>
|
||||||
<tr><th>Common Field</th><th>Description</th></tr>
|
<tr><th>Common Field</th><th>Description</th></tr>
|
||||||
|
|||||||
+47
-20
@@ -34,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:
|
||||||
@@ -54,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')
|
||||||
@@ -231,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**
|
||||||
@@ -322,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
|
||||||
|
|
||||||
@@ -343,6 +335,41 @@ 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:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+26
-16
@@ -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:
|
||||||
@@ -161,7 +163,7 @@ class RenderServerProxy:
|
|||||||
def get_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_status(self):
|
def get_status(self):
|
||||||
status = self.request_data('status')
|
status = self.request_data('status')
|
||||||
@@ -187,7 +189,7 @@ class RenderServerProxy:
|
|||||||
# Job Lifecycle:
|
# Job Lifecycle:
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
def create_job(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.
|
||||||
|
|
||||||
@@ -195,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.
|
||||||
@@ -206,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:
|
||||||
@@ -220,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._post(f'jobs/{job_id}/cancel', params={'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._post(f'jobs/{job_id}/delete', params={'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.
|
||||||
|
|
||||||
@@ -244,7 +254,7 @@ class RenderServerProxy:
|
|||||||
Response: The response from the server.
|
Response: The response from the server.
|
||||||
"""
|
"""
|
||||||
return requests.post(f'http://{self.hostname}:{self.port}/api/jobs/{parent_id}/subjob_update',
|
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:
|
||||||
@@ -308,17 +318,17 @@ class RenderServerProxy:
|
|||||||
# 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/jobs/{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/jobs/{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):
|
||||||
|
|||||||
@@ -100,10 +100,12 @@ class DistributedJobManager:
|
|||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
||||||
def _create_render_job(self, new_job_attributes: dict, loaded_project_local_path: Path):
|
def _create_render_job(self, new_job_attributes: dict, loaded_project_local_path: Path):
|
||||||
output_path = new_job_attributes.get('output_path')
|
requested_output_path = new_job_attributes.get('output_path')
|
||||||
output_filename = loaded_project_local_path.name if output_path else loaded_project_local_path.stem
|
output_filename = Path(str(requested_output_path)).name if requested_output_path else loaded_project_local_path.stem
|
||||||
|
|
||||||
output_dir = loaded_project_local_path.parent.parent / "output"
|
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}")
|
||||||
|
|||||||
@@ -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+)/'
|
||||||
|
|||||||
@@ -133,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)
|
||||||
|
|
||||||
@@ -353,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")
|
||||||
@@ -583,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:
|
||||||
@@ -595,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']
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+39
-38
@@ -229,26 +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 as e:
|
self.update_server_info_display(new_hostname)
|
||||||
logger.error(f"AttributeError in server_picked: {e}")
|
|
||||||
|
|
||||||
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."""
|
||||||
@@ -297,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):
|
||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+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"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from src.api.server_proxy import RenderServerProxy
|
from src.api.server_proxy import RenderServerProxy
|
||||||
|
|
||||||
@@ -38,9 +39,8 @@ class SubmissionTestCase(unittest.TestCase):
|
|||||||
msg=f'Server not reachable at {SERVER_HOST}:{SERVER_PORT}')
|
msg=f'Server not reachable at {SERVER_HOST}:{SERVER_PORT}')
|
||||||
|
|
||||||
def test_submit_job(self):
|
def test_submit_job(self):
|
||||||
sample_file_path = os.path.join(os.path.dirname(__file__), 'resources',
|
sample_file_path = Path(__file__).parent / 'resources' / 'batman_sample.blend'
|
||||||
'batman_sample.blend')
|
self.assertTrue(sample_file_path.exists(),
|
||||||
self.assertTrue(os.path.exists(sample_file_path),
|
|
||||||
msg=f'Test file not found: {sample_file_path}')
|
msg=f'Test file not found: {sample_file_path}')
|
||||||
|
|
||||||
sample_job = {
|
sample_job = {
|
||||||
|
|||||||
@@ -56,8 +56,70 @@ class TestCreateRenderJob:
|
|||||||
|
|
||||||
assert result == worker
|
assert result == worker
|
||||||
assert worker.status == RenderStatus.NOT_STARTED
|
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)
|
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.os.makedirs')
|
||||||
@patch('src.distributed_job_manager.EngineManager.create_worker')
|
@patch('src.distributed_job_manager.EngineManager.create_worker')
|
||||||
def test_split_jobs_enabled_calls_split_async(
|
def test_split_jobs_enabled_calls_split_async(
|
||||||
@@ -124,10 +186,12 @@ class TestFindAvailableServers:
|
|||||||
self, mock_proxy_class, mock_get_props, mock_found_hostnames,
|
self, mock_proxy_class, mock_get_props, mock_found_hostnames,
|
||||||
):
|
):
|
||||||
mock_found_hostnames.return_value = ['server-1.local']
|
mock_found_hostnames.return_value = ['server-1.local']
|
||||||
mock_get_props.return_value = {'api_version': '0.1', 'system_os': 'macos'}
|
from src.api.api_server import API_VERSION
|
||||||
|
|
||||||
|
mock_get_props.return_value = {'api_version': API_VERSION, 'system_os': 'macos'}
|
||||||
|
|
||||||
mock_proxy = MagicMock()
|
mock_proxy = MagicMock()
|
||||||
mock_proxy.is_engine_available.return_value = {
|
mock_proxy.get_engine_availability.return_value = {
|
||||||
'available': True,
|
'available': True,
|
||||||
'hostname': 'server-1.local',
|
'hostname': 'server-1.local',
|
||||||
}
|
}
|
||||||
@@ -136,3 +200,4 @@ class TestFindAvailableServers:
|
|||||||
result = DistributedJobManager.find_available_servers('blender')
|
result = DistributedJobManager.find_available_servers('blender')
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]['hostname'] == 'server-1.local'
|
assert result[0]['hostname'] == 'server-1.local'
|
||||||
|
mock_proxy.get_engine_availability.assert_called_once_with('blender')
|
||||||
|
|||||||
Reference in New Issue
Block a user