From b8b71d1e1685fe6449b1b130d143ae829801163e Mon Sep 17 00:00:00 2001 From: Brett Date: Sat, 6 Jun 2026 14:32:48 -0500 Subject: [PATCH] Add Blender plugin (#134) * Unbind hostname to allow localhost submissions * Fix issue where multiple cameras were outputting to the same directory * Add Blender plugin --- README.md | 12 + addons/blender/zordon_blender.zip | Bin 0 -> 7189 bytes addons/blender/zordon_blender/README.md | 153 ++++++ addons/blender/zordon_blender/__init__.py | 621 ++++++++++++++++++++++ src/api/api_server.py | 6 +- src/api/job_import_handler.py | 1 + src/distributed_job_manager.py | 6 +- tests/test_distributed_job_manager.py | 62 +++ 8 files changed, 856 insertions(+), 5 deletions(-) create mode 100644 addons/blender/zordon_blender.zip create mode 100644 addons/blender/zordon_blender/README.md create mode 100644 addons/blender/zordon_blender/__init__.py diff --git a/README.md b/README.md index 7e62b0c..52f3546 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,18 @@ curl -X POST http://localhost:8080/api/jobs \ 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 Workers automatically connect to the server when started. You can: diff --git a/addons/blender/zordon_blender.zip b/addons/blender/zordon_blender.zip new file mode 100644 index 0000000000000000000000000000000000000000..ebd0cf623920544ce6fca0a30d76039b2a911e01 GIT binary patch literal 7189 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 literal 0 HcmV?d00001 diff --git a/addons/blender/zordon_blender/README.md b/addons/blender/zordon_blender/README.md new file mode 100644 index 0000000..bb4831c --- /dev/null +++ b/addons/blender/zordon_blender/README.md @@ -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. diff --git a/addons/blender/zordon_blender/__init__.py b/addons/blender/zordon_blender/__init__.py new file mode 100644 index 0000000..054abb5 --- /dev/null +++ b/addons/blender/zordon_blender/__init__.py @@ -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() diff --git a/src/api/api_server.py b/src/api/api_server.py index 8569b3a..f3930b6 100755 --- a/src/api/api_server.py +++ b/src/api/api_server.py @@ -36,7 +36,7 @@ ssl._create_default_https_context = ssl._create_unverified_context # disable SS API_VERSION = "0.1" -def start_api_server(hostname: Optional[str] = None) -> None: +def start_api_server(hostname: Optional[str] = None, bind_host: str = '0.0.0.0') -> None: # get hostname if not hostname: @@ -54,9 +54,9 @@ def start_api_server(hostname: Optional[str] = None) -> None: flask_log = logging.getLogger('werkzeug') flask_log.setLevel(Config.flask_log_level.upper()) - logger.debug('Starting API server') + logger.debug(f'Starting API server on {bind_host}:{server.config["PORT"]}') try: - server.run(host=hostname, port=server.config['PORT'], debug=Config.flask_debug_enable, use_reloader=False, + server.run(host=bind_host, port=server.config['PORT'], debug=Config.flask_debug_enable, use_reloader=False, threaded=True) finally: logger.debug('Stopping API server') diff --git a/src/api/job_import_handler.py b/src/api/job_import_handler.py index 3fafd53..5238d8d 100644 --- a/src/api/job_import_handler.py +++ b/src/api/job_import_handler.py @@ -37,6 +37,7 @@ class JobImportHandler: processed_child_job_data = processed_job_data.copy() processed_child_job_data.pop("child_jobs") processed_child_job_data.update(child_job_diffs) + processed_child_job_data['__use_output_subdir'] = True job_data_to_create.append(processed_child_job_data) else: job_data_to_create.append(processed_job_data) diff --git a/src/distributed_job_manager.py b/src/distributed_job_manager.py index b7fd82f..dda2629 100644 --- a/src/distributed_job_manager.py +++ b/src/distributed_job_manager.py @@ -100,10 +100,12 @@ class DistributedJobManager: # -------------------------------------------- def _create_render_job(self, new_job_attributes: dict, loaded_project_local_path: Path): - output_path = new_job_attributes.get('output_path') - output_filename = loaded_project_local_path.name if output_path else loaded_project_local_path.stem + requested_output_path = new_job_attributes.get('output_path') + output_filename = Path(str(requested_output_path)).name if requested_output_path else loaded_project_local_path.stem output_dir = loaded_project_local_path.parent.parent / "output" + if new_job_attributes.get('__use_output_subdir'): + output_dir = output_dir / output_filename output_path = output_dir / output_filename os.makedirs(output_dir, exist_ok=True) logger.debug(f"New job output path: {output_path}") diff --git a/tests/test_distributed_job_manager.py b/tests/test_distributed_job_manager.py index 48f5d9c..bbc0e79 100644 --- a/tests/test_distributed_job_manager.py +++ b/tests/test_distributed_job_manager.py @@ -56,8 +56,70 @@ class TestCreateRenderJob: assert result == worker assert worker.status == RenderStatus.NOT_STARTED + assert mock_create_worker.call_args.kwargs['output_path'] == project_path.parent.parent / 'output' / 'test_project' mock_add.assert_called_once_with(worker, force_start=False) + @patch('src.distributed_job_manager.os.makedirs') + @patch('src.distributed_job_manager.EngineManager.create_worker') + def test_uses_requested_output_path( + self, mock_create_worker, mock_makedirs, distributed_job_manager_instance, + config_instance, tmp_path, + ): + worker = MagicMock() + worker.total_frames = 10 + worker.parent = None + mock_create_worker.return_value = worker + + project_path = tmp_path / 'test_project.blend' + project_path.write_text('fake') + + attrs = { + 'engine_name': 'blender', + 'args': {}, + 'name': 'Camera Job', + 'output_path': 'test_project_Camera-001', + 'enable_split_jobs': False, + } + + with patch('src.distributed_job_manager.RenderQueue.add_to_render_queue'): + with patch('src.distributed_job_manager.PreviewManager.update_previews_for_job'): + DistributedJobManager.create_render_job(attrs, project_path) + + assert mock_create_worker.call_args.kwargs['output_path'] == ( + project_path.parent.parent / 'output' / 'test_project_Camera-001' + ) + + @patch('src.distributed_job_manager.os.makedirs') + @patch('src.distributed_job_manager.EngineManager.create_worker') + def test_uses_output_subdir_when_requested( + self, mock_create_worker, mock_makedirs, distributed_job_manager_instance, + config_instance, tmp_path, + ): + worker = MagicMock() + worker.total_frames = 10 + worker.parent = None + mock_create_worker.return_value = worker + + project_path = tmp_path / 'test_project.blend' + project_path.write_text('fake') + + attrs = { + 'engine_name': 'blender', + 'args': {}, + 'name': 'Camera Job', + 'output_path': 'test_project_Camera-001', + '__use_output_subdir': True, + 'enable_split_jobs': False, + } + + with patch('src.distributed_job_manager.RenderQueue.add_to_render_queue'): + with patch('src.distributed_job_manager.PreviewManager.update_previews_for_job'): + DistributedJobManager.create_render_job(attrs, project_path) + + expected_output_dir = project_path.parent.parent / 'output' / 'test_project_Camera-001' + assert mock_create_worker.call_args.kwargs['output_path'] == expected_output_dir / 'test_project_Camera-001' + mock_makedirs.assert_called_with(expected_output_dir, exist_ok=True) + @patch('src.distributed_job_manager.os.makedirs') @patch('src.distributed_job_manager.EngineManager.create_worker') def test_split_jobs_enabled_calls_split_async(