2 Commits

9 changed files with 906 additions and 399 deletions

View File

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

View File

@@ -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')

View File

@@ -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',
)

View File

@@ -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,8 @@ from . import properties, operators, panel
classes = (
properties.SceneRendererSettings,
operators.SCENERENDERER_OT_render_all,
operators.SCENERENDERER_OT_cancel_batch,
operators.SCENERENDERER_OT_launch_swarm,
panel.SCENERENDERER_PT_panel,
)

460
scene_renderer/operators.py Normal file
View File

@@ -0,0 +1,460 @@
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'}

128
scene_renderer/panel.py Normal file
View File

@@ -0,0 +1,128 @@
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
layout.separator()
row = layout.row(align=True)
row.scale_y = 2.0
row.operator("scenerenderer.launch_swarm", text="Start Render Swarm (Network)", icon='NODETREE')

View File

@@ -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',
)

90
scene_renderer/utils.py Normal file
View File

@@ -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"]

115
utils.py
View File

@@ -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"]