import bpy import os from . import utils # -- Render-callback handlers ------------------------------------------------- @bpy.app.handlers.persistent def on_render_complete(scene, depsgraph=None): SCENERENDERER_OT_render_all._is_rendering = False @bpy.app.handlers.persistent def on_render_cancel(scene, depsgraph=None): SCENERENDERER_OT_render_all._is_rendering = False SCENERENDERER_OT_render_all._cancel_requested = True # -- Cancel operator ---------------------------------------------------------- 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" def execute(self, context): SCENERENDERER_OT_render_all._cancel_requested = True self.report( {'INFO'}, "Batch render cancellation requested. " "Will stop after current frame or on ESC.", ) return {'FINISHED'} # -- File-extension helper ---------------------------------------------------- # Why a map instead of relying on Blender's default: the extension must match # the actual format we configure (e.g. EXR → .exr, not .png). _FORMAT_TO_EXT = { 'PNG': '.png', 'JPEG': '.jpg', 'TIFF': '.tif', 'OPEN_EXR': '.exr', 'OPEN_EXR_MULTILAYER': '.exr', } # -- Main render operator ----------------------------------------------------- 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/" bl_options = {'REGISTER'} _timer = None _scenes = [] _index = 0 _total = 0 _orig_lock = None _is_rendering = False _cancel_requested = False # ---- modal loop --------------------------------------------------------- def modal(self, context, event): if event.type == 'ESC' or self._cancel_requested: self.report({'WARNING'}, "Render batch cancelled") self.finish(context) return {'CANCELLED'} if event.type == 'TIMER': if self._is_rendering: return {'PASS_THROUGH'} if self._index >= self._total: self.report({'INFO'}, f"All {self._total} scenes rendered") self.finish(context) return {'FINISHED'} scene_name = self._scenes[self._index] self._index += 1 try: SCENERENDERER_OT_render_all._is_rendering = True self._render_scene(context, scene_name) except Exception as e: SCENERENDERER_OT_render_all._is_rendering = False self.report({'ERROR'}, f"Failed {scene_name}: {e}") context.area.tag_redraw() return {'PASS_THROUGH'} # ---- cleanup ------------------------------------------------------------ def finish(self, context): if self._timer: context.window_manager.event_timer_remove(self._timer) self._timer = None context.window.cursor_set('DEFAULT') if self._orig_lock is not None: for sc in bpy.data.scenes: sc.render.use_lock_interface = self._orig_lock if on_render_complete in bpy.app.handlers.render_complete: bpy.app.handlers.render_complete.remove(on_render_complete) if on_render_cancel in bpy.app.handlers.render_cancel: bpy.app.handlers.render_cancel.remove(on_render_cancel) # ---- per-scene render --------------------------------------------------- def _render_scene(self, context, scene_name): settings = SCENERENDERER_OT_render_all._cached_settings scene = bpy.data.scenes.get(scene_name) if not scene: print(f" [SKIP] Scene not found: {scene_name}") SCENERENDERER_OT_render_all._is_rendering = False return if scene.name == "_Shots": SCENERENDERER_OT_render_all._is_rendering = False return bpy.context.window.scene = scene cam = scene.objects.get("Profile") if cam: scene.camera = cam elif not scene.camera: print(f" [SKIP] No camera in {scene.name}") SCENERENDERER_OT_render_all._is_rendering = False return self._apply_render_settings(scene, settings) self._setup_output_path(scene, settings) print(f"\n [{self._index}/{self._total}] Rendering: {scene.name}") with context.temp_override( window=context.window, area=context.area, region=context.region ): bpy.ops.render.render( 'INVOKE_DEFAULT', write_still=True, scene=scene.name ) print(f" [STARTED] {scene.name}") # ---- apply all settings to scene ---------------------------------------- @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"] # Test-mode shrinks resolution to 10% if settings["test_mode"]: r.resolution_percentage = 10 # Output format 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' # EXR supports 16 or 32 bit float depth = settings["color_depth"] r.image_settings.color_depth = '16' if depth == '8' else depth # Sampling 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"] if settings["time_limit"] > 0.0: c.time_limit = settings["time_limit"] else: c.time_limit = 0.0 # Denoising c.use_denoising = settings["use_denoising"] if c.use_denoising: c.denoiser = settings["denoiser"] c.denoising_input_passes = settings["denoising_input_passes"] # Light bounces 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"] # Clamping 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 # Film filter c.filter_width = settings["film_filter_width"] # Color management 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: # Fallback for Blender 4.0+ AgX 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: print( f" [WARNING] Look '{target_look}' / " f"'{agx_look}' not found, skipping." ) # ---- build output path -------------------------------------------------- def _setup_output_path(self, scene, settings): render_dir = bpy.path.abspath("//renders") if settings["test_mode"]: render_dir = os.path.join(render_dir, "test") os.makedirs(render_dir, exist_ok=True) safe_name = ( scene.name .replace("/", "_") .replace("\\", "_") .replace(":", "_") .strip() ) output_path = os.path.join(render_dir, safe_name) fmt = settings["output_format"] 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}") SCENERENDERER_OT_render_all._is_rendering = False return scene.render.filepath = output_path # ---- confirmation dialog ------------------------------------------------ def invoke(self, context, event): scenes = utils.get_scenes() return context.window_manager.invoke_props_dialog(self, width=300) def draw(self, context): layout = self.layout scenes = utils.get_scenes() layout.label( text=f"Ready to render {len(scenes)} scenes:", icon='RENDER_STILL', ) box = layout.box() col = box.column() for s in scenes[:8]: col.label(text=f" * {s}", icon='SCENE_DATA') if len(scenes) > 8: col.label(text=f" ... and {len(scenes) - 8} more") layout.separator() layout.label(text="Click OK to start.", icon='INFO') # ---- execute (entry point) ---------------------------------------------- def execute(self, context): settings = context.scene.scene_renderer # Cache ALL settings at batch start so they survive scene-context # switches during the modal loop. SCENERENDERER_OT_render_all._cached_settings = { "test_mode": settings.test_mode, "skip_existing": settings.skip_existing, "use_cpu": settings.use_cpu, # Resolution "override_resolution": settings.override_resolution, "resolution_x": settings.resolution_x, "resolution_y": settings.resolution_y, "resolution_percentage": settings.resolution_percentage, # Sampling "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, # Denoising "use_denoising": settings.use_denoising, "denoiser": settings.denoiser, "denoising_input_passes": settings.denoising_input_passes, # Bounces "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, # Clamping "use_clamping": settings.use_clamping, "clamp_direct": settings.clamp_direct, "clamp_indirect": settings.clamp_indirect, # Film "film_transparent": settings.film_transparent, "film_filter_width": settings.film_filter_width, # Output "output_format": settings.output_format, "color_depth": settings.color_depth, # Color "exposure": settings.exposure, "look": settings.look, } if on_render_complete not in bpy.app.handlers.render_complete: bpy.app.handlers.render_complete.append(on_render_complete) if on_render_cancel not in bpy.app.handlers.render_cancel: bpy.app.handlers.render_cancel.append(on_render_cancel) SCENERENDERER_OT_render_all._is_rendering = False SCENERENDERER_OT_render_all._cancel_requested = False silenced = utils.silence_puresky() if silenced: print(f" [FIX] Silenced {silenced} PureSky handlers.") fxd = utils.fix_lib_overrides() print(f"Fixed {fxd} lib-override parameters") img_fxd = utils.fix_missing_images() if img_fxd: print(f"Fixed {img_fxd} missing images with magenta dummies") if not settings.use_cpu: metal_ok = utils.init_metal_gpu() if metal_ok: print("Initialized Metal GPU") else: print("Metal GPU init failed, using whatever is available") self._orig_lock = bpy.data.scenes[0].render.use_lock_interface for sc in bpy.data.scenes: sc.render.use_lock_interface = True sc.cycles.device = 'CPU' if settings.use_cpu else 'GPU' bpy.context.view_layer.update() depsgraph = bpy.context.evaluated_depsgraph_get() depsgraph.update() self._scenes = utils.get_scenes() if settings.test_mode: self._scenes = self._scenes[:1] self._total = len(self._scenes) self._index = 0 if self._total == 0: self.report({'WARNING'}, "No scenes to render!") return {'CANCELLED'} print(f"\n{'='*50}") print( f" Scene Renderer -- {self._total} scenes" f"{' (TEST MODE)' if settings.test_mode else ''}" ) print(f"{'='*50}") context.window_manager.modal_handler_add(self) self._timer = context.window_manager.event_timer_add( 0.5, window=context.window ) context.window.cursor_set('WAIT') return {'RUNNING_MODAL'}