Downloadable engines (#34)

* Add blender_downloader.py

* Add engine_manager.py

* Add additional methods to engine_manager.py

* Refactor file layout to make engines on par with workers

* Add system platform info to status response

* Default to using system platform / cpu if none are provided

* Add API to download an engine and some general cleanup

* Add method to delete downloaded engine

* Add API calls to download engines and delete downloads

* Misc fixes
This commit is contained in:
2023-10-20 15:05:29 -05:00
committed by GitHub
parent 4563dcb255
commit 7d1ecf1fa5
21 changed files with 439 additions and 79 deletions

View File

@@ -10,3 +10,5 @@ SQLAlchemy~=2.0.15
Pillow==9.5.0 Pillow==9.5.0
zeroconf==0.64.1 zeroconf==0.64.1
Pypubsub~=4.0.3 Pypubsub~=4.0.3
tqdm==4.66.1
dmglib

View File

@@ -21,11 +21,12 @@ from flask import Flask, request, render_template, send_file, after_this_request
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from src.distributed_job_manager import DistributedJobManager from src.distributed_job_manager import DistributedJobManager
from src.engines.engine_manager import EngineManager
from src.render_queue import RenderQueue, JobNotFoundError from src.render_queue import RenderQueue, JobNotFoundError
from src.server_proxy import RenderServerProxy from src.server_proxy import RenderServerProxy
from src.utilities.server_helper import generate_thumbnail_for_job from src.utilities.server_helper import generate_thumbnail_for_job
from src.utilities.zeroconf_server import ZeroconfServer from src.utilities.zeroconf_server import ZeroconfServer
from src.worker_factory import RenderWorkerFactory from src.workers.worker_factory import RenderWorkerFactory
from src.workers.base_worker import string_to_status, RenderStatus from src.workers.base_worker import string_to_status, RenderStatus
logger = logging.getLogger() logger = logging.getLogger()
@@ -499,14 +500,21 @@ def clear_history():
def status(): def status():
renderer_data = {} renderer_data = {}
for render_class in RenderWorkerFactory.supported_classes(): for render_class in RenderWorkerFactory.supported_classes():
if render_class.engine.renderer_path(): # only return renderers installed on host if render_class.engine.default_renderer_path(): # only return renderers installed on host
renderer_data[render_class.engine.name()] = \ renderer_data[render_class.engine.name()] = \
{'version': render_class.engine.version(), {'versions': EngineManager.all_versions_for_engine(render_class.engine.name()),
'is_available': RenderQueue.is_available_for_job(render_class.engine.name()) 'is_available': RenderQueue.is_available_for_job(render_class.engine.name())
} }
# Get system info
system_platform = platform.system().lower().replace('darwin', 'macos')
system_platform_version = platform.mac_ver()[0] if system_platform == 'macos' else platform.release().lower()
system_cpu = platform.machine().lower().replace('amd64', 'x64')
return {"timestamp": datetime.now().isoformat(), return {"timestamp": datetime.now().isoformat(),
"platform": platform.platform(), "system_platform": system_platform,
"system_platform_version": system_platform_version,
"system_cpu": system_cpu,
"cpu_percent": psutil.cpu_percent(percpu=False), "cpu_percent": psutil.cpu_percent(percpu=False),
"cpu_percent_per_cpu": psutil.cpu_percent(percpu=True), "cpu_percent_per_cpu": psutil.cpu_percent(percpu=True),
"cpu_count": psutil.cpu_count(logical=False), "cpu_count": psutil.cpu_count(logical=False),
@@ -523,17 +531,36 @@ def status():
@server.get('/api/renderer_info') @server.get('/api/renderer_info')
def renderer_info(): def renderer_info():
renderer_data = {} renderer_data = {}
for r in RenderWorkerFactory.supported_renderers(): for engine_name in RenderWorkerFactory.supported_renderers():
engine = RenderWorkerFactory.class_for_name(r).engine engine = RenderWorkerFactory.class_for_name(engine_name).engine
if engine.renderer_path(): if engine.default_renderer_path():
renderer_data[r] = {'is_available': RenderQueue.is_available_for_job(engine.name()),
'version': engine.version(), # Get all installed versions of engine
renderer_data[engine_name] = {'is_available': RenderQueue.is_available_for_job(engine.name()),
'versions': EngineManager.all_versions_for_engine(engine_name),
'supported_extensions': engine.supported_extensions, 'supported_extensions': engine.supported_extensions,
'supported_export_formats': engine.get_output_formats(), 'supported_export_formats': engine().get_output_formats()}
'path': engine.renderer_path()}
return renderer_data return renderer_data
@server.post('/api/download_engine')
def download_engine():
download_result = EngineManager.download_engine(request.args.get('engine'),
request.args.get('version'),
request.args.get('system_os'),
request.args.get('cpu'))
return download_result if download_result else ("Error downloading requested engine", 500)
@server.post('/api/delete_engine')
def delete_engine_download():
delete_result = EngineManager.delete_engine_download(request.args.get('engine'),
request.args.get('version'),
request.args.get('system_os'),
request.args.get('cpu'))
return "Success" if delete_result else ("Error deleting requested engine", 500)
@server.route('/upload') @server.route('/upload')
def upload_file_page(): def upload_file_page():
return render_template('upload.html', supported_renderers=RenderWorkerFactory.supported_renderers()) return render_template('upload.html', supported_renderers=RenderWorkerFactory.supported_renderers())
@@ -563,6 +590,10 @@ def start_server(background_thread=False):
server.config['MAX_CONTENT_PATH'] = config['max_content_path'] server.config['MAX_CONTENT_PATH'] = config['max_content_path']
server.config['enable_split_jobs'] = config.get('enable_split_jobs', False) server.config['enable_split_jobs'] = config.get('enable_split_jobs', False)
# Setup directory for saving engines to
EngineManager.engines_path = os.path.join(os.path.join(os.path.expanduser(config['upload_folder']), 'engines'))
os.makedirs(EngineManager.engines_path, exist_ok=True)
# disable most Flask logging # disable most Flask logging
flask_log = logging.getLogger('werkzeug') flask_log = logging.getLogger('werkzeug')
flask_log.setLevel(config.get('flask_log_level', 'ERROR').upper()) flask_log.setLevel(config.get('flask_log_level', 'ERROR').upper())

View File

@@ -224,7 +224,7 @@ class NewJobWindow(Frame):
return return
if renderer == 'blender': if renderer == 'blender':
self.project_info = Blender.get_scene_info(self.chosen_file) self.project_info = Blender().get_scene_info(self.chosen_file)
self.draw_blender_settings() self.draw_blender_settings()
elif renderer == 'ffmpeg': elif renderer == 'ffmpeg':
f = FFMPEG.get_frame_count(self.chosen_file) f = FFMPEG.get_frame_count(self.chosen_file)
@@ -371,7 +371,7 @@ class NewJobWindow(Frame):
if renderer == 'blender': if renderer == 'blender':
if self.blender_pack_textures.get(): if self.blender_pack_textures.get():
self.progress_label.configure(text="Packing Blender file...") self.progress_label.configure(text="Packing Blender file...")
new_path = Blender.pack_project_file(project_path=input_path, timeout=300) new_path = Blender().pack_project_file(project_path=input_path, timeout=300)
if new_path: if new_path:
logger.info(f"New Path is now {new_path}") logger.info(f"New Path is now {new_path}")
input_path = new_path input_path = new_path

View File

@@ -8,16 +8,15 @@ class AERender(BaseRenderEngine):
supported_extensions = ['.aep'] supported_extensions = ['.aep']
@classmethod def version(self):
def version(cls):
version = None version = None
try: try:
render_path = cls.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.check_output([render_path, '-version'], timeout=SUBPROCESS_TIMEOUT)
version = ver_out.decode('utf-8').split(" ")[-1].strip() version = ver_out.decode('utf-8').split(" ")[-1].strip()
except Exception as e: except Exception as e:
logger.error(f'Failed to get {cls.name()} version: {e}') logger.error(f'Failed to get {self.name()} version: {e}')
return version return version
@classmethod @classmethod

View File

@@ -11,12 +11,18 @@ class BaseRenderEngine(object):
install_paths = [] install_paths = []
supported_extensions = [] supported_extensions = []
def __init__(self, custom_path=None):
self.custom_renderer_path = custom_path
def renderer_path(self):
return self.custom_renderer_path or self.default_renderer_path()
@classmethod @classmethod
def name(cls): def name(cls):
return cls.__name__.lower() return cls.__name__.lower()
@classmethod @classmethod
def renderer_path(cls): def default_renderer_path(cls):
path = None path = None
try: try:
path = subprocess.check_output(['which', cls.name()], timeout=SUBPROCESS_TIMEOUT).decode('utf-8').strip() path = subprocess.check_output(['which', cls.name()], timeout=SUBPROCESS_TIMEOUT).decode('utf-8').strip()
@@ -28,13 +34,11 @@ class BaseRenderEngine(object):
logger.exception(e) logger.exception(e)
return path return path
@classmethod def version(self):
def version(cls):
raise NotImplementedError("version not implemented") raise NotImplementedError("version not implemented")
@classmethod def get_help(self):
def get_help(cls): path = self.renderer_path()
path = cls.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.check_output([path, '-h'], stderr=subprocess.STDOUT,

View File

@@ -14,11 +14,10 @@ class Blender(BaseRenderEngine):
install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender'] install_paths = ['/Applications/Blender.app/Contents/MacOS/Blender']
supported_extensions = ['.blend'] supported_extensions = ['.blend']
@classmethod def version(self):
def version(cls):
version = None version = None
try: try:
render_path = cls.renderer_path() render_path = self.renderer_path()
if render_path: if render_path:
ver_out = subprocess.check_output([render_path, '-v'], timeout=SUBPROCESS_TIMEOUT) ver_out = subprocess.check_output([render_path, '-v'], timeout=SUBPROCESS_TIMEOUT)
version = ver_out.decode('utf-8').splitlines()[0].replace('Blender', '').strip() version = ver_out.decode('utf-8').splitlines()[0].replace('Blender', '').strip()
@@ -26,17 +25,15 @@ class Blender(BaseRenderEngine):
logger.error(f'Failed to get Blender version: {e}') logger.error(f'Failed to get Blender version: {e}')
return version return version
@classmethod def get_output_formats(self):
def get_output_formats(cls): format_string = self.get_help().split('Format Options')[-1].split('Animation Playback Options')[0]
format_string = cls.get_help().split('Format Options')[-1].split('Animation Playback Options')[0]
formats = re.findall(r"'([A-Z_0-9]+)'", format_string) formats = re.findall(r"'([A-Z_0-9]+)'", format_string)
return formats return formats
@classmethod def run_python_expression(self, project_path, python_expression, timeout=None):
def run_python_expression(cls, project_path, python_expression, timeout=None):
if os.path.exists(project_path): if os.path.exists(project_path):
try: try:
return subprocess.run([cls.renderer_path(), '-b', project_path, '--python-expr', python_expression], return subprocess.run([self.renderer_path(), '-b', project_path, '--python-expr', python_expression],
capture_output=True, timeout=timeout) capture_output=True, timeout=timeout)
except Exception as e: except Exception as e:
logger.warning(f"Error running python expression in blender: {e}") logger.warning(f"Error running python expression in blender: {e}")
@@ -44,11 +41,10 @@ class Blender(BaseRenderEngine):
else: else:
raise FileNotFoundError(f'Project file not found: {project_path}') raise FileNotFoundError(f'Project file not found: {project_path}')
@classmethod def run_python_script(self, project_path, script_path, timeout=None):
def run_python_script(cls, project_path, script_path, timeout=None):
if os.path.exists(project_path) and os.path.exists(script_path): if os.path.exists(project_path) and os.path.exists(script_path):
try: try:
return subprocess.run([cls.renderer_path(), '-b', project_path, '--python', script_path], return subprocess.run([self.default_renderer_path(), '-b', project_path, '--python', script_path],
capture_output=True, timeout=timeout) capture_output=True, timeout=timeout)
except Exception as e: except Exception as e:
logger.warning(f"Error running python expression in blender: {e}") logger.warning(f"Error running python expression in blender: {e}")
@@ -59,11 +55,10 @@ class Blender(BaseRenderEngine):
raise FileNotFoundError(f'Python script not found: {script_path}') raise FileNotFoundError(f'Python script not found: {script_path}')
raise Exception("Uncaught exception") raise Exception("Uncaught exception")
@classmethod def get_scene_info(self, project_path, timeout=10):
def get_scene_info(cls, project_path, timeout=10):
scene_info = {} scene_info = {}
try: try:
results = cls.run_python_script(project_path, os.path.join(os.path.dirname(os.path.realpath(__file__)), results = self.run_python_script(project_path, os.path.join(os.path.dirname(os.path.realpath(__file__)),
'scripts', 'blender', 'get_file_info.py'), timeout=timeout) 'scripts', 'blender', 'get_file_info.py'), timeout=timeout)
result_text = results.stdout.decode() result_text = results.stdout.decode()
for line in result_text.splitlines(): for line in result_text.splitlines():
@@ -77,12 +72,11 @@ class Blender(BaseRenderEngine):
logger.error(f'Error getting file details for .blend file: {e}') logger.error(f'Error getting file details for .blend file: {e}')
return scene_info return scene_info
@classmethod def pack_project_file(self, project_path, timeout=30):
def pack_project_file(cls, project_path, timeout=30):
# Credit to L0Lock for pack script - https://blender.stackexchange.com/a/243935 # Credit to L0Lock for pack script - https://blender.stackexchange.com/a/243935
try: try:
logger.info(f"Starting to pack Blender file: {project_path}") logger.info(f"Starting to pack Blender file: {project_path}")
results = cls.run_python_script(project_path, os.path.join(os.path.dirname(os.path.realpath(__file__)), results = self.run_python_script(project_path, os.path.join(os.path.dirname(os.path.realpath(__file__)),
'scripts', 'blender', 'pack_project.py'), timeout=timeout) 'scripts', 'blender', 'pack_project.py'), timeout=timeout)
result_text = results.stdout.decode() result_text = results.stdout.decode()

View File

@@ -0,0 +1,128 @@
import os
import logging
import platform
import shutil
try:
from .blender_engine import Blender
except ImportError:
from blender_engine import Blender
try:
from .ffmpeg_engine import FFMPEG
except ImportError:
from ffmpeg_engine import FFMPEG
logger = logging.getLogger()
class EngineManager:
engines_path = "~/zordon-uploads/engines"
@classmethod
def supported_engines(cls):
return [Blender, FFMPEG]
@classmethod
def all_engines(cls):
results = []
# Parse downloaded engine directory
try:
all_items = os.listdir(cls.engines_path)
all_directories = [item for item in all_items if os.path.isdir(os.path.join(cls.engines_path, item))]
for directory in all_directories:
# Split the input string by dashes to get segments
segments = directory.split('-')
# Define the keys for each word
keys = ["engine", "version", "system_os", "cpu"]
# Create a dictionary with named keys
executable_names = {'linux': 'blender', 'windows': 'blender.exe', 'macos': 'Blender.app'}
result_dict = {keys[i]: segments[i] for i in range(min(len(keys), len(segments)))}
result_dict['path'] = os.path.join(cls.engines_path, directory, executable_names[result_dict['system_os']])
result_dict['type'] = 'managed'
results.append(result_dict)
except FileNotFoundError:
logger.warning("Cannot find local engines download directory")
# add system installs to this list
for eng in cls.supported_engines():
if eng.default_renderer_path():
results.append({'engine': eng.name(), 'version': eng().version(),
'system_os': cls.system_os(),
'cpu': cls.system_cpu(),
'path': eng.default_renderer_path(), 'type': 'system'})
return results
@classmethod
def all_versions_for_engine(cls, engine):
return [x for x in cls.all_engines() if x['engine'] == engine]
@classmethod
def newest_engine_version(cls, engine, system_os=None, cpu=None):
system_os = system_os or cls.system_os()
cpu = cpu or cls.system_cpu()
try:
filtered = [x for x in cls.all_engines() if x['engine'] == engine and x['system_os'] == system_os and x['cpu'] == cpu]
versions = sorted(filtered, key=lambda x: x['version'], reverse=True)
return versions[0]
except IndexError:
logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}")
return None
@classmethod
def has_engine_version(cls, engine, version, system_os=None, cpu=None):
system_os = system_os or cls.system_os()
cpu = cpu or cls.system_cpu()
filtered = [x for x in cls.all_engines() if
x['engine'] == engine and x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version]
return filtered[0] if filtered else False
@staticmethod
def system_os():
return platform.system().lower().replace('darwin', 'macos')
@staticmethod
def system_cpu():
return platform.machine().lower().replace('amd64', 'x64')
@classmethod
def download_engine(cls, engine, version, system_os=None, cpu=None):
existing_download = cls.has_engine_version(engine, version, system_os, cpu)
if existing_download:
logger.info(f"Requested download of {engine} {version}, but local copy already exists")
return existing_download
if engine == "blender":
from .scripts.blender.blender_downloader import BlenderDownloader
logger.info(f"Requesting download of {engine} {version}")
if BlenderDownloader.download_engine(version, download_location=cls.engines_path, system_os=system_os, cpu=cpu):
return cls.has_engine_version(engine, version, system_os, cpu)
else:
logger.error("Error downloading Engine")
return None # Return None to indicate an error
@classmethod
def delete_engine_download(cls, engine, version, system_os=None, cpu=None):
logger.info(f"Requested deletion of engine: {engine}-{version}")
found = cls.has_engine_version(engine, version, system_os, cpu)
if found:
dir_path = os.path.dirname(found['path'])
shutil.rmtree(dir_path, ignore_errors=True)
logger.info(f"Engine {engine}-{version}-{found['system_os']}-{found['cpu']} successfully deleted")
return True
else:
logger.error(f"Cannot find engine: {engine}-{version}")
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# print(EngineManager.newest_engine_version('blender', 'macos', 'arm64'))
EngineManager.delete_engine_download('blender', '3.2.1', 'windows', 'x64')

View File

@@ -7,11 +7,10 @@ import re
class FFMPEG(BaseRenderEngine): class FFMPEG(BaseRenderEngine):
@classmethod def version(self):
def version(cls):
version = None version = None
try: try:
ver_out = subprocess.check_output([cls.renderer_path(), '-version'], ver_out = subprocess.check_output([self.renderer_path(), '-version'],
timeout=SUBPROCESS_TIMEOUT).decode('utf-8') timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
match = re.match(".*version\s*(\S+)\s*Copyright", ver_out) match = re.match(".*version\s*(\S+)\s*Copyright", ver_out)
if match: if match:
@@ -20,29 +19,25 @@ class FFMPEG(BaseRenderEngine):
logger.error("Failed to get FFMPEG version: {}".format(e)) logger.error("Failed to get FFMPEG version: {}".format(e))
return version return version
@classmethod def get_encoders(self):
def get_encoders(cls): raw_stdout = subprocess.check_output([self.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL,
raw_stdout = subprocess.check_output([cls.renderer_path(), '-encoders'], stderr=subprocess.DEVNULL,
timeout=SUBPROCESS_TIMEOUT).decode('utf-8') timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
pattern = '(?P<type>[VASFXBD.]{6})\s+(?P<name>\S{2,})\s+(?P<description>.*)' pattern = '(?P<type>[VASFXBD.]{6})\s+(?P<name>\S{2,})\s+(?P<description>.*)'
encoders = [m.groupdict() for m in re.finditer(pattern, raw_stdout)] encoders = [m.groupdict() for m in re.finditer(pattern, raw_stdout)]
return encoders return encoders
@classmethod def get_all_formats(self):
def get_all_formats(cls): raw_stdout = subprocess.check_output([self.renderer_path(), '-formats'], stderr=subprocess.DEVNULL,
raw_stdout = subprocess.check_output([cls.renderer_path(), '-formats'], stderr=subprocess.DEVNULL,
timeout=SUBPROCESS_TIMEOUT).decode('utf-8') timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
pattern = '(?P<type>[DE]{1,2})\s+(?P<name>\S{2,})\s+(?P<description>.*)' pattern = '(?P<type>[DE]{1,2})\s+(?P<name>\S{2,})\s+(?P<description>.*)'
formats = [m.groupdict() for m in re.finditer(pattern, raw_stdout)] formats = [m.groupdict() for m in re.finditer(pattern, raw_stdout)]
return formats return formats
@classmethod def get_output_formats(self):
def get_output_formats(cls): return [x for x in self.get_all_formats() if 'E' in x['type'].upper()]
return [x for x in cls.get_all_formats() if 'E' in x['type'].upper()]
@classmethod def get_frame_count(self, path_to_file):
def get_frame_count(cls, path_to_file): raw_stdout = subprocess.check_output([self.default_renderer_path(), '-i', path_to_file, '-map', '0:v:0', '-c', 'copy',
raw_stdout = subprocess.check_output([cls.renderer_path(), '-i', path_to_file, '-map', '0:v:0', '-c', 'copy',
'-f', 'null', '-'], stderr=subprocess.STDOUT, '-f', 'null', '-'], stderr=subprocess.STDOUT,
timeout=SUBPROCESS_TIMEOUT).decode('utf-8') timeout=SUBPROCESS_TIMEOUT).decode('utf-8')
match = re.findall(r'frame=\s*(\d+)', raw_stdout) match = re.findall(r'frame=\s*(\d+)', raw_stdout)

View File

View File

View File

@@ -0,0 +1,206 @@
import logging
import os
import re
import platform
import shutil
import tarfile
import tempfile
import zipfile
import requests
from tqdm import tqdm
# url = "https://download.blender.org/release/"
url = "https://ftp.nluug.nl/pub/graphics/blender/release/" # much faster mirror for testing
logger = logging.getLogger()
supported_formats = ['.zip', '.tar.xz', '.dmg']
class BlenderDownloader:
@staticmethod
def get_major_versions():
try:
response = requests.get(url)
response.raise_for_status()
# Use regex to find all the <a> tags and extract the href attribute
link_pattern = r'<a href="([^"]+)">Blender(\d+[^<]+)</a>'
link_matches = re.findall(link_pattern, response.text)
major_versions = [link[-1].strip('/') for link in link_matches]
major_versions.sort(reverse=True)
return major_versions
except requests.exceptions.RequestException as e:
logger.error(f"Error: {e}")
return None
@staticmethod
def get_minor_versions(major_version, system_os=None, cpu=None):
base_url = url + 'Blender' + major_version
response = requests.get(base_url)
response.raise_for_status()
versions_pattern = r'<a href="(?P<file>[^"]+)">blender-(?P<version>[\d\.]+)-(?P<system_os>\w+)-(?P<cpu>\w+).*</a>'
versions_data = [match.groupdict() for match in re.finditer(versions_pattern, response.text)]
# Filter to just the supported formats
versions_data = [item for item in versions_data if any(item["file"].endswith(ext) for ext in supported_formats)]
if system_os:
versions_data = [x for x in versions_data if x['system_os'] == system_os]
if cpu:
versions_data = [x for x in versions_data if x['cpu'] == cpu]
for v in versions_data:
v['url'] = os.path.join(base_url, v['file'])
versions_data = sorted(versions_data, key=lambda x: x['version'], reverse=True)
return versions_data
@staticmethod
def find_LTS_versions():
response = requests.get('https://www.blender.org/download/lts/')
response.raise_for_status()
lts_pattern = r'https://www.blender.org/download/lts/(\d+-\d+)/'
lts_matches = re.findall(lts_pattern, response.text)
lts_versions = [ver.replace('-', '.') for ver in list(set(lts_matches))]
lts_versions.sort(reverse=True)
return lts_versions
@classmethod
def find_most_recent_version(cls, system_os, cpu, lts_only=False):
try:
major_version = cls.find_LTS_versions()[0] if lts_only else cls.get_major_versions()[0]
most_recent = cls.get_minor_versions(major_version, system_os, cpu)[0]
return most_recent
except IndexError:
logger.error("Cannot find a most recent version")
@classmethod
def download_engine(cls, version, download_location, system_os=None, cpu=None):
system_os = system_os or platform.system().lower().replace('darwin', 'macos')
cpu = cpu or platform.machine().lower().replace('amd64', 'x64')
try:
logger.info(f"Requesting download of blender-{version}-{system_os}-{cpu}")
major_version = '.'.join(version.split('.')[:2])
minor_versions = [x for x in cls.get_minor_versions(major_version, system_os, cpu) if x['version'] == version]
# we get the URL instead of calculating it ourselves. May change this
cls.download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location)
except IndexError:
logger.error("Cannot find requested engine")
@classmethod
def download_and_extract_app(cls, remote_url, download_location):
binary_path = None
# Create a temp download directory
temp_download_dir = tempfile.mkdtemp()
downloaded_file_path = os.path.join(temp_download_dir, os.path.basename(remote_url))
try:
output_dir_name = os.path.basename(remote_url)
for fmt in supported_formats:
output_dir_name = output_dir_name.split(fmt)[0]
if os.path.exists(os.path.join(download_location, output_dir_name)):
logger.error(f"Engine download for {output_dir_name} already exists")
return
if not os.path.exists(downloaded_file_path):
# Make a GET request to the URL with stream=True to enable streaming
logger.info(f"Downloading {output_dir_name} from {remote_url}")
response = requests.get(remote_url, stream=True)
# Check if the request was successful
if response.status_code == 200:
# Get the total file size from the "Content-Length" header
file_size = int(response.headers.get("Content-Length", 0))
# Create a progress bar using tqdm
progress_bar = tqdm(total=file_size, unit="B", unit_scale=True)
# Open a file for writing in binary mode
with open(downloaded_file_path, "wb") as file:
for chunk in response.iter_content(chunk_size=1024):
if chunk:
# Write the chunk to the file
file.write(chunk)
# Update the progress bar
progress_bar.update(len(chunk))
# Close the progress bar
progress_bar.close()
logger.info(f"Successfully downloaded {os.path.basename(downloaded_file_path)}")
else:
logger.error(f"Failed to download the file. Status code: {response.status_code}")
os.makedirs(download_location, exist_ok=True)
# Extract the downloaded Blender file
# Linux - Process .tar.xz files
if downloaded_file_path.lower().endswith('.tar.xz'):
try:
with tarfile.open(downloaded_file_path, 'r:xz') as tar:
tar.extractall(path=download_location)
os.path.join(download_location, output_dir_name, 'blender')
logger.info(f'Successfully extracted {os.path.basename(downloaded_file_path)} to {download_location}')
except tarfile.TarError as e:
logger.error(f'Error extracting {downloaded_file_path}: {e}')
except FileNotFoundError:
logger.error(f'File not found: {downloaded_file_path}')
# Windows - Process .zip files
elif downloaded_file_path.lower().endswith('.zip'):
try:
with zipfile.ZipFile(downloaded_file_path, 'r') as zip_ref:
zip_ref.extractall(download_location)
logger.info(f'Successfully extracted {os.path.basename(downloaded_file_path)} to {download_location}')
except zipfile.BadZipFile as e:
logger.error(f'Error: {downloaded_file_path} is not a valid ZIP file.')
except FileNotFoundError:
logger.error(f'File not found: {downloaded_file_path}')
# macOS - Process .dmg files
elif downloaded_file_path.lower().endswith('.dmg'):
import dmglib
dmg = dmglib.DiskImage(downloaded_file_path)
for mount_point in dmg.attach():
try:
# Copy the entire .app bundle to the destination directory
shutil.copytree(os.path.join(mount_point, 'Blender.app'),
os.path.join(download_location, output_dir_name, 'Blender.app'))
binary_path = os.path.join(download_location, output_dir_name, 'Blender.app')
logger.info(f'Successfully copied {os.path.basename(downloaded_file_path)} to {download_location}')
except FileNotFoundError:
logger.error(f'Error: The source .app bundle does not exist.')
except PermissionError:
logger.error(f'Error: Permission denied to copy {download_location}.')
except Exception as e:
logger.error(f'An error occurred: {e}')
dmg.detach()
else:
logger.error("Unknown file. Unable to extract binary.")
except Exception as e:
logger.exception(e)
# remove downloaded file on completion
shutil.rmtree(temp_download_dir)
return binary_path
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
BlenderDownloader.download_engine('3.3.1', download_location="/Users/brett/Desktop/test/releases", system_os='linux', cpu='x64')

View File

@@ -5,7 +5,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from src.utilities.status_utils import RenderStatus from src.utilities.status_utils import RenderStatus
from src.worker_factory import RenderWorkerFactory from src.workers.worker_factory import RenderWorkerFactory
from src.workers.base_worker import Base from src.workers.base_worker import Base
logger = logging.getLogger() logger = logging.getLogger()
@@ -100,7 +100,7 @@ class RenderQueue:
@classmethod @classmethod
def is_available_for_job(cls, renderer, priority=2): def is_available_for_job(cls, renderer, priority=2):
if not RenderWorkerFactory.class_for_name(renderer).engine.renderer_path(): if not RenderWorkerFactory.class_for_name(renderer).engine.default_renderer_path():
return False return False
instances = cls.renderer_instances() instances = cls.renderer_instances()

View File

@@ -1,20 +1,20 @@
import subprocess import subprocess
from src.workers.engines.ffmpeg_engine import FFMPEG from src.engines.ffmpeg_engine import FFMPEG
def image_sequence_to_video(source_glob_pattern, output_path, framerate=24, encoder="prores_ks", profile=4, def image_sequence_to_video(source_glob_pattern, output_path, framerate=24, encoder="prores_ks", profile=4,
start_frame=1): start_frame=1):
subprocess.run([FFMPEG.renderer_path(), "-framerate", str(framerate), "-start_number", str(start_frame), "-i", subprocess.run([FFMPEG.default_renderer_path(), "-framerate", str(framerate), "-start_number", str(start_frame), "-i",
f"{source_glob_pattern}", "-c:v", encoder, "-profile:v", str(profile), '-pix_fmt', 'yuva444p10le', f"{source_glob_pattern}", "-c:v", encoder, "-profile:v", str(profile), '-pix_fmt', 'yuva444p10le',
output_path], check=True) output_path], check=True)
def save_first_frame(source_path, dest_path, max_width=1280): def save_first_frame(source_path, dest_path, max_width=1280):
subprocess.run([FFMPEG.renderer_path(), '-i', source_path, '-vf', f'scale={max_width}:-1', subprocess.run([FFMPEG.default_renderer_path(), '-i', source_path, '-vf', f'scale={max_width}:-1',
'-vframes', '1', dest_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) '-vframes', '1', dest_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
def generate_thumbnail(source_path, dest_path, max_width=240, fps=12): def generate_thumbnail(source_path, dest_path, max_width=240, fps=12):
subprocess.run([FFMPEG.renderer_path(), '-i', source_path, '-vf', subprocess.run([FFMPEG.default_renderer_path(), '-i', source_path, '-vf',
f"scale={max_width}:trunc(ow/a/2)*2,format=yuv420p", '-r', str(fps), '-c:v', 'libx264', '-preset', f"scale={max_width}:trunc(ow/a/2)*2,format=yuv420p", '-r', str(fps), '-c:v', 'libx264', '-preset',
'ultrafast', '-an', dest_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) 'ultrafast', '-an', dest_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)

View File

@@ -7,7 +7,7 @@ import re
import time import time
from src.workers.base_worker import BaseRenderWorker, timecode_to_frames from src.workers.base_worker import BaseRenderWorker, timecode_to_frames
from src.workers.engines.aerender_engine import AERender from src.engines.aerender_engine import AERender
def aerender_path(): def aerender_path():

View File

@@ -64,7 +64,8 @@ class BaseRenderWorker(Base):
self.args = args or {} self.args = args or {}
self.date_created = datetime.now() self.date_created = datetime.now()
self.renderer = self.engine.name() self.renderer = self.engine.name()
self.renderer_version = self.engine.version() self.renderer_version = self.engine().version()
self.custom_renderer_path = None
self.priority = priority self.priority = priority
self.parent = parent self.parent = parent
self.children = {} self.children = {}
@@ -158,7 +159,7 @@ class BaseRenderWorker(Base):
self.errors.append(msg) self.errors.append(msg)
return return
if not self.engine.renderer_path(): if not self.engine.default_renderer_path() and not self.custom_renderer_path:
self.status = RenderStatus.ERROR self.status = RenderStatus.ERROR
msg = 'Cannot find render engine path for {}'.format(self.engine.name()) msg = 'Cannot find render engine path for {}'.format(self.engine.name())
logger.error(msg) logger.error(msg)
@@ -167,7 +168,7 @@ class BaseRenderWorker(Base):
self.status = RenderStatus.RUNNING self.status = RenderStatus.RUNNING
self.start_time = datetime.now() self.start_time = datetime.now()
logger.info(f'Starting {self.engine.name()} {self.engine.version()} Render for {self.input_path} | ' logger.info(f'Starting {self.engine.name()} {self.engine().version()} Render for {self.input_path} | '
f'Frame Count: {self.total_frames}') f'Frame Count: {self.total_frames}')
self.__thread.start() self.__thread.start()
@@ -182,7 +183,7 @@ class BaseRenderWorker(Base):
with open(self.log_path(), "a") as f: with open(self.log_path(), "a") as f:
f.write(f"{self.start_time.isoformat()} - Starting {self.engine.name()} {self.engine.version()} " f.write(f"{self.start_time.isoformat()} - Starting {self.engine.name()} {self.engine().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: {subprocess_cmds}\n")
f.write('=' * 80 + '\n\n') f.write('=' * 80 + '\n\n')

View File

@@ -2,7 +2,7 @@
import re import re
from collections import Counter from collections import Counter
from src.workers.engines.blender_engine import Blender from src.engines.blender_engine import Blender
from src.utilities.ffmpeg_helper import image_sequence_to_video from src.utilities.ffmpeg_helper import image_sequence_to_video
from src.workers.base_worker import * from src.workers.base_worker import *
@@ -24,7 +24,7 @@ class BlenderRenderWorker(BaseRenderWorker):
self.__frame_percent_complete = 0.0 self.__frame_percent_complete = 0.0
# Scene Info # Scene Info
self.scene_info = Blender.get_scene_info(input_path) self.scene_info = Blender().get_scene_info(input_path)
self.start_frame = int(self.scene_info.get('start_frame', 1)) 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.end_frame = int(self.scene_info.get('end_frame', self.start_frame))
self.project_length = (self.end_frame - self.start_frame) + 1 self.project_length = (self.end_frame - self.start_frame) + 1
@@ -32,7 +32,7 @@ class BlenderRenderWorker(BaseRenderWorker):
def generate_worker_subprocess(self): def generate_worker_subprocess(self):
cmd = [self.engine.renderer_path()] cmd = [self.engine.default_renderer_path()]
if self.args.get('background', True): # optionally run render not in background if self.args.get('background', True): # optionally run render not in background
cmd.append('-b') cmd.append('-b')
cmd.append(self.input_path) cmd.append(self.input_path)

View File

@@ -3,7 +3,7 @@ import re
import subprocess import subprocess
from src.workers.base_worker import BaseRenderWorker from src.workers.base_worker import BaseRenderWorker
from src.workers.engines.ffmpeg_engine import FFMPEG from src.engines.ffmpeg_engine import FFMPEG
class FFMPEGRenderWorker(BaseRenderWorker): class FFMPEGRenderWorker(BaseRenderWorker):
@@ -14,7 +14,7 @@ class FFMPEGRenderWorker(BaseRenderWorker):
super(FFMPEGRenderWorker, self).__init__(input_path=input_path, output_path=output_path, args=args, super(FFMPEGRenderWorker, self).__init__(input_path=input_path, output_path=output_path, args=args,
parent=parent, name=name) parent=parent, name=name)
stream_info = subprocess.check_output([self.engine.renderer_path(), "-i", # https://stackoverflow.com/a/61604105 stream_info = subprocess.check_output([self.engine.default_renderer_path(), "-i", # https://stackoverflow.com/a/61604105
input_path, "-map", "0:v:0", "-c", "copy", "-f", "null", "-y", input_path, "-map", "0:v:0", "-c", "copy", "-f", "null", "-y",
"/dev/null"], stderr=subprocess.STDOUT).decode('utf-8') "/dev/null"], stderr=subprocess.STDOUT).decode('utf-8')
found_frames = re.findall('frame=\s*(\d+)', stream_info) found_frames = re.findall('frame=\s*(\d+)', stream_info)
@@ -23,7 +23,7 @@ class FFMPEGRenderWorker(BaseRenderWorker):
def generate_worker_subprocess(self): def generate_worker_subprocess(self):
cmd = [self.engine.renderer_path(), '-y', '-stats', '-i', self.input_path] cmd = [self.engine.default_renderer_path(), '-y', '-stats', '-i', self.input_path]
# Resize frame # Resize frame
if self.args.get('x_resolution', None) and self.args.get('y_resolution', None): if self.args.get('x_resolution', None) and self.args.get('y_resolution', None):