14 Commits

Author SHA1 Message Date
Brett Williams
a76b0340f9 Disable parts of add_job UI when missing critical project data instead of crashing 2024-08-04 12:06:29 -05:00
Brett Williams
f9c114bf32 Add UI options for aerender 2024-08-04 10:00:16 -05:00
Brett Williams
dad9b8c250 Misc improvements 2024-08-04 01:19:22 -05:00
Brett Williams
8826382f86 Misc worker cleanup 2024-08-04 01:03:24 -05:00
Brett Williams
58822c4a20 get_project_info gets comp names from .aepx files now 2024-08-04 01:01:21 -05:00
Brett Williams
dc7f3877b2 Cleanup subprocess generation 2024-08-03 21:23:25 -05:00
Brett Williams
b1280ad445 Add AERender to supported_engines in EngineManager 2024-08-03 21:03:01 -05:00
Brett Williams
0cebb93ba2 Merge remote-tracking branch 'origin/feature/84-after-effects-support' into feature/84-after-effects-support 2024-08-03 20:56:54 -05:00
Brett Williams
a2785400ac Fix generate_worker_subprocess in aerender_worker.py 2024-08-03 20:55:48 -05:00
Brett Williams
d9201b5082 Changes to engine file extensions structure 2024-08-03 20:55:48 -05:00
Brett Williams
7d633d97c2 Fix getting path to After Effects 2024-08-03 20:55:48 -05:00
Brett Williams
e6e2ff8e07 Fix generate_worker_subprocess in aerender_worker.py 2024-08-03 20:45:43 -05:00
Brett Williams
7986960b21 Changes to engine file extensions structure 2024-08-03 20:21:26 -05:00
Brett Williams
ebb847b09e Fix getting path to After Effects 2024-08-03 20:00:36 -05:00
15 changed files with 189 additions and 413 deletions

View File

@@ -1,279 +0,0 @@
#!/usr/bin/env python3
import datetime
import os.path
import socket
import threading
import time
import traceback
from rich import box
from rich.console import Console
from rich.layout import Layout
from rich.live import Live
from rich.panel import Panel
from rich.table import Column
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
from src.engines.core.base_worker import RenderStatus, string_to_status
from src.api.server_proxy import RenderServerProxy
from src.utilities.misc_helper import get_time_elapsed
from start_server import start_server
"""
The RenderDashboard is designed to be run on a remote machine or on the local server
This provides a detailed status of all jobs running on the server
"""
status_colors = {RenderStatus.ERROR: "red", RenderStatus.CANCELLED: 'orange1', RenderStatus.COMPLETED: 'green',
RenderStatus.NOT_STARTED: "yellow", RenderStatus.SCHEDULED: 'purple',
RenderStatus.RUNNING: 'cyan'}
categories = [RenderStatus.RUNNING, RenderStatus.ERROR, RenderStatus.NOT_STARTED, RenderStatus.SCHEDULED,
RenderStatus.COMPLETED, RenderStatus.CANCELLED, RenderStatus.UNDEFINED]
renderer_colors = {'ffmpeg': '[magenta]', 'blender': '[orange1]', 'aerender': '[purple]'}
local_hostname = socket.gethostname()
def status_string_to_color(status_string):
job_status = string_to_status(status_string)
job_color = '[{}]'.format(status_colors[job_status])
return job_color
def sorted_jobs(all_jobs):
sort_by_date = True
if not sort_by_date:
sorted_job_list = []
if all_jobs:
for status_category in categories:
found_jobs = [x for x in all_jobs if x['status'] == status_category.value]
if found_jobs:
sorted_found_jobs = sorted(found_jobs, key=lambda d: datetime.datetime.fromisoformat(d['date_created']), reverse=True)
sorted_job_list.extend(sorted_found_jobs)
else:
sorted_job_list = sorted(all_jobs, key=lambda d: datetime.datetime.fromisoformat(d['date_created']), reverse=True)
return sorted_job_list
def create_node_tree(all_server_data) -> Tree:
main_tree = Tree("[magenta]Server Cluster")
for server_host, server_data in all_server_data['servers'].items():
node_title_local = f"[cyan bold]{server_host}[/] [yellow](This Computer)[default]"
node_title_remote = f"[cyan]{server_host} [magenta](Remote)[default]"
node_tree_text = node_title_local if (server_host == local_hostname) else node_title_remote
if server_data.get('is_online', False):
node_tree_text = node_tree_text + " - [green]Running"
node_tree = Tree(node_tree_text)
stats_text = f"CPU: [yellow]{server_data['status']['cpu_percent']}% [default]| RAM: " \
f"[yellow]{server_data['status']['memory_percent']}% [default]| Cores: " \
f"[yellow]{server_data['status']['cpu_count']} [default]| " \
f"{server_data['status']['platform'].split('-')[0]}"
node_tree.add(Tree(stats_text))
running_jobs = [job for job in server_data['jobs'] if job['status'] == RenderStatus.RUNNING.value]
not_started = [job for job in server_data['jobs'] if job['status'] == RenderStatus.NOT_STARTED.value]
scheduled = [job for job in server_data['jobs'] if job['status'] == RenderStatus.SCHEDULED.value]
jobs_to_display = running_jobs + not_started + scheduled
jobs_tree = Tree(f"Running: [green]{len(running_jobs)} [default]| Queued: [cyan]{len(not_started)}"
f"[default] | Scheduled: [cyan]{len(scheduled)}")
for job in jobs_to_display:
renderer = f"{renderer_colors[job['renderer']]}{job['renderer']}[default]"
filename = os.path.basename(job['input_path']).split('.')[0]
if job['status'] == RenderStatus.RUNNING.value:
jobs_tree.add(f"[bold]{renderer} {filename} ({job['id']}) - {status_string_to_color(job['status'])}{(float(job['percent_complete']) * 100):.1f}%")
else:
jobs_tree.add(f"{filename} ({job['id']}) - {status_string_to_color(job['status'])}{job['status'].title()}")
if not jobs_to_display:
jobs_tree.add("[italic]No running jobs")
node_tree.add(jobs_tree)
main_tree.add(node_tree)
else:
# if server is offline
node_tree_text = node_tree_text + " - [red]Offline"
node_tree = Tree(node_tree_text)
main_tree.add(node_tree)
return main_tree
def create_jobs_table(all_server_data) -> Table:
table = Table("ID", "Name", "Renderer", Column(header="Priority", justify="center"),
Column(header="Status", justify="center"), Column(header="Time Elapsed", justify="right"),
Column(header="# Frames", justify="right"), "Client", show_lines=True,
box=box.HEAVY_HEAD)
all_jobs = []
for server_name, server_data in all_server_data['servers'].items():
for job in server_data['jobs']:
#todo: clean this up
all_jobs.append(job)
all_jobs = sorted_jobs(all_jobs)
for job in all_jobs:
job_status = string_to_status(job['status'])
job_color = '[{}]'.format(status_colors[job_status])
job_text = f"{job_color}" + job_status.value.title()
if job_status == RenderStatus.ERROR and job['errors']:
job_text = job_text + "\n" + "\n".join(job['errors'])
# Project name
project_name = job_color + (job['name'] or os.path.basename(job['input_path']))
elapsed_time = get_time_elapsed(datetime.datetime.fromisoformat(job['start_time']),
datetime.datetime.fromisoformat(job['end_time']))
if job_status == RenderStatus.RUNNING:
job_text = f"{job_color}[bold]Running - {float(job['percent_complete']) * 100:.1f}%"
elapsed_time = "[bold]" + elapsed_time
project_name = "[bold]" + project_name
elif job_status == RenderStatus.CANCELLED or job_status == RenderStatus.ERROR:
project_name = "[strike]" + project_name
# Priority
priority_color = ["red", "yellow", "cyan"][(job['priority'] - 1)]
client_name = job['client'] or 'unknown'
client_colors = {'unknown': '[red]', local_hostname: '[yellow]'}
client_title = client_colors.get(client_name, '[magenta]') + client_name
table.add_row(
job['id'],
project_name,
renderer_colors.get(job['renderer'], '[cyan]') + job['renderer'] + '[default]-' + job['renderer_version'],
f"[{priority_color}]{job['priority']}",
job_text,
elapsed_time,
str(max(int(job['total_frames']), 1)),
client_title
)
return table
def create_status_panel(all_server_data):
for key, value in all_server_data['servers'].items():
if key == local_hostname:
return str(value['status'])
return "no status"
class KeyboardThread(threading.Thread):
def __init__(self, input_cbk = None, name='keyboard-input-thread'):
self.input_cbk = input_cbk
super(KeyboardThread, self).__init__(name=name)
self.start()
def run(self):
while True:
self.input_cbk(input()) #waits to get input + Return
def my_callback(inp):
#evaluate the keyboard input
print('You Entered:', inp)
if __name__ == '__main__':
get_server_ip = input("Enter server IP or None for local: ") or local_hostname
server_proxy = RenderServerProxy(get_server_ip, "8080")
if not server_proxy.connect():
if server_proxy.hostname == local_hostname:
start_server_input = input("Local server not running. Start server? (y/n) ")
if start_server_input and start_server_input[0].lower() == "y":
# Startup the local server
start_server()
test = server_proxy.connect()
print(f"connected? {test}")
else:
print(f"\nUnable to connect to server: {server_proxy.hostname}")
print("\nVerify IP address is correct and server is running")
exit(1)
# start the Keyboard thread
# kthread = KeyboardThread(my_callback)
# Console Layout
console = Console()
layout = Layout()
# Divide the "screen" in to three parts
layout.split(
Layout(name="header", size=3),
Layout(ratio=1, name="main")
# Layout(size=10, name="footer"),
)
# Divide the "main" layout in to "side" and "body"
layout["main"].split_row(
Layout(name="side"),
Layout(name="body",
ratio=3))
# Divide the "side" layout in to two
layout["side"].split(Layout(name="side_top"), Layout(name="side_bottom"))
# Server connection header
header_text = Text(f"Connected to server: ")
header_text.append(f"{server_proxy.hostname} ", style="green")
if server_proxy.hostname == local_hostname:
header_text.append("(This Computer)", style="yellow")
else:
header_text.append("(Remote)", style="magenta")
# background process to update server data independent of the UI
def fetch_server_data(server):
while True:
fetched_data = server.get_data(timeout=5)
if fetched_data:
server.fetched_status_data = fetched_data
time.sleep(1)
x = threading.Thread(target=fetch_server_data, args=(server_proxy,))
x.daemon = True
x.start()
# draw and update the UI
with Live(console=console, screen=False, refresh_per_second=1, transient=True) as live:
while True:
try:
if server_proxy.fetched_status_data:
server_online = False
if server_proxy.fetched_status_data.get('timestamp', None):
timestamp = datetime.datetime.fromisoformat(server_proxy.fetched_status_data['timestamp'])
time_diff = datetime.datetime.now() - timestamp
server_online = time_diff.seconds < 10 # client is offline if not updated in certain time
layout["body"].update(create_jobs_table(server_proxy.fetched_status_data))
layout["side_top"].update(Panel(create_node_tree(server_proxy.fetched_status_data)))
layout["side_bottom"].update(Panel(create_status_panel(server_proxy.fetched_status_data)))
online_text = "Online" if server_online else "Offline"
online_color = "green" if server_online else "red"
layout["header"].update(Panel(Text(f"Zordon Render Client - Version 0.0.1 alpha - {online_text}",
justify="center", style=online_color)))
live.update(layout, refresh=False)
except Exception as e:
print(f"Exception updating table: {e}")
traceback.print_exception(e)
time.sleep(1)
# # # todo: Add input prompt to manage running jobs (ie add, cancel, get info, etc)

View File

@@ -11,7 +11,13 @@ from setuptools import setup
APP = ['main.py'] APP = ['main.py']
DATA_FILES = [('config', glob.glob('config/*.*')), DATA_FILES = [('config', glob.glob('config/*.*')),
('resources', glob.glob('resources/*.*'))] ('resources', glob.glob('resources/*.*'))]
OPTIONS = {} OPTIONS = {
'excludes': ['PySide6'],
'includes': ['zeroconf', 'zeroconf._services.info'],
'plist': {
'LSMinimumSystemVersion': '10.15', # Specify minimum macOS version
},
}
setup( setup(
app=APP, app=APP,

View File

@@ -463,6 +463,7 @@ def delete_engine_download():
@server.get('/api/renderer/<renderer>/args') @server.get('/api/renderer/<renderer>/args')
def get_renderer_args(renderer): def get_renderer_args(renderer):
try: try:
# todo: possibly deprecate
renderer_engine_class = EngineManager.engine_with_name(renderer) renderer_engine_class = EngineManager.engine_with_name(renderer)
return renderer_engine_class().get_arguments() return renderer_engine_class().get_arguments()
except LookupError: except LookupError:

View File

@@ -1,22 +1,74 @@
import glob
import logging
import subprocess
from src.engines.core.base_engine import BaseRenderEngine from src.engines.core.base_engine import BaseRenderEngine
logger = logging.getLogger()
class AERender(BaseRenderEngine): class AERender(BaseRenderEngine):
supported_extensions = ['.aep'] file_extensions = ['aepx']
def version(self): def version(self):
version = None version = None
try: try:
render_path = self.renderer_path() render_path = self.renderer_path()
if render_path: if render_path:
ver_out = subprocess.check_output([render_path, '-version'], timeout=SUBPROCESS_TIMEOUT) ver_out = subprocess.run([render_path, '-version'], capture_output=True, text=True)
version = ver_out.decode('utf-8').split(" ")[-1].strip() version = ver_out.stdout.split(" ")[-1].strip()
except Exception as e: except Exception as e:
logger.error(f'Failed to get {self.name()} version: {e}') logger.error(f'Failed to get {self.name()} version: {e}')
return version return version
@classmethod
def default_renderer_path(cls):
paths = glob.glob('/Applications/*After Effects*/aerender')
if len(paths) > 1:
logger.warning('Multiple After Effects installations detected')
elif not paths:
logger.error('After Effects installation not found')
return paths[0]
def get_project_info(self, project_path, timeout=10):
scene_info = {}
try:
import xml.etree.ElementTree as ET
tree = ET.parse(project_path)
root = tree.getroot()
namespace = {'ae': 'http://www.adobe.com/products/aftereffects'}
comp_names = []
for item in root.findall(".//ae:Item", namespace):
if item.find("ae:Layr", namespace) is not None:
for string in item.findall("./ae:string", namespace):
comp_names.append(string.text)
scene_info['comp_names'] = comp_names
except Exception as e:
logger.error(f'Error getting file details for .aepx file: {e}')
return scene_info
def run_javascript(self, script_path, project_path, timeout=None):
# todo: implement
pass
@classmethod @classmethod
def get_output_formats(cls): def get_output_formats(cls):
# todo: create implementation # todo: create implementation
return [] return []
def ui_options(self, project_info):
from src.engines.aerender.aerender_ui import AERenderUI
return AERenderUI.get_options(self, project_info)
@classmethod
def worker_class(cls):
from src.engines.aerender.aerender_worker import AERenderWorker
return AERenderWorker
if __name__ == "__main__":
x = AERender().get_project_info('/Users/brett/ae_testing/project.aepx')
print(x)

View File

@@ -0,0 +1,8 @@
class AERenderUI:
@staticmethod
def get_options(instance, project_info):
options = [
{'name': 'comp', 'options': project_info.get('comp_names', [])}
]
return options

View File

@@ -9,72 +9,39 @@ import time
from src.engines.core.base_worker import BaseRenderWorker, timecode_to_frames from src.engines.core.base_worker import BaseRenderWorker, timecode_to_frames
from src.engines.aerender.aerender_engine import AERender from src.engines.aerender.aerender_engine import AERender
logger = logging.getLogger()
def aerender_path():
paths = glob.glob('/Applications/*After Effects*/aerender')
if len(paths) > 1:
logging.warning('Multiple After Effects installations detected')
elif not paths:
logging.error('After Effects installation not found')
else:
return paths[0]
class AERenderWorker(BaseRenderWorker): class AERenderWorker(BaseRenderWorker):
supported_extensions = ['.aep']
engine = AERender engine = AERender
def __init__(self, input_path, output_path, args=None, parent=None, name=None): def __init__(self, input_path, output_path, engine_path, args=None, parent=None, name=None):
super(AERenderWorker, self).__init__(input_path=input_path, output_path=output_path, args=args, super(AERenderWorker, self).__init__(input_path=input_path, output_path=output_path, engine_path=engine_path,
parent=parent, name=name) args=args, parent=parent, name=name)
self.comp = args.get('comp', None) # temp files for processing stdout
self.render_settings = args.get('render_settings', None) self.__progress_history = []
self.omsettings = args.get('omsettings', None) self.__temp_attributes = {}
self.progress = 0
self.progress_history = []
self.attributes = {}
def generate_worker_subprocess(self): def generate_worker_subprocess(self):
if os.path.exists('nexrender-cli-macos'): comp = self.args.get('comp', 'Comp 1')
logging.info('nexrender found') render_settings = self.args.get('render_settings', None)
# { omsettings = self.args.get('omsettings', None)
# "template": {
# "src": String, command = [self.renderer_path, '-project', self.input_path, '-comp', f'"{comp}"']
# "composition": String,
# if render_settings:
# "frameStart": Number, command.extend(['-RStemplate', render_settings])
# "frameEnd": Number,
# "frameIncrement": Number, if omsettings:
# command.extend(['-OMtemplate', omsettings])
# "continueOnMissing": Boolean,
# "settingsTemplate": String, command.extend(['-s', self.start_frame,
# "outputModule": String, '-e', self.end_frame,
# "outputExt": String, '-output', self.output_path])
# }, return command
# "assets": [],
# "actions": {
# "prerender": [],
# "postrender": [],
# },
# "onChange": Function,
# "onRenderProgress": Function
# }
job = {'template':
{
'src': 'file://' + self.input_path, 'composition': self.comp.replace('"', ''),
'settingsTemplate': self.render_settings.replace('"', ''),
'outputModule': self.omsettings.replace('"', ''), 'outputExt': 'mov'}
}
x = ['./nexrender-cli-macos', "'{}'".format(json.dumps(job))]
else:
logging.info('nexrender not found')
x = [aerender_path(), '-project', self.input_path, '-comp', self.comp, '-RStemplate', self.render_settings,
'-OMtemplate', self.omsettings, '-output', self.output_path]
return x
def _parse_stdout(self, line): def _parse_stdout(self, line):
@@ -83,12 +50,12 @@ class AERenderWorker(BaseRenderWorker):
# print 'progress' # print 'progress'
trimmed = line.replace('PROGRESS:', '').strip() trimmed = line.replace('PROGRESS:', '').strip()
if len(trimmed): if len(trimmed):
self.progress_history.append(line) self.__progress_history.append(line)
if 'Seconds' in trimmed: if 'Seconds' in trimmed:
self._update_progress(line) self._update_progress(line)
elif ': ' in trimmed: elif ': ' in trimmed:
tmp = trimmed.split(': ') tmp = trimmed.split(': ')
self.attributes[tmp[0].strip()] = tmp[1].strip() self.__temp_attributes[tmp[0].strip()] = tmp[1].strip()
elif line.startswith('WARNING:'): elif line.startswith('WARNING:'):
trimmed = line.replace('WARNING:', '').strip() trimmed = line.replace('WARNING:', '').strip()
self.warnings.append(trimmed) self.warnings.append(trimmed)
@@ -99,28 +66,28 @@ class AERenderWorker(BaseRenderWorker):
def _update_progress(self, line): def _update_progress(self, line):
if not self.total_frames: if not self.total_frames:
duration_string = self.attributes.get('Duration', None) duration_string = self.__temp_attributes.get('Duration', None)
frame_rate = self.attributes.get('Frame Rate', '0').split(' ')[0] frame_rate = self.__temp_attributes.get('Frame Rate', '0').split(' ')[0]
self.total_frames = timecode_to_frames(duration_string.split('Duration:')[-1], float(frame_rate)) self.total_frames = timecode_to_frames(duration_string.split('Duration:')[-1], float(frame_rate))
match = re.match(r'PROGRESS:.*\((?P<frame>\d+)\): (?P<time>\d+)', line).groupdict() match = re.match(r'PROGRESS:.*\((?P<frame>\d+)\): (?P<time>\d+)', line).groupdict()
self.last_frame = match['frame'] self.current_frame = match['frame']
def average_frame_duration(self): def average_frame_duration(self):
total_durations = 0 total_durations = 0
for line in self.progress_history: for line in self.__progress_history:
match = re.match(r'PROGRESS:.*\((?P<frame>\d+)\): (?P<time>\d+)', line) match = re.match(r'PROGRESS:.*\((?P<frame>\d+)\): (?P<time>\d+)', line)
if match: if match:
total_durations += int(match.group(2)) total_durations += int(match.group(2))
average = float(total_durations) / self.last_frame average = float(total_durations) / self.current_frame
return average return average
def percent_complete(self): def percent_complete(self):
if self.total_frames: if self.total_frames:
return (float(self.last_frame) / float(self.total_frames)) * 100 return (float(self.current_frame) / float(self.total_frames)) * 100
else: else:
return 0 return 0
@@ -128,8 +95,11 @@ class AERenderWorker(BaseRenderWorker):
if __name__ == '__main__': if __name__ == '__main__':
logging.basicConfig(format='%(asctime)s - %(message)s', datefmt='%d-%b-%y %H:%M:%S', level=logging.DEBUG) logging.basicConfig(format='%(asctime)s - %(message)s', datefmt='%d-%b-%y %H:%M:%S', level=logging.DEBUG)
r = AERenderWorker('/Users/brett/Desktop/Youtube_Vids/Film_Formats/Frame_Animations.aep', '"Film Pan"', r = AERenderWorker(input_path='/Users/brett/ae_testing/project.aepx',
'"Draft Settings"', '"ProRes"', '/Users/brett/Desktop/test_render') output_path='/Users/brett/ae_testing/project.mp4',
engine_path=AERenderWorker.engine.default_renderer_path(),
args={'start_frame': 1, 'end_frame': 5})
r.start() r.start()
while r.is_running(): while r.is_running():
time.sleep(0.1) time.sleep(0.1)

View File

@@ -11,25 +11,22 @@ class Blender(BaseRenderEngine):
install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender'] install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender']
binary_names = {'linux': 'blender', 'windows': 'blender.exe', 'macos': 'Blender'} binary_names = {'linux': 'blender', 'windows': 'blender.exe', 'macos': 'Blender'}
file_extensions = ['blend']
@staticmethod @staticmethod
def downloader(): def downloader():
from src.engines.blender.blender_downloader import BlenderDownloader from src.engines.blender.blender_downloader import BlenderDownloader
return BlenderDownloader return BlenderDownloader
@staticmethod @classmethod
def worker_class(): def worker_class(cls):
from src.engines.blender.blender_worker import BlenderRenderWorker from src.engines.blender.blender_worker import BlenderRenderWorker
return BlenderRenderWorker return BlenderRenderWorker
def ui_options(self): def ui_options(self, project_info):
from src.engines.blender.blender_ui import BlenderUI from src.engines.blender.blender_ui import BlenderUI
return BlenderUI.get_options(self) return BlenderUI.get_options(self)
@staticmethod
def supported_extensions():
return ['blend']
def version(self): def version(self):
version = None version = None
try: try:
@@ -115,7 +112,7 @@ class Blender(BaseRenderEngine):
logger.error(f'Error packing .blend file: {e}') logger.error(f'Error packing .blend file: {e}')
return None return None
def get_arguments(self): def get_arguments(self): # possibly deprecate
help_text = subprocess.check_output([self.renderer_path(), '-h']).decode('utf-8') help_text = subprocess.check_output([self.renderer_path(), '-h']).decode('utf-8')
lines = help_text.splitlines() lines = help_text.splitlines()

View File

@@ -12,17 +12,12 @@ class BlenderRenderWorker(BaseRenderWorker):
engine = Blender engine = Blender
def __init__(self, input_path, output_path, engine_path, args=None, parent=None, name=None): def __init__(self, input_path, output_path, engine_path, args=None, parent=None, name=None):
super(BlenderRenderWorker, self).__init__(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args, parent=parent, name=name) super(BlenderRenderWorker, self).__init__(input_path=input_path, output_path=output_path,
engine_path=engine_path, args=args, parent=parent, name=name)
# Stats # Stats
self.__frame_percent_complete = 0.0 self.__frame_percent_complete = 0.0
self.current_frame = -1 # todo: is this necessary?
# Scene Info
self.scene_info = Blender(engine_path).get_project_info(input_path)
self.start_frame = int(self.scene_info.get('start_frame', 1))
self.end_frame = int(self.scene_info.get('end_frame', self.start_frame))
self.project_length = (self.end_frame - self.start_frame) + 1
self.current_frame = -1
def generate_worker_subprocess(self): def generate_worker_subprocess(self):

View File

@@ -19,8 +19,8 @@ for cam_obj in bpy.data.cameras:
data = {'cameras': cameras, data = {'cameras': cameras,
'engine': scene.render.engine, 'engine': scene.render.engine,
'frame_start': scene.frame_start, 'start_frame': scene.frame_start,
'frame_end': scene.frame_end, 'end_frame': scene.frame_end,
'resolution_x': scene.render.resolution_x, 'resolution_x': scene.render.resolution_x,
'resolution_y': scene.render.resolution_y, 'resolution_y': scene.render.resolution_y,
'resolution_percentage': scene.render.resolution_percentage, 'resolution_percentage': scene.render.resolution_percentage,

View File

@@ -9,12 +9,12 @@ SUBPROCESS_TIMEOUT = 5
class BaseRenderEngine(object): class BaseRenderEngine(object):
install_paths = [] install_paths = []
supported_extensions = [] file_extensions = []
def __init__(self, custom_path=None): def __init__(self, custom_path=None):
self.custom_renderer_path = custom_path self.custom_renderer_path = custom_path
if not self.renderer_path() or not os.path.exists(self.renderer_path()): if not self.renderer_path() or not os.path.exists(self.renderer_path()):
raise FileNotFoundError(f"Cannot find path to renderer for {self.name()} instance") raise FileNotFoundError(f"Cannot find path ({self.renderer_path()}) for renderer '{self.name()}'")
if not os.access(self.renderer_path(), os.X_OK): if not os.access(self.renderer_path(), os.X_OK):
logger.warning(f"Path is not executable. Setting permissions to 755 for {self.renderer_path()}") logger.warning(f"Path is not executable. Setting permissions to 755 for {self.renderer_path()}")
@@ -47,19 +47,18 @@ class BaseRenderEngine(object):
def downloader(): # override when subclassing if using a downloader class def downloader(): # override when subclassing if using a downloader class
return None return None
@staticmethod @classmethod
def worker_class(): # override when subclassing to link worker class def worker_class(cls): # override when subclassing to link worker class
raise NotImplementedError("Worker class not implemented") raise NotImplementedError(f"Worker class not implemented for engine {cls.name()}")
def ui_options(self): # override to return options for ui def ui_options(self, project_info): # override to return options for ui
return {} return {}
def get_help(self): # override if renderer uses different help flag def get_help(self): # override if renderer uses different help flag
path = self.renderer_path() path = self.renderer_path()
if not path: if not path:
raise FileNotFoundError("renderer path not found") raise FileNotFoundError("renderer path not found")
help_doc = subprocess.check_output([path, '-h'], stderr=subprocess.STDOUT, help_doc = subprocess.run([path, '-h'], capture_output=True, text=True).stdout.strip()
timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
return help_doc return help_doc
def get_project_info(self, project_path, timeout=10): def get_project_info(self, project_path, timeout=10):
@@ -69,6 +68,10 @@ class BaseRenderEngine(object):
def get_output_formats(cls): def get_output_formats(cls):
raise NotImplementedError(f"get_output_formats not implemented for {cls.__name__}") raise NotImplementedError(f"get_output_formats not implemented for {cls.__name__}")
@classmethod
def supported_extensions(cls):
return cls.file_extensions
def get_arguments(self): def get_arguments(self):
pass pass

View File

@@ -81,8 +81,11 @@ class BaseRenderWorker(Base):
# Frame Ranges # Frame Ranges
self.project_length = 0 # is this necessary? self.project_length = 0 # is this necessary?
self.current_frame = 0 self.current_frame = 0
self.start_frame = 0
self.end_frame = None # Get Project Info
self.scene_info = self.engine(engine_path).get_project_info(project_path=input_path)
self.start_frame = int(self.scene_info.get('start_frame', 1))
self.end_frame = int(self.scene_info.get('end_frame', self.start_frame))
# Logging # Logging
self.start_time = None self.start_time = None
@@ -192,7 +195,7 @@ class BaseRenderWorker(Base):
f.write(f"{self.start_time.isoformat()} - Starting {self.engine.name()} {self.renderer_version} " f.write(f"{self.start_time.isoformat()} - Starting {self.engine.name()} {self.renderer_version} "
f"render for {self.input_path}\n\n") f"render for {self.input_path}\n\n")
f.write(f"Running command: {subprocess_cmds}\n") f.write(f"Running command: \"{' '.join(subprocess_cmds)}\"\n")
f.write('=' * 80 + '\n\n') f.write('=' * 80 + '\n\n')
while True: while True:
@@ -207,9 +210,9 @@ class BaseRenderWorker(Base):
else: else:
f.write(f'\n{"=" * 20} Attempt #{failed_attempts + 1} {"=" * 20}\n\n') f.write(f'\n{"=" * 20} Attempt #{failed_attempts + 1} {"=" * 20}\n\n')
logger.warning(f"Restarting render - Attempt #{failed_attempts + 1}") logger.warning(f"Restarting render - Attempt #{failed_attempts + 1}")
self.status = RenderStatus.RUNNING
# Start process and get updates # Start process and get updates
self.status = RenderStatus.RUNNING
self.__process = subprocess.Popen(subprocess_cmds, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, self.__process = subprocess.Popen(subprocess_cmds, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
universal_newlines=False) universal_newlines=False)

View File

@@ -6,6 +6,7 @@ import concurrent.futures
from src.engines.blender.blender_engine import Blender from src.engines.blender.blender_engine import Blender
from src.engines.ffmpeg.ffmpeg_engine import FFMPEG from src.engines.ffmpeg.ffmpeg_engine import FFMPEG
from src.engines.aerender.aerender_engine import AERender
from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu
logger = logging.getLogger() logger = logging.getLogger()
@@ -18,7 +19,7 @@ class EngineManager:
@staticmethod @staticmethod
def supported_engines(): def supported_engines():
return [Blender, FFMPEG] return [Blender, FFMPEG, AERender]
@classmethod @classmethod
def engine_with_name(cls, engine_name): def engine_with_name(cls, engine_name):
@@ -79,17 +80,20 @@ class EngineManager:
'type': 'system' 'type': 'system'
} }
if not filter_name:
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
futures = { futures = {
executor.submit(fetch_engine_details, eng): eng.name() executor.submit(fetch_engine_details, eng): eng.name()
for eng in cls.supported_engines() for eng in cls.supported_engines()
if eng.default_renderer_path() and (not filter_name or filter_name == eng.name()) if eng.default_renderer_path()
} }
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
result = future.result() result = future.result()
if result: if result:
results.append(result) results.append(result)
else:
results.append(fetch_engine_details(cls.engine_with_name(filter_name)))
return results return results
@@ -294,6 +298,6 @@ if __name__ == '__main__':
# print(EngineManager.newest_engine_version('blender', 'macos', 'arm64')) # print(EngineManager.newest_engine_version('blender', 'macos', 'arm64'))
# EngineManager.delete_engine_download('blender', '3.2.1', 'macos', 'a') # EngineManager.delete_engine_download('blender', '3.2.1', 'macos', 'a')
EngineManager.engines_path = "/Users/brettwilliams/zordon-uploads/engines" EngineManager.engines_path = "/Users/brettwilliams/zordon-uploads/engines/"
# print(EngineManager.is_version_downloaded("ffmpeg", "6.0")) # print(EngineManager.is_version_downloaded("ffmpeg", "6.0"))
print(EngineManager.get_engines()) print(EngineManager.get_engines())

View File

@@ -12,24 +12,26 @@ class FFMPEG(BaseRenderEngine):
from src.engines.ffmpeg.ffmpeg_downloader import FFMPEGDownloader from src.engines.ffmpeg.ffmpeg_downloader import FFMPEGDownloader
return FFMPEGDownloader return FFMPEGDownloader
@staticmethod @classmethod
def worker_class(): def worker_class(cls):
from src.engines.ffmpeg.ffmpeg_worker import FFMPEGRenderWorker from src.engines.ffmpeg.ffmpeg_worker import FFMPEGRenderWorker
return FFMPEGRenderWorker return FFMPEGRenderWorker
def ui_options(self): def ui_options(self, project_info):
from src.engines.ffmpeg.ffmpeg_ui import FFMPEGUI from src.engines.ffmpeg.ffmpeg_ui import FFMPEGUI
return FFMPEGUI.get_options(self) return FFMPEGUI.get_options(self, project_info)
@classmethod @classmethod
def supported_extensions(cls): def supported_extensions(cls):
if not cls.file_extensions:
help_text = (subprocess.check_output([cls().renderer_path(), '-h', 'full'], stderr=subprocess.STDOUT) help_text = (subprocess.check_output([cls().renderer_path(), '-h', 'full'], stderr=subprocess.STDOUT)
.decode('utf-8')) .decode('utf-8'))
found = re.findall(r'extensions that .* is allowed to access \(default "(.*)"', help_text) found = re.findall(r'extensions that .* is allowed to access \(default "(.*)"', help_text)
found_extensions = set() found_extensions = set()
for match in found: for match in found:
found_extensions.update(match.split(',')) found_extensions.update(match.split(','))
return list(found_extensions) cls.file_extensions = list(found_extensions)
return cls.file_extensions
def version(self): def version(self):
version = None version = None

View File

@@ -1,5 +1,5 @@
class FFMPEGUI: class FFMPEGUI:
@staticmethod @staticmethod
def get_options(instance): def get_options(instance, project_info):
options = [] options = []
return options return options

View File

@@ -322,12 +322,22 @@ class NewRenderJobForm(QWidget):
self.load_file_group.setHidden(True) self.load_file_group.setHidden(True)
self.toggle_renderer_enablement(True) self.toggle_renderer_enablement(True)
# Load scene data # -- Load scene data
self.start_frame_input.setValue(self.project_info.get('frame_start')) # start / end frames
self.end_frame_input.setValue(self.project_info.get('frame_end')) self.start_frame_input.setValue(self.project_info.get('start_frame', 0))
self.resolution_x_input.setValue(self.project_info.get('resolution_x')) self.end_frame_input.setValue(self.project_info.get('end_frame', 0))
self.resolution_y_input.setValue(self.project_info.get('resolution_y')) self.start_frame_input.setEnabled(bool(self.project_info.get('start_frame')))
self.frame_rate_input.setValue(self.project_info.get('fps')) self.end_frame_input.setEnabled(bool(self.project_info.get('start_frame')))
# resolution
self.resolution_x_input.setValue(self.project_info.get('resolution_x', 1920))
self.resolution_y_input.setValue(self.project_info.get('resolution_y', 1080))
self.resolution_x_input.setEnabled(bool(self.project_info.get('resolution_x')))
self.resolution_y_input.setEnabled(bool(self.project_info.get('resolution_y')))
# frame rate
self.frame_rate_input.setValue(self.project_info.get('fps', 24))
self.frame_rate_input.setEnabled(bool(self.project_info.get('fps')))
# Cameras # Cameras
self.cameras_list.clear() self.cameras_list.clear()
@@ -350,7 +360,7 @@ class NewRenderJobForm(QWidget):
# Dynamic Engine Options # Dynamic Engine Options
clear_layout(self.renderer_options_layout) # clear old options clear_layout(self.renderer_options_layout) # clear old options
# dynamically populate option list # dynamically populate option list
self.current_engine_options = engine().ui_options() self.current_engine_options = engine().ui_options(self.project_info)
for option in self.current_engine_options: for option in self.current_engine_options:
h_layout = QHBoxLayout() h_layout = QHBoxLayout()
label = QLabel(option['name'].replace('_', ' ').capitalize() + ':') label = QLabel(option['name'].replace('_', ' ').capitalize() + ':')
@@ -496,8 +506,12 @@ class SubmitWorker(QThread):
engine = EngineManager.engine_with_name(self.window.renderer_type.currentText().lower()) engine = EngineManager.engine_with_name(self.window.renderer_type.currentText().lower())
input_path = engine().perform_presubmission_tasks(input_path) input_path = engine().perform_presubmission_tasks(input_path)
# submit # submit
result = None
try:
result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list, result = self.window.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list,
callback=create_callback) callback=create_callback)
except Exception as e:
pass
self.message_signal.emit(result) self.message_signal.emit(result)