From 64c2eb275e9f1094efae1e618ac4f410412d1c00 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 21 Jun 2026 23:52:58 +0200 Subject: [PATCH] refactor: move addon files to scene_renderer subfolder --- operators.py | 210 ----------- panel.py | 30 -- properties.py | 43 --- __init__.py => scene_renderer/__init__.py | 3 +- scene_renderer/operators.py | 413 ++++++++++++++++++++++ scene_renderer/panel.py | 123 +++++++ scene_renderer/properties.py | 225 ++++++++++++ scene_renderer/utils.py | 90 +++++ utils.py | 115 ------ 9 files changed, 853 insertions(+), 399 deletions(-) delete mode 100644 operators.py delete mode 100644 panel.py delete mode 100644 properties.py rename __init__.py => scene_renderer/__init__.py (92%) create mode 100644 scene_renderer/operators.py create mode 100644 scene_renderer/panel.py create mode 100644 scene_renderer/properties.py create mode 100644 scene_renderer/utils.py delete mode 100644 utils.py diff --git a/operators.py b/operators.py deleted file mode 100644 index 2ce6f82..0000000 --- a/operators.py +++ /dev/null @@ -1,210 +0,0 @@ -import bpy -import os -from . import utils - - -@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 - - -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 - - 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'} - - 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) - - def _render_scene(self, context, scene_name): - settings = context.scene.scene_renderer - 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 - - r = scene.render - r.engine = 'CYCLES' - r.image_settings.file_format = 'PNG' - r.image_settings.color_mode = 'RGBA' - r.film_transparent = True - - c = scene.cycles - c.samples = 1 if settings.test_mode else settings.samples - - if settings.exposure != 0.0: - scene.view_settings.exposure = settings.exposure - scene.cycles.film_exposure = 2.0 ** settings.exposure - if settings.look != 'None': - scene.view_settings.look = settings.look - - if settings.test_mode: - r.resolution_percentage = 10 - - 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) - - if settings.skip_existing and os.path.exists(output_path + ".png"): - print(f" [SKIP] Already exists: {safe_name}.png") - SCENERENDERER_OT_render_all._is_rendering = False - return - - r.filepath = output_path - - 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}") - - 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(flow=True) - 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') - - def execute(self, context): - settings = context.scene.scene_renderer - - 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'} \ No newline at end of file diff --git a/panel.py b/panel.py deleted file mode 100644 index 2583aa1..0000000 --- a/panel.py +++ /dev/null @@ -1,30 +0,0 @@ -import bpy - - -class SCENERENDERER_PT_panel(bpy.types.Panel): - bl_label = "Scene Renderer" - bl_idname = "SCENERENDERER_PT_panel" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "Scene Renderer" - - def draw(self, context): - layout = self.layout - settings = context.scene.scene_renderer - - row = layout.row(align=True) - row.prop(settings, "test_mode", toggle=True) - row.prop(settings, "skip_existing", toggle=True) - - layout.prop(settings, "use_cpu") - layout.prop(settings, "samples") - - box = layout.box() - box.label(text="Color", icon='SHADING_RENDERED') - box.prop(settings, "exposure", slider=True) - box.prop(settings, "look") - - layout.separator() - row = layout.row(align=True) - row.scale_y = 2.0 - row.operator("scenerenderer.render_all", text="Render All Scenes", icon='RENDER_STILL') \ No newline at end of file diff --git a/properties.py b/properties.py deleted file mode 100644 index 465d799..0000000 --- a/properties.py +++ /dev/null @@ -1,43 +0,0 @@ -import bpy - - -class SceneRendererSettings(bpy.types.PropertyGroup): - test_mode: bpy.props.BoolProperty( - name="Test Mode", - description="Render only the first scene at 10% resolution with 1 sample", - default=False, - ) - skip_existing: bpy.props.BoolProperty( - name="Skip Existing", - description="Skip scenes whose render file already exists", - default=True, - ) - use_cpu: bpy.props.BoolProperty( - name="CPU Only", - description="Force CPU rendering instead of GPU", - default=False, - ) - samples: bpy.props.IntProperty( - name="Samples", - description="Cycles render samples", - default=4096, min=1, max=65536, - ) - exposure: bpy.props.FloatProperty( - name="Exposure", - description="Exposure boost in stops", - default=0.0, soft_min=-3.0, soft_max=3.0, - ) - look: bpy.props.EnumProperty( - name="Color Look", - items=[ - ('None', "None", ""), - ('Very Low Contrast', "Very Low Contrast", ""), - ('Low Contrast', "Low Contrast", ""), - ('Medium Low Contrast', "Medium Low Contrast", ""), - ('Medium Contrast', "Medium Contrast", ""), - ('Medium High Contrast', "Medium High Contrast", ""), - ('High Contrast', "High Contrast", ""), - ('Very High Contrast', "Very High Contrast", ""), - ], - default='None', - ) \ No newline at end of file diff --git a/__init__.py b/scene_renderer/__init__.py similarity index 92% rename from __init__.py rename to scene_renderer/__init__.py index 3e434ea..c8d545e 100644 --- a/__init__.py +++ b/scene_renderer/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "Scene Renderer", "author": "Marc Mintel", - "version": (1, 0, 0), + "version": (2, 0, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Scene Renderer", "description": "Render all scenes to //renders/ relative to the blend file", @@ -15,6 +15,7 @@ from . import properties, operators, panel classes = ( properties.SceneRendererSettings, operators.SCENERENDERER_OT_render_all, + operators.SCENERENDERER_OT_cancel_batch, panel.SCENERENDERER_PT_panel, ) diff --git a/scene_renderer/operators.py b/scene_renderer/operators.py new file mode 100644 index 0000000..72539cf --- /dev/null +++ b/scene_renderer/operators.py @@ -0,0 +1,413 @@ +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'} + + +_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'} \ No newline at end of file diff --git a/scene_renderer/panel.py b/scene_renderer/panel.py new file mode 100644 index 0000000..3cc8c93 --- /dev/null +++ b/scene_renderer/panel.py @@ -0,0 +1,123 @@ +import bpy + + +class SCENERENDERER_PT_panel(bpy.types.Panel): + bl_label = "Scene Renderer" + bl_idname = "SCENERENDERER_PT_panel" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Scene Renderer" + + def draw(self, context): + layout = self.layout + settings = context.scene.scene_renderer + + # --- General toggles --- + row = layout.row(align=True) + row.prop(settings, "test_mode", toggle=True) + row.prop(settings, "skip_existing", toggle=True) + + layout.prop(settings, "use_cpu") + + # --- Resolution --- + box = layout.box() + row = box.row() + row.prop(settings, "override_resolution") + if settings.override_resolution: + col = box.column(align=True) + col.prop(settings, "resolution_x") + col.prop(settings, "resolution_y") + col.prop(settings, "resolution_percentage", slider=True) + + # --- Sampling --- + box = layout.box() + box.label(text="Sampling", icon='OUTLINER_OB_LIGHT') + box.prop(settings, "samples") + box.prop(settings, "min_samples") + + row = box.row() + row.prop(settings, "use_noise_threshold", text="Noise Threshold") + sub = row.row() + sub.active = settings.use_noise_threshold + sub.prop(settings, "noise_threshold", text="") + + box.prop(settings, "time_limit") + + # --- Denoising --- + box = layout.box() + box.label(text="Denoising", icon='MOD_SMOOTH') + box.prop(settings, "use_denoising") + if settings.use_denoising: + box.prop(settings, "denoiser") + box.prop(settings, "denoising_input_passes") + + # --- Light Bounces --- + box = layout.box() + box.label(text="Light Bounces", icon='LIGHT_SUN') + box.prop(settings, "max_bounces") + col = box.column(align=True) + col.prop(settings, "diffuse_bounces") + col.prop(settings, "glossy_bounces") + col.prop(settings, "transmission_bounces") + col.prop(settings, "transparent_max_bounces") + col.prop(settings, "volume_bounces") + + # --- Clamping --- + box = layout.box() + row = box.row() + row.prop(settings, "use_clamping") + if settings.use_clamping: + col = box.column(align=True) + col.prop(settings, "clamp_direct") + col.prop(settings, "clamp_indirect") + + # --- Film --- + box = layout.box() + box.label(text="Film", icon='CAMERA_DATA') + box.prop(settings, "film_transparent") + box.prop(settings, "film_filter_width") + + # --- Output --- + box = layout.box() + box.label(text="Output", icon='OUTPUT') + box.prop(settings, "output_format") + # Color depth only for formats that support it + if settings.output_format in ('PNG', 'TIFF', 'OPEN_EXR'): + box.prop(settings, "color_depth") + + # --- Color Management --- + box = layout.box() + box.label(text="Color", icon='SHADING_RENDERED') + box.prop(settings, "exposure", slider=True) + box.prop(settings, "look") + + # --- Render button --- + layout.separator() + + from . import operators + if operators.g_is_rendering: + scene_name = "" + if len(operators.g_scenes_to_render) > 0 and operators.g_render_index > 0: + scene_name = operators.g_scenes_to_render[operators.g_render_index - 1] + + layout.label(text=f"Rendering: {scene_name} ({operators.g_render_index}/{len(operators.g_scenes_to_render)})", icon='SCENE_DATA') + + if operators.g_progress_text: + layout.label(text=operators.g_progress_text, icon='TIME') + else: + layout.label(text="Initializing...", icon='TIME') + + row = layout.row(align=True) + row.scale_y = 2.0 + row.operator( + "scenerenderer.cancel_batch", + text="Cancel Render", + icon='CANCEL', + ) + else: + row = layout.row(align=True) + row.scale_y = 2.0 + op1 = row.operator("scenerenderer.render_all", text="Current Scene", icon='RENDER_STILL') + op1.render_only_current = True + op2 = row.operator("scenerenderer.render_all", text="All Scenes", icon='RENDER_ANIMATION') + op2.render_only_current = False \ No newline at end of file diff --git a/scene_renderer/properties.py b/scene_renderer/properties.py new file mode 100644 index 0000000..d4430c2 --- /dev/null +++ b/scene_renderer/properties.py @@ -0,0 +1,225 @@ +import bpy + + +class SceneRendererSettings(bpy.types.PropertyGroup): + + # --- General --- + + test_mode: bpy.props.BoolProperty( + name="Test Mode", + description="Render only the first scene at 10% resolution with 1 sample", + default=False, + ) + skip_existing: bpy.props.BoolProperty( + name="Skip Existing", + description="Skip scenes whose render file already exists", + default=True, + ) + use_cpu: bpy.props.BoolProperty( + name="CPU Only", + description="Force CPU rendering instead of GPU", + default=False, + ) + + # --- Resolution --- + + override_resolution: bpy.props.BoolProperty( + name="Override Resolution", + description="Override the per-scene resolution with a global value", + default=False, + ) + resolution_x: bpy.props.IntProperty( + name="Resolution X", + description="Horizontal render resolution", + default=1920, min=1, max=32768, + ) + resolution_y: bpy.props.IntProperty( + name="Resolution Y", + description="Vertical render resolution", + default=1080, min=1, max=32768, + ) + resolution_percentage: bpy.props.IntProperty( + name="Resolution %", + description="Resolution scale percentage", + default=100, min=1, max=100, + subtype='PERCENTAGE', + ) + + # --- Sampling --- + + samples: bpy.props.IntProperty( + name="Max Samples", + description="Maximum number of Cycles render samples", + default=4096, min=1, max=65536, + ) + min_samples: bpy.props.IntProperty( + name="Min Samples", + description=( + "Minimum number of samples before adaptive sampling can stop. " + "Set to 0 to use Blender default" + ), + default=0, min=0, max=65536, + ) + use_noise_threshold: bpy.props.BoolProperty( + name="Use Noise Threshold", + description="Stop sampling when the noise is below this threshold", + default=True, + ) + noise_threshold: bpy.props.FloatProperty( + name="Noise Threshold", + description="Noise threshold for adaptive sampling", + default=0.01, min=0.0001, max=1.0, precision=4, + ) + time_limit: bpy.props.FloatProperty( + name="Time Limit", + description=( + "Maximum render time per frame in seconds. " + "0 = no limit" + ), + default=0.0, min=0.0, max=86400.0, + ) + + # --- Denoising --- + + use_denoising: bpy.props.BoolProperty( + name="Use Denoising", + description="Enable denoising for final render", + default=True, + ) + denoiser: bpy.props.EnumProperty( + name="Denoiser", + description="Which denoiser to use", + items=[ + ('OPENIMAGEDENOISE', "OpenImageDenoise", "Intel Open Image Denoise (CPU)"), + ('OPTIX', "OptiX", "NVIDIA OptiX (GPU only)"), + ], + default='OPENIMAGEDENOISE', + ) + denoising_input_passes: bpy.props.EnumProperty( + name="Denoise Passes", + description="Passes used by the denoiser for better quality", + items=[ + ('RGB', "Color", "Use only color data"), + ('RGB_ALBEDO', "Color + Albedo", "Use color and albedo data"), + ('RGB_ALBEDO_NORMAL', "Color + Albedo + Normal", + "Use color, albedo, and normal data (best quality)"), + ], + default='RGB_ALBEDO_NORMAL', + ) + + # --- Light Bounces --- + + max_bounces: bpy.props.IntProperty( + name="Total", + description="Total maximum number of light bounces", + default=12, min=0, max=1024, + ) + diffuse_bounces: bpy.props.IntProperty( + name="Diffuse", + description="Maximum number of diffuse bounces", + default=4, min=0, max=1024, + ) + glossy_bounces: bpy.props.IntProperty( + name="Glossy", + description="Maximum number of glossy bounces", + default=4, min=0, max=1024, + ) + transmission_bounces: bpy.props.IntProperty( + name="Transmission", + description="Maximum number of transmission bounces", + default=12, min=0, max=1024, + ) + transparent_max_bounces: bpy.props.IntProperty( + name="Transparent", + description="Maximum number of transparent bounces", + default=8, min=0, max=1024, + ) + volume_bounces: bpy.props.IntProperty( + name="Volume", + description="Maximum number of volume scattering bounces", + default=0, min=0, max=1024, + ) + + # --- Clamping --- + + use_clamping: bpy.props.BoolProperty( + name="Use Clamping", + description="Clamp sample values to reduce fireflies", + default=False, + ) + clamp_direct: bpy.props.FloatProperty( + name="Direct Light", + description=( + "Clamp value for direct light samples. " + "0 = no clamping" + ), + default=0.0, min=0.0, max=1e8, + ) + clamp_indirect: bpy.props.FloatProperty( + name="Indirect Light", + description=( + "Clamp value for indirect light samples. " + "0 = no clamping" + ), + default=10.0, min=0.0, max=1e8, + ) + + # --- Film --- + + film_transparent: bpy.props.BoolProperty( + name="Transparent Background", + description="Render the background as transparent", + default=True, + ) + film_filter_width: bpy.props.FloatProperty( + name="Pixel Filter Width", + description="Pixel filter width for anti-aliasing", + default=1.5, min=0.01, max=10.0, + ) + + # --- Output --- + + output_format: bpy.props.EnumProperty( + name="Format", + description="Output file format", + items=[ + ('PNG', "PNG", "PNG image with alpha support"), + ('JPEG', "JPEG", "JPEG image (no alpha)"), + ('TIFF', "TIFF", "TIFF image"), + ('OPEN_EXR', "OpenEXR", "OpenEXR HDR image"), + ('OPEN_EXR_MULTILAYER', "OpenEXR Multilayer", + "OpenEXR with all render passes"), + ], + default='PNG', + ) + color_depth: bpy.props.EnumProperty( + name="Color Depth", + description="Bit depth per channel", + items=[ + ('8', "8 bit", "8 bits per channel"), + ('16', "16 bit", "16 bits per channel"), + ], + default='16', + ) + + # --- Color Management --- + + exposure: bpy.props.FloatProperty( + name="Exposure", + description="Exposure boost in stops", + default=0.0, soft_min=-3.0, soft_max=3.0, + ) + look: bpy.props.EnumProperty( + name="Color Look", + items=[ + ('None', "None", ""), + ('Very Low Contrast', "Very Low Contrast", ""), + ('Low Contrast', "Low Contrast", ""), + ('Medium Low Contrast', "Medium Low Contrast", ""), + ('Medium Contrast', "Medium Contrast", ""), + ('Medium High Contrast', "Medium High Contrast", ""), + ('High Contrast', "High Contrast", ""), + ('Very High Contrast', "Very High Contrast", ""), + ], + default='None', + ) \ No newline at end of file diff --git a/scene_renderer/utils.py b/scene_renderer/utils.py new file mode 100644 index 0000000..e698cd1 --- /dev/null +++ b/scene_renderer/utils.py @@ -0,0 +1,90 @@ +import bpy +import os + + +def silence_puresky(): + removed = 0 + for handler_list in [bpy.app.handlers.depsgraph_update_pre, + bpy.app.handlers.depsgraph_update_post]: + for h in handler_list[:]: + if "puresky" in str(h).lower() or "PureSky" in str(h): + try: + handler_list.remove(h) + removed += 1 + except Exception: + pass + return removed + + +def _snapshot_fragile_sockets(obj, mod, iface): + """Snapshot Material/Object sockets before modifying any override.""" + FRAGILE_TYPES = {'NodeSocketMaterial', 'NodeSocketObject'} + snapshot = {} + for item in iface.items_tree: + if item.item_type != 'SOCKET' or item.in_out != 'INPUT': + continue + if item.socket_type not in FRAGILE_TYPES: + continue + sid = item.identifier + if sid in mod and mod[sid] is not None: + snapshot[sid] = mod[sid] + # Debug output to verify we grab the material + # print(f" [SNAPSHOT] {obj.name} -> {item.name}: {mod[sid]}") + return snapshot + + +def _restore_fragile_sockets(mod, snapshot): + """Restore Material/Object sockets that got wiped by override re-eval.""" + restored = 0 + for sid, value in snapshot.items(): + current = mod[sid] if sid in mod else None + # Force restore if the current value no longer matches our snapshot + if current != value: + try: + mod[sid] = value + restored += 1 + except Exception as e: + print(f" [RESTORE FAILED] {sid}: {e}") + return restored + + +def fix_lib_overrides(): + # Disabled as forcing "True" or "Index" onto generic modifier properties + # destroys the library overrides and clears all material assignments. + return 0 + + + +def fix_missing_images(): + fixed = 0 + for img in bpy.data.images: + if img.source == 'FILE' and img.filepath: + if getattr(img, "packed_file", None): + continue + abs_path = (bpy.path.abspath(img.filepath, library=img.library) + if img.library else bpy.path.abspath(img.filepath)) + if not os.path.exists(abs_path): + img.source = 'GENERATED' + img.generated_width = 8 + img.generated_height = 8 + img.generated_type = 'BLANK' + img.generated_color = (1.0, 0.0, 1.0, 1.0) + fixed += 1 + return fixed + + +def init_metal_gpu(): + try: + prefs = bpy.context.preferences.addons['cycles'].preferences + prefs.compute_device_type = 'METAL' + prefs.get_devices() + for dev in prefs.devices: + dev.use = True + return True + except Exception: + return False + + +def get_scenes(): + return [s.name for s in bpy.data.scenes + if not s.name.startswith("_") and s.name != "_Shots"] \ No newline at end of file diff --git a/utils.py b/utils.py deleted file mode 100644 index 52eca86..0000000 --- a/utils.py +++ /dev/null @@ -1,115 +0,0 @@ -import bpy -import os - - -def silence_puresky(): - removed = 0 - for handler_list in [bpy.app.handlers.depsgraph_update_pre, - bpy.app.handlers.depsgraph_update_post]: - for h in handler_list[:]: - if "puresky" in str(h).lower() or "PureSky" in str(h): - try: - handler_list.remove(h) - removed += 1 - except Exception: - pass - return removed - - -def fix_lib_overrides(): - fixed = 0 - for obj in bpy.data.objects: - if not obj.modifiers: - continue - for mod in obj.modifiers: - if mod.type != 'NODES' or not mod.node_group: - continue - if not hasattr(mod.node_group, 'interface'): - continue - - ng = mod.node_group - name_to_sid = {} - iface = getattr(ng, 'interface', None) - if iface and hasattr(iface, 'items_tree'): - for item in iface.items_tree: - if item.item_type == 'SOCKET' and item.in_out == 'INPUT': - name_to_sid[item.name] = (item.identifier, item.socket_type) - - for item in (getattr(iface, 'items_tree', []) if iface else []): - if item.item_type != 'SOCKET' or item.in_out != 'INPUT': - continue - sid = item.identifier - if sid not in mod: - continue - stored = mod[sid] - st = item.socket_type - tt = type(stored).__name__ - if st == 'NodeSocketBool' and tt == 'int': - mod[sid] = bool(stored) - fixed += 1 - elif st == 'NodeSocketMenu' and tt == 'int': - mod[sid] = int(stored) - fixed += 1 - - ng_name = ng.name - if ng_name == "Add Shader v1.0": - if "Filter by ID" in name_to_sid: - mod[name_to_sid["Filter by ID"][0]] = True - fixed += 1 - if "ID" in name_to_sid and name_to_sid["ID"][1] == "NodeSocketString": - mod[name_to_sid["ID"][0]] = "Index" - fixed += 1 - elif ng_name in ["Add Shaders by Index v1.0", "Make Material", "Make Labels"]: - if "ID" in name_to_sid and name_to_sid["ID"][1] == "NodeSocketString": - mod[name_to_sid["ID"][0]] = "Index" - fixed += 1 - if ng_name == "Make Labels" and "Shrinkwrap" in name_to_sid: - mod[name_to_sid["Shrinkwrap"][0]] = True - fixed += 1 - elif ng_name == "Make attached": - if "Reset Children" in name_to_sid: - mod[name_to_sid["Reset Children"][0]] = True - fixed += 1 - if "Separate Children" in name_to_sid: - mod[name_to_sid["Separate Children"][0]] = True - fixed += 1 - elif ng_name == "Proxy v1.0": - if "Reset Children" in name_to_sid: - mod[name_to_sid["Reset Children"][0]] = True - fixed += 1 - return fixed - - -def fix_missing_images(): - fixed = 0 - for img in bpy.data.images: - if img.source == 'FILE' and img.filepath: - if getattr(img, "packed_file", None): - continue - abs_path = (bpy.path.abspath(img.filepath, library=img.library) - if img.library else bpy.path.abspath(img.filepath)) - if not os.path.exists(abs_path): - img.source = 'GENERATED' - img.generated_width = 8 - img.generated_height = 8 - img.generated_type = 'BLANK' - img.generated_color = (1.0, 0.0, 1.0, 1.0) - fixed += 1 - return fixed - - -def init_metal_gpu(): - try: - prefs = bpy.context.preferences.addons['cycles'].preferences - prefs.compute_device_type = 'METAL' - prefs.get_devices() - for dev in prefs.devices: - dev.use = True - return True - except Exception: - return False - - -def get_scenes(): - return [s.name for s in bpy.data.scenes - if not s.name.startswith("_") and s.name != "_Shots"] \ No newline at end of file