460 lines
16 KiB
Python
460 lines
16 KiB
Python
import bpy
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import sys
|
|
from . import utils
|
|
|
|
# Global state to track background rendering across the UI
|
|
g_render_process = None
|
|
g_render_log = None
|
|
g_is_rendering = False
|
|
g_cancel_requested = False
|
|
|
|
# Global state for the queue
|
|
g_scenes_to_render = []
|
|
g_render_index = 0
|
|
g_temp_blend = ""
|
|
g_temp_script = ""
|
|
g_render_dir = ""
|
|
g_progress_text = ""
|
|
g_log_seek_pos = 0
|
|
|
|
|
|
def cleanup_process():
|
|
global g_render_process, g_render_log
|
|
if g_render_process is not None:
|
|
if g_render_process.poll() is None:
|
|
g_render_process.terminate()
|
|
g_render_process.wait()
|
|
g_render_process = None
|
|
|
|
if g_render_log is not None:
|
|
try:
|
|
g_render_log.close()
|
|
except Exception:
|
|
pass
|
|
g_render_log = None
|
|
|
|
def cleanup_all():
|
|
global g_is_rendering, g_cancel_requested
|
|
global g_temp_blend, g_temp_script, g_progress_text, g_log_seek_pos
|
|
|
|
cleanup_process()
|
|
|
|
if g_temp_blend and os.path.exists(g_temp_blend):
|
|
try:
|
|
os.remove(g_temp_blend)
|
|
except Exception:
|
|
pass
|
|
|
|
if g_temp_script and os.path.exists(g_temp_script):
|
|
try:
|
|
os.remove(g_temp_script)
|
|
except Exception:
|
|
pass
|
|
|
|
for sc in bpy.data.scenes:
|
|
sc.render.use_lock_interface = False
|
|
|
|
g_is_rendering = False
|
|
g_cancel_requested = False
|
|
g_progress_text = ""
|
|
g_log_seek_pos = 0
|
|
|
|
# Force UI update
|
|
for window in bpy.context.window_manager.windows:
|
|
for area in window.screen.areas:
|
|
area.tag_redraw()
|
|
|
|
def render_queue_timer():
|
|
global g_render_process, g_is_rendering, g_cancel_requested
|
|
global g_scenes_to_render, g_render_index, g_render_dir, g_temp_blend, g_temp_script
|
|
global g_progress_text, g_log_seek_pos
|
|
|
|
if g_cancel_requested:
|
|
print(" [CANCELLED] User aborted background render.")
|
|
cleanup_all()
|
|
return None
|
|
|
|
# Tail the log for progress updates
|
|
if g_render_dir:
|
|
log_path = os.path.join(g_render_dir, "render_log.txt")
|
|
if os.path.exists(log_path):
|
|
try:
|
|
with open(log_path, "r") as f:
|
|
f.seek(g_log_seek_pos)
|
|
new_data = f.read()
|
|
g_log_seek_pos = f.tell()
|
|
if new_data:
|
|
lines = new_data.strip().split('\\n')
|
|
for line in reversed(lines):
|
|
if " | Time:" in line or " | Sample" in line or " | Rendered" in line:
|
|
parts = line.split(" | ")
|
|
clean_parts = [p for p in parts if "Time:" in p or "Remaining:" in p or "Sample" in p or "Rendered" in p]
|
|
if clean_parts:
|
|
g_progress_text = " | ".join(clean_parts)
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
if g_render_process is not None:
|
|
retcode = g_render_process.poll()
|
|
if retcode is None:
|
|
# Still running
|
|
return 0.5
|
|
|
|
# Process finished
|
|
if retcode != 0:
|
|
print(f" [ERROR] Render failed with code {retcode} for {g_scenes_to_render[g_render_index-1]}")
|
|
else:
|
|
print(f" [DONE] {g_scenes_to_render[g_render_index-1]}")
|
|
|
|
cleanup_process()
|
|
|
|
# Start next
|
|
if g_render_index >= len(g_scenes_to_render):
|
|
print(f" [FINISHED] All {len(g_scenes_to_render)} scenes rendered successfully.")
|
|
cleanup_all()
|
|
return None
|
|
|
|
scene_name = g_scenes_to_render[g_render_index]
|
|
g_render_index += 1
|
|
|
|
try:
|
|
settings = SCENERENDERER_OT_render_all._cached_settings
|
|
safe_name = scene_name.replace("/", "_").replace("\\\\", "_").replace(":", "_").strip()
|
|
output_path = os.path.join(g_render_dir, safe_name)
|
|
|
|
fmt = settings["output_format"]
|
|
from .operators import _FORMAT_TO_EXT
|
|
ext = _FORMAT_TO_EXT.get(fmt, '.png')
|
|
|
|
if settings["skip_existing"] and os.path.exists(output_path + ext):
|
|
print(f" [SKIP] Already exists: {safe_name}{ext}")
|
|
return 0.1 # Skip immediately
|
|
|
|
print(f"\\n [{g_render_index}/{len(g_scenes_to_render)}] Rendering Subprocess: {scene_name}")
|
|
|
|
blender_bin = bpy.app.binary_path
|
|
cmd = [
|
|
"nice", "-n", "19",
|
|
blender_bin,
|
|
"-b", g_temp_blend,
|
|
"-P", g_temp_script,
|
|
"--", scene_name, output_path
|
|
]
|
|
|
|
log_path = os.path.join(g_render_dir, "render_log.txt")
|
|
global g_render_log
|
|
g_render_log = open(log_path, "a")
|
|
g_render_log.write(f"\\n\\n--- STARTING RENDER: {scene_name} ---\\n")
|
|
g_render_log.write(f"Command: {' '.join(cmd)}\\n")
|
|
g_render_log.flush()
|
|
|
|
g_render_process = subprocess.Popen(cmd, stdout=g_render_log, stderr=subprocess.STDOUT)
|
|
except Exception as e:
|
|
print(f" [ERROR] Failed to start {scene_name}: {e}")
|
|
|
|
# Force UI update
|
|
for window in bpy.context.window_manager.windows:
|
|
for area in window.screen.areas:
|
|
area.tag_redraw()
|
|
|
|
return 0.5
|
|
|
|
|
|
class SCENERENDERER_OT_cancel_batch(bpy.types.Operator):
|
|
bl_idname = "scenerenderer.cancel_batch"
|
|
bl_label = "Cancel Batch Render"
|
|
bl_description = "Abort the current batch rendering process"
|
|
|
|
@classmethod
|
|
def poll(cls, context):
|
|
return g_is_rendering
|
|
|
|
def execute(self, context):
|
|
global g_cancel_requested
|
|
g_cancel_requested = True
|
|
self.report({'INFO'}, "Batch render cancellation requested.")
|
|
return {'FINISHED'}
|
|
|
|
|
|
class SCENERENDERER_OT_launch_swarm(bpy.types.Operator):
|
|
bl_idname = "scenerenderer.launch_swarm"
|
|
bl_label = "Launch Render Swarm"
|
|
bl_description = "Start the Render Swarm Companion App as Host"
|
|
|
|
def execute(self, context):
|
|
# Ensure file is saved
|
|
if not bpy.data.is_saved:
|
|
self.report({'ERROR'}, "Please save your .blend file first!")
|
|
return {'CANCELLED'}
|
|
|
|
bpy.ops.wm.save_mainfile()
|
|
|
|
filepath = bpy.data.filepath
|
|
render_dir = bpy.path.abspath("//renders")
|
|
os.makedirs(render_dir, exist_ok=True)
|
|
|
|
# Determine path to swarm app
|
|
swarm_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "scene-renderer-swarm"))
|
|
if not os.path.exists(swarm_dir):
|
|
self.report({'ERROR'}, f"Swarm App not found at {swarm_dir}")
|
|
return {'CANCELLED'}
|
|
|
|
import platform
|
|
if platform.system() == "Windows":
|
|
script = os.path.join(swarm_dir, "run.bat")
|
|
else:
|
|
script = os.path.join(swarm_dir, "run.sh")
|
|
|
|
cmd = [
|
|
script,
|
|
"--blend", filepath,
|
|
"--out", render_dir,
|
|
"--start", str(context.scene.frame_start),
|
|
"--end", str(context.scene.frame_end)
|
|
]
|
|
|
|
try:
|
|
# We use Popen so we don't block Blender
|
|
subprocess.Popen(cmd, cwd=swarm_dir)
|
|
self.report({'INFO'}, "Render Swarm Host launched!")
|
|
except Exception as e:
|
|
self.report({'ERROR'}, f"Failed to launch Swarm: {e}")
|
|
|
|
return {'FINISHED'}
|
|
|
|
|
|
_FORMAT_TO_EXT = {
|
|
'PNG': '.png',
|
|
'JPEG': '.jpg',
|
|
'TIFF': '.tif',
|
|
'OPEN_EXR': '.exr',
|
|
'OPEN_EXR_MULTILAYER': '.exr',
|
|
}
|
|
|
|
|
|
class SCENERENDERER_OT_render_all(bpy.types.Operator):
|
|
bl_idname = "scenerenderer.render_all"
|
|
bl_label = "Render All Scenes"
|
|
bl_description = "Render all scenes to //renders/ via Background Subprocess"
|
|
bl_options = {'REGISTER'}
|
|
|
|
render_only_current: bpy.props.BoolProperty(
|
|
name="Only Current Scene",
|
|
default=False,
|
|
options={'HIDDEN'},
|
|
)
|
|
|
|
# Cache for settings
|
|
_cached_settings = {}
|
|
|
|
@staticmethod
|
|
def _apply_render_settings(scene, settings):
|
|
r = scene.render
|
|
c = scene.cycles
|
|
|
|
# Engine & basics
|
|
r.engine = 'CYCLES'
|
|
r.film_transparent = settings["film_transparent"]
|
|
|
|
# Resolution override
|
|
if settings["override_resolution"]:
|
|
r.resolution_x = settings["resolution_x"]
|
|
r.resolution_y = settings["resolution_y"]
|
|
r.resolution_percentage = settings["resolution_percentage"]
|
|
|
|
if settings["test_mode"]:
|
|
r.resolution_percentage = 10
|
|
|
|
fmt = settings["output_format"]
|
|
r.image_settings.file_format = fmt
|
|
r.image_settings.color_management = 'FOLLOW_SCENE'
|
|
|
|
if fmt in ('PNG', 'TIFF', 'OPEN_EXR'):
|
|
r.image_settings.color_depth = settings["color_depth"]
|
|
if fmt in ('PNG',):
|
|
r.image_settings.color_mode = 'RGBA'
|
|
elif fmt in ('JPEG',):
|
|
r.image_settings.color_mode = 'RGB'
|
|
elif fmt in ('TIFF',):
|
|
r.image_settings.color_mode = 'RGBA'
|
|
elif fmt in ('OPEN_EXR', 'OPEN_EXR_MULTILAYER'):
|
|
r.image_settings.color_mode = 'RGBA'
|
|
depth = settings["color_depth"]
|
|
r.image_settings.color_depth = '16' if depth == '8' else depth
|
|
|
|
c.samples = 1 if settings["test_mode"] else settings["samples"]
|
|
if settings["min_samples"] > 0:
|
|
c.adaptive_min_samples = settings["min_samples"]
|
|
c.use_adaptive_sampling = settings["use_noise_threshold"]
|
|
if c.use_adaptive_sampling:
|
|
c.adaptive_threshold = settings["noise_threshold"]
|
|
c.time_limit = settings["time_limit"] if settings["time_limit"] > 0.0 else 0.0
|
|
|
|
c.use_denoising = settings["use_denoising"]
|
|
if c.use_denoising:
|
|
c.denoiser = settings["denoiser"]
|
|
c.denoising_input_passes = settings["denoising_input_passes"]
|
|
|
|
if hasattr(c, 'max_bounces'): c.max_bounces = settings["max_bounces"]
|
|
if hasattr(c, 'diffuse_bounces'): c.diffuse_bounces = settings["diffuse_bounces"]
|
|
if hasattr(c, 'glossy_bounces'): c.glossy_bounces = settings["glossy_bounces"]
|
|
if hasattr(c, 'transmission_bounces'): c.transmission_bounces = settings["transmission_bounces"]
|
|
if hasattr(c, 'transparent_max_bounces'): c.transparent_max_bounces = settings["transparent_max_bounces"]
|
|
if hasattr(c, 'volume_bounces'): c.volume_bounces = settings["volume_bounces"]
|
|
|
|
if settings["use_clamping"]:
|
|
c.sample_clamp_direct = settings["clamp_direct"]
|
|
c.sample_clamp_indirect = settings["clamp_indirect"]
|
|
else:
|
|
c.sample_clamp_direct = 0.0
|
|
c.sample_clamp_indirect = 0.0
|
|
|
|
c.filter_width = settings["film_filter_width"]
|
|
|
|
if settings["exposure"] != 0.0:
|
|
scene.view_settings.exposure = settings["exposure"]
|
|
|
|
if settings["look"] != 'None':
|
|
target_look = settings["look"]
|
|
try:
|
|
scene.view_settings.look = target_look
|
|
except TypeError:
|
|
agx_look = f"AgX - {target_look}"
|
|
if target_look == "Medium Contrast":
|
|
agx_look = "AgX - Base Contrast"
|
|
try:
|
|
scene.view_settings.look = agx_look
|
|
except TypeError:
|
|
pass
|
|
|
|
def invoke(self, context, event):
|
|
global g_is_rendering
|
|
if g_is_rendering:
|
|
self.report({'WARNING'}, "Already rendering in background!")
|
|
return {'CANCELLED'}
|
|
# Bypass the dialog entirely so we don't block the UI thread modal context!
|
|
return self.execute(context)
|
|
|
|
def execute(self, context):
|
|
global g_is_rendering, g_cancel_requested
|
|
global g_scenes_to_render, g_render_index, g_render_dir, g_temp_blend, g_temp_script
|
|
|
|
settings = context.scene.scene_renderer
|
|
|
|
g_is_rendering = True
|
|
g_cancel_requested = False
|
|
|
|
SCENERENDERER_OT_render_all._cached_settings = {
|
|
"test_mode": settings.test_mode,
|
|
"skip_existing": settings.skip_existing,
|
|
"use_cpu": settings.use_cpu,
|
|
"override_resolution": settings.override_resolution,
|
|
"resolution_x": settings.resolution_x,
|
|
"resolution_y": settings.resolution_y,
|
|
"resolution_percentage": settings.resolution_percentage,
|
|
"samples": settings.samples,
|
|
"min_samples": settings.min_samples,
|
|
"use_noise_threshold": settings.use_noise_threshold,
|
|
"noise_threshold": settings.noise_threshold,
|
|
"time_limit": settings.time_limit,
|
|
"use_denoising": settings.use_denoising,
|
|
"denoiser": settings.denoiser,
|
|
"denoising_input_passes": settings.denoising_input_passes,
|
|
"max_bounces": settings.max_bounces,
|
|
"diffuse_bounces": settings.diffuse_bounces,
|
|
"glossy_bounces": settings.glossy_bounces,
|
|
"transmission_bounces": settings.transmission_bounces,
|
|
"transparent_max_bounces": settings.transparent_max_bounces,
|
|
"volume_bounces": settings.volume_bounces,
|
|
"use_clamping": settings.use_clamping,
|
|
"clamp_direct": settings.clamp_direct,
|
|
"clamp_indirect": settings.clamp_indirect,
|
|
"film_transparent": settings.film_transparent,
|
|
"film_filter_width": settings.film_filter_width,
|
|
"output_format": settings.output_format,
|
|
"color_depth": settings.color_depth,
|
|
"exposure": settings.exposure,
|
|
"look": settings.look,
|
|
}
|
|
|
|
silenced = utils.silence_puresky()
|
|
utils.fix_lib_overrides()
|
|
utils.fix_missing_images()
|
|
|
|
if not settings.use_cpu:
|
|
utils.init_metal_gpu()
|
|
|
|
for sc in bpy.data.scenes:
|
|
sc.render.use_lock_interface = True
|
|
sc.cycles.device = 'CPU' if settings.use_cpu else 'GPU'
|
|
self._apply_render_settings(sc, SCENERENDERER_OT_render_all._cached_settings)
|
|
|
|
bpy.context.view_layer.update()
|
|
bpy.context.evaluated_depsgraph_get().update()
|
|
|
|
g_render_dir = bpy.path.abspath("//renders")
|
|
if settings.test_mode:
|
|
g_render_dir = os.path.join(g_render_dir, "test")
|
|
os.makedirs(g_render_dir, exist_ok=True)
|
|
|
|
# We must save the current state so the background process has it.
|
|
# This causes a tiny hiccup (saving) but it's required.
|
|
temp_blend_fd, g_temp_blend = tempfile.mkstemp(suffix=".blend", prefix="sr_temp_")
|
|
os.close(temp_blend_fd)
|
|
bpy.ops.wm.save_as_mainfile(filepath=g_temp_blend, copy=True)
|
|
|
|
temp_script_fd, g_temp_script = tempfile.mkstemp(suffix=".py", prefix="sr_script_")
|
|
with os.fdopen(temp_script_fd, 'w') as f:
|
|
f.write("""import bpy
|
|
import sys
|
|
|
|
try:
|
|
args = sys.argv[sys.argv.index("--") + 1:]
|
|
scene_name = args[0]
|
|
output_path = args[1]
|
|
except ValueError:
|
|
print("Error: Scene name and output path required")
|
|
sys.exit(1)
|
|
|
|
scene = bpy.data.scenes.get(scene_name)
|
|
if not scene:
|
|
sys.exit(1)
|
|
|
|
cam = scene.objects.get("Profile")
|
|
if cam:
|
|
scene.camera = cam
|
|
|
|
scene.render.filepath = output_path
|
|
bpy.ops.render.render(write_still=True, scene=scene_name)
|
|
""")
|
|
|
|
if self.render_only_current:
|
|
g_scenes_to_render = [context.scene.name]
|
|
else:
|
|
g_scenes_to_render = utils.get_scenes()
|
|
if settings.test_mode:
|
|
g_scenes_to_render = g_scenes_to_render[:1]
|
|
|
|
g_render_index = 0
|
|
|
|
if len(g_scenes_to_render) == 0:
|
|
self.report({'WARNING'}, "No scenes to render!")
|
|
cleanup_all()
|
|
return {'CANCELLED'}
|
|
|
|
log_path = os.path.join(g_render_dir, "render_log.txt")
|
|
with open(log_path, "w") as f:
|
|
f.write("=== SCENE RENDERER LOG ===\\n")
|
|
|
|
print(f"\\n{'='*50}")
|
|
print(f" Scene Renderer (Timers) -- {len(g_scenes_to_render)} scenes")
|
|
print(f"{'='*50}")
|
|
|
|
# Use global timers instead of modal to prevent dialog/UI thread lockups!
|
|
bpy.app.timers.register(render_queue_timer)
|
|
|
|
return {'FINISHED'} |