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'}