Person at a computer with headphones in a warm sunset-lit home office; the right side shows a batch render progress with asset thumbnails and checkmarks.

Blender Batch Render Manager: The Complete Add-on

Post 10 of 10 — Blender Add-on Development with Python


In this series: We built a Batch Render Manager add-on from scratch across ten posts. Post 1 set up the environment. Post 2 established the package structure. Post 3 built the operators. Post 4 built the panel UI. Post 5 built the data model. Post 6 wired up subprocess rendering. Post 7 added the modal runner. Post 8 added preferences and notifications. Post 9 finished error handling and packaging. This is the finale.


Ten posts. One add-on. Let’s ship it.

This final post has three parts. First, the complete assembled code for every file in the package — a single reference you can bookmark instead of stitching together snippets from nine posts. Second, cross-version notes covering the differences between Blender 4.0 legacy and 4.2+ Extensions. Third, a retrospective: what we built, what worked, what we’d do differently, and where to go from here.

If you’ve been following along and your add-on is already working, feel free to skip straight to the retrospective. But if anything drifted across the posts or you want a clean final state to install from scratch, the complete listings are in this post (or our Github repository).


The Complete Add-on

Package Structure

batch_render_manager/
├── __init__.py
├── blender_manifest.toml
├── operators.py
├── panels.py
├── properties.py
├── preferences.py
├── queue_runner.py
├── file_io.py
├── validation.py
└── notifications.py

blender_manifest.toml

schema_version = "1.0.0"

id = "batch_render_manager"
version = "0.1.0"
name = "Batch Render Manager"
tagline = "Queue multiple .blend files for sequential background rendering"
maintainer = "Harlepengren <[email protected]>"
type = "add-on"

blender_version_min = "4.2.0"

license = ["SPDX:GPL-2.0-or-later"]

[permissions]
network = "Send completion notifications via email and Discord webhooks"
files = "Read .blend files; write render output and queue state to disk"
TOML

__init__.py

bl_info = {
    "name": "Batch Render Manager",
    "author": "Harlepengren",
    "version": (0, 1, 0),
    "blender": (3, 6, 0),
    "location": "Properties > Render > Batch Render Manager",
    "description": "Queue multiple render jobs with per-job setting overrides and completion notifications.",
    "warning": "",
    "doc_url": "",
    "category": "Render",
}

import bpy
from bpy.app.handlers import persistent
from . import operators, panels, properties, preferences, file_io, notifications

@persistent
def load_queue_on_startup(dummy):
    ctx = bpy.context
    if ctx and ctx.scene and ctx.scene.batch_render_is_running:
        return
    file_io.load_queue()

def register():
    properties.register()
    operators.register()
    panels.register()
    preferences.register()
    bpy.app.handlers.load_post.append(load_queue_on_startup)

def unregister():
    bpy.app.handlers.load_post.remove(load_queue_on_startup)
    # Stop any active queue before unregistering.
    from . import queue_runner
    if bpy.app.timers.is_registered(queue_runner._poll_render_timer):
        bpy.app.timers.unregister(queue_runner._poll_render_timer)
    if queue_runner._active_process is not None:
        queue_runner._active_process.terminate()

    preferences.unregister()
    panels.unregister()
    operators.unregister()
    properties.unregister()
Python

properties.py

import bpy

# Status values used by the queue runner and the UI.
JOB_STATUS_ITEMS = [
    ('PENDING', "Pending",  "Waiting to be rendered"),
    ('RUNNING', "Running",  "Currently rendering"),
    ('DONE',    "Done",     "Rendered successfully"),
    ('FAILED',  "Failed",   "Render failed or was cancelled"),
]

class RenderJobItem(bpy.types.PropertyGroup):
    """Represents a single render job in the queue."""

    name: bpy.props.StringProperty(
        name="Job Name",
        description="A label for this render job",
        default="Untitled Job",
    )

    filepath: bpy.props.StringProperty(
        name="File Path",
        description="Path to the .blend file to render",
        default="",
        subtype='FILE_PATH',
    )

    enabled: bpy.props.BoolProperty(
        name="Enabled",
        description="Include this job in the queue run",
        default=True,
    )

    status: bpy.props.EnumProperty(
        name="Status",
        description="Current state of this render job",
        items=JOB_STATUS_ITEMS,
        default='PENDING',
    )

    override_resolution: bpy.props.BoolProperty(
        name="Override Resolution",
        description="Use a custom resolution for this job instead of the scene default",
        default=False,
    )

    res_x: bpy.props.IntProperty(
        name="Resolution X",
        description="Override render width in pixels",
        default=1920,
        min=4,
        soft_max=7680,
    )

    res_y: bpy.props.IntProperty(
        name="Resolution Y",
        description="Override render height in pixels",
        default=1080,
        min=4,
        soft_max=4320,
    )

    override_samples: bpy.props.BoolProperty(
        name="Override Samples",
        description="Use a custom sample count for this job",
        default=False,
    )

    samples: bpy.props.IntProperty(
        name="Samples",
        description="Override render sample count",
        default=128,
        min=1,
        soft_max=4096,
    )

    override_output: bpy.props.BoolProperty(
        name="Override Output Path",
        description="Use a custom output path for this job",
        default=False,
    )

    output_path: bpy.props.StringProperty(
        name="Output Path",
        description="Override output directory or file path",
        default="",
        subtype='DIR_PATH',
    )

    override_frame_range: bpy.props.BoolProperty(
        name="Override Frame Range",
        description="Use a custom frame range for this job instead of the scene default",
        default=False,
    )

    frame_start: bpy.props.IntProperty(
        name="Frame Start",
        description="First frame to render for this job",
        default=1,
        min=0,
    )

    frame_end: bpy.props.IntProperty(
        name="Frame End",
        description="Last frame to render for this job",
        default=250,
        min=0,
    )

classes = [RenderJobItem]

def register():
    for cls in classes:
        bpy.utils.register_class(cls)

    bpy.types.Scene.batch_render_jobs = bpy.props.CollectionProperty(
        type=RenderJobItem,
        name="Render Queue",
        description="List of render jobs in the batch queue",
    )

    bpy.types.Scene.batch_render_active_job_index = bpy.props.IntProperty(
        name="Active Job Index",
        description="Index of the currently selected render job",
        default=0,
    )

    bpy.types.Scene.batch_render_is_running = bpy.props.BoolProperty(
        name="Queue Running",
        description="True while the batch render queue is active",
        default=False,
    )

    bpy.types.Scene.batch_render_jobs_done = bpy.props.IntProperty(
        name="Jobs Done",
        description="Number of completed jobs in the current run",
        default=0,
    )

def unregister():
    del bpy.types.Scene.batch_render_jobs
    del bpy.types.Scene.batch_render_active_job_index
    del bpy.types.Scene.batch_render_is_running
    del bpy.types.Scene.batch_render_jobs_done

    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)
Python

operators.py

import bpy
import os
import platform
import subprocess as _subprocess
from . import queue_runner, file_io, validation

class BATCHRENDER_OT_add_job(bpy.types.Operator):
    """Add a new render job to the queue"""
    bl_idname = "batch_render.add_job"
    bl_label = "Add Job"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        jobs = context.scene.batch_render_jobs
        new_job = jobs.add()
        new_job.name = f"Job {len(jobs)}"
        new_job.status = 'PENDING'
        context.scene.batch_render_active_job_index = len(jobs) - 1
        file_io.save_queue()
        return {'FINISHED'}

class BATCHRENDER_OT_remove_job(bpy.types.Operator):
    """Remove the selected job from the queue"""
    bl_idname = "batch_render.remove_job"
    bl_label = "Remove Job"
    bl_options = {'REGISTER', 'UNDO'}

    @classmethod
    def poll(cls, context):
        return len(context.scene.batch_render_jobs) > 0

    def execute(self, context):
        jobs = context.scene.batch_render_jobs
        index = context.scene.batch_render_active_job_index
        jobs.remove(index)
        context.scene.batch_render_active_job_index = max(0, min(index, len(jobs) - 1))
        file_io.save_queue()
        return {'FINISHED'}

class BATCHRENDER_OT_start_queue(bpy.types.Operator):
    """Start processing the render queue"""
    bl_idname = "batch_render.start_queue"
    bl_label = "Start Queue"
    bl_options = {'REGISTER'}

    @classmethod
    def poll(cls, context):
        if context.scene.batch_render_is_running:
            return False
        jobs = context.scene.batch_render_jobs
        return any(j.status == 'PENDING' and j.enabled for j in jobs)

    def execute(self, context):
        errors = validation.validate_queue(context)
        if errors:
            first_job, first_msg = errors[0]
            self.report(
                {'ERROR'},
                f"Queue validation failed — '{first_job}': {first_msg}"
                + (f" (and {len(errors) - 1} more)" if len(errors) > 1 else "")
            )
            return {'CANCELLED'}
        queue_runner.start_queue(context)
        return {'FINISHED'}

class BATCHRENDER_OT_stop_queue(bpy.types.Operator):
    """Stop the currently running render queue"""
    bl_idname = "batch_render.stop_queue"
    bl_label = "Stop Queue"
    bl_options = {'REGISTER'}

    @classmethod
    def poll(cls, context):
        return context.scene.batch_render_is_running

    def invoke(self, context, event):
        return context.window_manager.invoke_confirm(self, event)

    def execute(self, context):
        queue_runner.stop_queue(context)
        return {'FINISHED'}

class BATCHRENDER_OT_retry_job(bpy.types.Operator):
    """Reset a failed job so it can be run again"""
    bl_idname = "batch_render.retry_job"
    bl_label = "Retry Job"
    bl_options = {'REGISTER', 'UNDO'}

    @classmethod
    def poll(cls, context):
        jobs = context.scene.batch_render_jobs
        index = context.scene.batch_render_active_job_index
        if not jobs or index >= len(jobs):
            return False
        return jobs[index].status == 'FAILED'

    def execute(self, context):
        jobs = context.scene.batch_render_jobs
        index = context.scene.batch_render_active_job_index
        job = jobs[index]
        job.status = 'PENDING'
        file_io.save_queue()
        self.report({'INFO'}, f"'{job.name}' reset to PENDING — previous errors still in the log.")
        return {'FINISHED'}

class BATCHRENDER_OT_open_error_log(bpy.types.Operator):
    """Open the error log in the system's default text editor"""
    bl_idname = "batch_render.open_error_log"
    bl_label = "Open Error Log"
    bl_options = {'REGISTER'}

    @classmethod
    def poll(cls, context):
        config_dir = bpy.utils.user_resource('CONFIG', path="batch_render_manager")
        log_path = os.path.join(config_dir, "error_log.txt")
        return os.path.isfile(log_path)

    def execute(self, context):
        config_dir = bpy.utils.user_resource('CONFIG', path="batch_render_manager")
        log_path = os.path.join(config_dir, "error_log.txt")
        try:
            if platform.system() == 'Windows':
                os.startfile(log_path)
            elif platform.system() == 'Darwin':
                _subprocess.run(['open', log_path])
            else:
                _subprocess.run(['xdg-open', log_path])
        except Exception as e:
            self.report({'ERROR'}, f"Could not open log: {e}")
            return {'CANCELLED'}
        return {'FINISHED'}

class BATCHRENDER_OT_reset_queue(bpy.types.Operator):
    """Reset all completed and failed jobs back to pending"""
    bl_idname = "batch_render.reset_queue"
    bl_label = "Reset Queue"
    bl_options = {'REGISTER', 'UNDO'}

    @classmethod
    def poll(cls, context):
        jobs = context.scene.batch_render_jobs
        return any(j.status in {'DONE', 'FAILED'} for j in jobs)

    def execute(self, context):
        for job in context.scene.batch_render_jobs:
            if job.status in {'DONE', 'FAILED'}:
                job.status = 'PENDING'
        return {'FINISHED'}

classes = [
    BATCHRENDER_OT_add_job,
    BATCHRENDER_OT_remove_job,
    BATCHRENDER_OT_start_queue,
    BATCHRENDER_OT_stop_queue,
    BATCHRENDER_OT_retry_job,
    BATCHRENDER_OT_open_error_log,
    BATCHRENDER_OT_reset_queue,
]

def register():
    for cls in classes:
        bpy.utils.register_class(cls)

def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)
Python

panels.py

import bpy

class BATCHRENDER_UL_queue(bpy.types.UIList):
    """Draws each render job as a row in the queue list."""

    def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
        if self.layout_type in {'DEFAULT', 'COMPACT'}:
            row = layout.row(align=True)
            row.prop(item, "enabled", text="")
            status_icons = {
                'PENDING': 'HANDLETYPE_FREE_VEC',
                'RUNNING': 'PLAY',
                'DONE':    'CHECKMARK',
                'FAILED':  'ERROR',
            }
            icon = status_icons.get(item.status, 'NONE')
            row.label(text=item.name, icon=icon)
        elif self.layout_type == 'GRID':
            layout.alignment = 'CENTER'
            layout.label(text="", icon='RENDER_STILL')

class BATCHRENDER_PT_queue(bpy.types.Panel):
    """Main panel for the Batch Render Manager queue."""
    bl_label = "Batch Render Queue"
    bl_idname = "BATCHRENDER_PT_queue"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Batch Render"

    def draw(self, context):
        layout = self.layout
        scene = context.scene

        layout.label(text="Render Queue:", icon='SEQUENCE')

        layout.template_list(
            "BATCHRENDER_UL_queue", "",
            scene, "batch_render_jobs",
            scene, "batch_render_active_job_index",
            rows=4,
        )

        row = layout.row(align=True)
        row.operator("batch_render.add_job", text="", icon='ADD')
        row.operator("batch_render.remove_job", text="", icon='REMOVE')

        layout.separator()

        row = layout.row(align=True)
        row.scale_y = 1.4
        row.operator("batch_render.start_queue", icon='PLAY')
        row.operator("batch_render.stop_queue", icon='SNAP_FACE')

        row = layout.row(align=True)
        row.operator("batch_render.reset_queue")

        layout.separator()

        self._draw_progress(layout, context)

        layout.separator()
        layout.operator("batch_render.open_error_log", icon='TEXT')

    def _draw_progress(self, layout, context):
        scene = context.scene
        jobs = scene.batch_render_jobs

        is_running = scene.batch_render_is_running
        total = sum(1 for j in jobs if j.enabled)
        done = scene.batch_render_jobs_done

        if is_running:
            factor = done / total if total > 0 else 0.0
            label = f"Rendering job {done + 1} of {total}..."
            status_text = "Status: Running"
            status_icon = 'PLAY'
        elif any(j.status == 'FAILED' for j in jobs):
            factor = 1.0
            label = "Finished with errors"
            status_text = "Status: Failed"
            status_icon = 'ERROR'
        elif total > 0 and all(j.status == 'DONE' for j in jobs if j.enabled):
            factor = 1.0
            label = "All jobs complete"
            status_text = "Status: Done"
            status_icon = 'CHECKMARK'
        else:
            factor = 0.0
            label = "No active render"
            status_text = "Status: Idle"
            status_icon = 'INFO'

        col = layout.column(align=True)
        col.label(text=status_text, icon=status_icon)
        col.progress(factor=factor, text=label)

class BATCHRENDER_PT_job_overrides(bpy.types.Panel):
    """Per-job render setting overrides."""
    bl_label = "Job Overrides"
    bl_idname = "BATCHRENDER_PT_job_overrides"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "Batch Render"
    bl_parent_id = "BATCHRENDER_PT_queue"
    bl_options = {'DEFAULT_CLOSED'}

    def draw(self, context):
        layout = self.layout
        scene = context.scene
        jobs = scene.batch_render_jobs
        index = scene.batch_render_active_job_index

        if not jobs or index >= len(jobs):
            layout.label(text="No job selected.", icon='INFO')
            return

        job = jobs[index]

        box = layout.box()
        box.prop(job, "name")

        filepath_box = layout.box()
        filepath_box.alert = not bool(job.filepath)
        filepath_box.prop(job, "filepath")
        if not job.filepath:
            filepath_box.label(text="Set a .blend file path to enable this job.", icon='ERROR')

        layout.separator()

        # Resolution
        box = layout.box()
        row = box.row()
        row.prop(job, "override_resolution", text="Resolution Override")
        sub = box.column()
        sub.enabled = job.override_resolution
        sub.prop(job, "res_x")
        sub.prop(job, "res_y")

        # Samples
        box = layout.box()
        row = box.row()
        row.prop(job, "override_samples", text="Samples Override")
        sub = box.column()
        sub.enabled = job.override_samples
        sub.prop(job, "samples")

        # Output Path
        box = layout.box()
        row = box.row()
        row.prop(job, "override_output", text="Output Path Override")
        sub = box.column()
        sub.enabled = job.override_output
        sub.prop(job, "output_path")

        # Frame Range
        box = layout.box()
        row = box.row()
        row.prop(job, "override_frame_range", text="Frame Range Override")
        sub = box.row(align=True)
        sub.enabled = job.override_frame_range
        sub.prop(job, "frame_start")
        sub.prop(job, "frame_end")

classes = [
    BATCHRENDER_UL_queue,
    BATCHRENDER_PT_queue,
    BATCHRENDER_PT_job_overrides,
]

def register():
    for cls in classes:
        bpy.utils.register_class(cls)

def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)
Python

preferences.py

import bpy

class BatchRenderManagerPreferences(bpy.types.AddonPreferences):
    """User preferences for the Batch Render Manager."""
    bl_idname = "batch_render_manager"

    notify_on_complete: bpy.props.BoolProperty(
        name="Notify on Queue Complete",
        description="Send a notification when the full queue finishes",
        default=True,
    )

    notify_on_failure: bpy.props.BoolProperty(
        name="Notify on Job Failure",
        description="Send a notification when an individual job fails",
        default=True,
    )

    email_notifications: bpy.props.BoolProperty(
        name="Email Notifications",
        description="Send email notifications via SMTP",
        default=False,
    )

    smtp_host: bpy.props.StringProperty(
        name="SMTP Host",
        description="SMTP server hostname (e.g. smtp.gmail.com)",
        default="",
    )

    smtp_port: bpy.props.IntProperty(
        name="SMTP Port",
        description="SMTP server port (typically 587 for TLS, 465 for SSL)",
        default=587,
        min=1,
        max=65535,
    )

    smtp_user: bpy.props.StringProperty(
        name="Sender Email",
        description="The email address used to send notifications",
        default="",
    )

    smtp_password: bpy.props.StringProperty(
        name="SMTP Password",
        description="Password or app-specific password for the sender account",
        default="",
        subtype='PASSWORD',
    )

    smtp_recipient: bpy.props.StringProperty(
        name="Recipient Email",
        description="Email address to send notifications to",
        default="",
    )

    discord_notifications: bpy.props.BoolProperty(
        name="Discord Notifications",
        description="Post notifications to a Discord channel via webhook",
        default=False,
    )

    discord_webhook_url: bpy.props.StringProperty(
        name="Webhook URL",
        description="Discord webhook URL (from channel settings > Integrations)",
        default="",
    )

    def draw(self, context):
        layout = self.layout

        box = layout.box()
        box.label(text="When to Notify", icon='INFO')
        col = box.column()
        col.prop(self, "notify_on_complete")
        col.prop(self, "notify_on_failure")

        layout.separator()

        box = layout.box()
        row = box.row()
        row.prop(self, "email_notifications")
        if self.email_notifications:
            col = box.column()
            col.prop(self, "smtp_host")
            col.prop(self, "smtp_port")
            col.prop(self, "smtp_user")
            col.prop(self, "smtp_password")
            col.prop(self, "smtp_recipient")

        layout.separator()

        box = layout.box()
        row = box.row()
        row.prop(self, "discord_notifications")
        if self.discord_notifications:
            box.prop(self, "discord_webhook_url")

classes = [BatchRenderManagerPreferences]

def register():
    for cls in classes:
        bpy.utils.register_class(cls)

def unregister():
    for cls in reversed(classes):
        bpy.utils.unregister_class(cls)
Python

queue_runner.py

import bpy
import subprocess
import os
import re
from . import file_io, notifications

# Module-level state for the currently active render.
_active_process = None
_active_job_index = None

def _safe_dir_name(name):
    """Strip characters that are invalid in directory names."""
    return re.sub(r'[\\/:*?"<>|]', '_', name).strip()

def build_python_expr(job):
    """
    Build the --python-expr string to inject override settings
    into the subprocess Blender session before rendering.
    Returns None if no overrides are active.
    """
    lines = ["import bpy", "scene = bpy.data.scenes[0]"]
    has_overrides = False

    if job.override_frame_range:
        lines.append(f"scene.frame_start = {job.frame_start}")
        lines.append(f"scene.frame_end = {job.frame_end}")
        has_overrides = True

    if job.override_resolution:
        lines.append(f"scene.render.resolution_x = {job.res_x}")
        lines.append(f"scene.render.resolution_y = {job.res_y}")
        has_overrides = True

    if job.override_samples:
        lines.append(f"scene.cycles.samples = {job.samples}")
        lines.append(f"scene.eevee.taa_render_samples = {job.samples}")
        has_overrides = True

    if job.override_output:
        path = os.path.normpath(job.output_path)
        job_dir = os.path.join(path, _safe_dir_name(job.name))
        os.makedirs(job_dir, exist_ok=True)
        job_dir = job_dir + os.sep
        escaped = job_dir.replace("\\", "\\\\")
        lines.append(f"scene.render.filepath = '{escaped}'")
        has_overrides = True

    if not has_overrides:
        return None

    return "; ".join(lines)

def build_command(job):
    """Build the full subprocess command list for a single render job."""
    blender_path = bpy.app.binary_path
    cmd = [blender_path, "--background", job.filepath]

    python_expr = build_python_expr(job)
    if python_expr:
        cmd.extend(["--python-expr", python_expr])

    # -s / -e set the frame range; -a renders the animation.
    # -a must come last — Blender processes render actions when it sees them.
    cmd.extend([
        "-s", str(job.frame_start),
        "-e", str(job.frame_end),
        "-a",
    ])

    return cmd

def _launch_job(scene, index):
    """
    Launch the subprocess for the job at the given index.
    Updates module-level state and sets the job status to RUNNING.
    """
    global _active_process, _active_job_index

    job = scene.batch_render_jobs[index]
    cmd = build_command(job)

    print(f"[Batch Render] Starting: {job.name}")
    print(f"[Batch Render] Command: {' '.join(cmd)}")

    _active_process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    _active_job_index = index
    job.status = 'RUNNING'
    file_io.save_queue()

def _find_next_pending(scene, start_index):
    """
    Return the index of the next enabled PENDING job at or after start_index.
    Returns None if no such job exists.
    """
    jobs = scene.batch_render_jobs
    for i in range(start_index, len(jobs)):
        job = jobs[i]
        if job.enabled and job.status == 'PENDING':
            return i
    return None

def _finish_queue(scene):
    """Clear state and mark the scene as no longer running."""
    global _active_process, _active_job_index

    _active_process = None
    _active_job_index = None
    scene.batch_render_is_running = False

    prefs = bpy.context.preferences.addons["batch_render_manager"].preferences
    if prefs.notify_on_complete:
        failed = [j.name for j in scene.batch_render_jobs if j.enabled and j.status == 'FAILED']
        done_count = sum(1 for j in scene.batch_render_jobs if j.enabled and j.status == 'DONE')
        if not failed:
            notifications.notify(
                "Render Queue Complete",
                f"All {done_count} job(s) finished successfully.",
            )
        else:
            notifications.notify(
                "Render Queue Complete",
                f"{done_count} job(s) done, {len(failed)} failed: {', '.join(failed)}",
            )

    print("[Batch Render] Queue complete.")

def _poll_render_timer():
    """
    Timer callback — runs on the main thread every 2 seconds.

    Checks whether the active subprocess has finished. If it has,
    updates job status and launches the next pending job (or finishes
    the queue). Returns 2.0 to reschedule, or None to unregister.
    """
    global _active_process, _active_job_index

    scene = bpy.context.scene
    if _active_process is None or scene is None:
        return None

    # poll() returns None while running, or a return code when done.
    returncode = _active_process.poll()

    if returncode is None:
        # Still running — tag the viewport for redraw and check again later.
        for area in bpy.context.screen.areas:
            if area.type == 'VIEW_3D':
                area.tag_redraw()
        return 2.0

    # The process has finished.
    job = scene.batch_render_jobs[_active_job_index]

    if returncode == 0:
        job.status = 'DONE'
        file_io.save_queue()
        print(f"[Batch Render] Done: {job.name}")
    else:
        job.status = 'FAILED'
        file_io.save_queue()
        _, stderr = _active_process.communicate()
        print(f"[Batch Render] FAILED: {job.name}")
        stderr_text = stderr.decode(errors='replace') if stderr else "(no stderr output)"
        print(stderr_text)
        file_io.write_error_log(job.name, stderr_text)
        prefs = bpy.context.preferences.addons["batch_render_manager"].preferences
        if prefs.notify_on_failure:
            notifications.notify(
                "Render Job Failed",
                f"'{job.name}' failed during rendering.\nCheck the console for details.",
            )

    scene.batch_render_jobs_done += 1

    # Find and launch the next job.
    next_index = _find_next_pending(scene, _active_job_index + 1)

    if next_index is not None:
        next_job = scene.batch_render_jobs[next_index]
        if not os.path.isfile(next_job.filepath):
            next_job.status = 'FAILED'
            file_io.save_queue()
            print(f"[Batch Render] ERROR: File not found: {next_job.filepath}")
            prefs = bpy.context.preferences.addons["batch_render_manager"].preferences
            if prefs.notify_on_failure:
                notifications.notify(
                    "Render Job Failed",
                    f"'{next_job.name}' could not start: file not found.\n{next_job.filepath}",
                )
            scene.batch_render_jobs_done += 1
            next_index = _find_next_pending(scene, next_index + 1)

    if next_index is not None:
        _launch_job(scene, next_index)
        return 2.0
    else:
        _finish_queue(scene)
        return None  # Unregister the timer.

def start_queue(context):
    """
    Entry point called by BATCHRENDER_OT_start_queue.
    Launches the first pending job and registers the polling timer.
    """
    scene = context.scene
    scene.batch_render_jobs_done = 0
    scene.batch_render_is_running = True

    first_index = _find_next_pending(scene, 0)
    if first_index is None:
        scene.batch_render_is_running = False
        return

    job = scene.batch_render_jobs[first_index]
    if not os.path.isfile(job.filepath):
        job.status = 'FAILED'
        file_io.save_queue()
        print(f"[Batch Render] ERROR: File not found: {job.filepath}")
        prefs = context.preferences.addons["batch_render_manager"].preferences
        if prefs.notify_on_failure:
            notifications.notify(
                "Render Job Failed",
                f"'{job.name}' could not start: file not found.\n{job.filepath}",
            )
        scene.batch_render_is_running = False
        return

    _launch_job(scene, first_index)

    if not bpy.app.timers.is_registered(_poll_render_timer):
        bpy.app.timers.register(_poll_render_timer, first_interval=2.0)

def stop_queue(context):
    """
    Entry point called by BATCHRENDER_OT_stop_queue.
    Terminates the running subprocess and cleans up.
    """
    scene = context.scene

    if _active_process is not None:
        _active_process.terminate()
        try:
            _active_process.wait(timeout=5)
        except subprocess.TimeoutExpired:
            _active_process.kill()

        if _active_job_index is not None:
            job = scene.batch_render_jobs[_active_job_index]
            job.status = 'PENDING'
            print(f"[Batch Render] Stopped: {job.name}")

    if bpy.app.timers.is_registered(_poll_render_timer):
        bpy.app.timers.unregister(_poll_render_timer)

    _finish_queue(scene)
Python

file_io.py

import bpy
import json
import os
import datetime

def _get_queue_path():
    """Return the full path to the queue JSON file in the user config directory."""
    config_dir = bpy.utils.user_resource('CONFIG', path="batch_render_manager", create=True)
    return os.path.join(config_dir, "queue.json")

def save_queue():
    """Serialize the current queue to JSON and write it to the config directory."""
    jobs = bpy.context.scene.batch_render_jobs
    queue_data = []

    for job in jobs:
        queue_data.append({
            "name": job.name,
            "filepath": job.filepath,
            "enabled": job.enabled,
            "status": job.status,
            "override_resolution": job.override_resolution,
            "res_x": job.res_x,
            "res_y": job.res_y,
            "override_samples": job.override_samples,
            "samples": job.samples,
            "override_output": job.override_output,
            "output_path": job.output_path,
            "frame_start": job.frame_start,
            "frame_end": job.frame_end,
        })

    path = _get_queue_path()
    try:
        with open(path, 'w', encoding='utf-8') as f:
            json.dump(queue_data, f, indent=2)
    except OSError as e:
        print(f"[Batch Render] Could not save queue: {e}")

def load_queue():
    """
    Load queue data from JSON and populate the scene's collection.
    Resets any RUNNING jobs to PENDING (crash recovery).
    """
    path = _get_queue_path()
    if not os.path.isfile(path):
        return

    try:
        with open(path, 'r', encoding='utf-8') as f:
            queue_data = json.load(f)
    except (OSError, json.JSONDecodeError) as e:
        print(f"[Batch Render] Could not load queue: {e}")
        return

    jobs = bpy.context.scene.batch_render_jobs
    jobs.clear()

    for entry in queue_data:
        job = jobs.add()
        job.name = entry.get("name", "Untitled Job")
        job.filepath = entry.get("filepath", "")
        job.enabled = entry.get("enabled", True)
        raw_status = entry.get("status", "PENDING")
        job.status = raw_status if raw_status != 'RUNNING' else 'PENDING'
        job.override_resolution = entry.get("override_resolution", False)
        job.res_x = entry.get("res_x", 1920)
        job.res_y = entry.get("res_y", 1080)
        job.override_samples = entry.get("override_samples", False)
        job.samples = entry.get("samples", 128)
        job.override_output = entry.get("override_output", False)
        job.output_path = entry.get("output_path", "")
        job.frame_start = entry.get("frame_start", 1)
        job.frame_end = entry.get("frame_end", 250)

def write_error_log(job_name, stderr_output):
    """
    Append a failed job's error output to the error log in the config directory.
    Each entry is timestamped and separated for readability.
    """
    config_dir = bpy.utils.user_resource('CONFIG', path="batch_render_manager", create=True)
    log_path = os.path.join(config_dir, "error_log.txt")

    timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    separator = "-" * 60

    entry = (
        f"\n{separator}\n"
        f"[{timestamp}] FAILED: {job_name}\n"
        f"{separator}\n"
        f"{stderr_output.strip()}\n"
    )

    try:
        with open(log_path, 'a', encoding='utf-8') as f:
            f.write(entry)
        print(f"[Batch Render] Error log updated: {log_path}")
    except OSError as e:
        print(f"[Batch Render] Could not write error log: {e}")
Python

validation.py

import gzip
import os

def _is_blend_file(filepath):
    try:
        with open(filepath, 'rb') as f:
            header = f.read(7)
        if header == b'BLENDER':
            return True
        if header[:2] == b'\x1f\x8b':
            with gzip.open(filepath, 'rb') as f:
                return f.read(7) == b'BLENDER'
        return False
    except OSError:
        return False

def validate_queue(context):
    """
    Check all enabled, pending jobs for common problems.
    Returns a list of (job_name, error_message) tuples.
    An empty list means the queue is ready to run.
    """
    jobs = context.scene.batch_render_jobs
    errors = []

    for job in jobs:
        if not job.enabled or job.status != 'PENDING':
            continue

        if not job.filepath:
            errors.append((job.name, "No file path set."))
            continue

        if not os.path.isfile(job.filepath):
            errors.append((job.name, f"File not found: {job.filepath}"))
            continue

        if not _is_blend_file(job.filepath):
            errors.append((job.name, f"Not a valid .blend file: {job.filepath}"))

        if job.frame_end < job.frame_start:
            errors.append((job.name, f"Frame end ({job.frame_end}) is before frame start ({job.frame_start})."))

    return errors
Python

notifications.py

import bpy
import json
import smtplib
import ssl
import urllib.request
import urllib.error

def _get_prefs():
    """Return the add-on preferences object."""
    return bpy.context.preferences.addons["batch_render_manager"].preferences

def notify(title, message):
    """Dispatch a notification through all enabled channels."""
    prefs = _get_prefs()

    if prefs.email_notifications:
        _notify_email(title, message, prefs)

    if prefs.discord_notifications:
        _notify_discord(title, message, prefs)

def _notify_email(title, message, prefs):
    if not prefs.smtp_host or not prefs.smtp_user or not prefs.smtp_recipient:
        print("[Batch Render] Email notification skipped: incomplete SMTP settings.")
        return

    subject = f"[Batch Render] {title}"
    body = f"Subject: {subject}\r\nFrom: {prefs.smtp_user}\r\nTo: {prefs.smtp_recipient}\r\n\r\n{message}"

    try:
        context = ssl.create_default_context()
        with smtplib.SMTP(prefs.smtp_host, prefs.smtp_port, timeout=10) as server:
            server.starttls(context=context)
            server.login(prefs.smtp_user, prefs.smtp_password)
            server.sendmail(prefs.smtp_user, prefs.smtp_recipient, body)
        print(f"[Batch Render] Email sent to {prefs.smtp_recipient}")
    except smtplib.SMTPAuthenticationError:
        print("[Batch Render] Email failed: authentication error. Check your credentials.")
    except smtplib.SMTPException as e:
        print(f"[Batch Render] Email failed: {e}")
    except OSError as e:
        print(f"[Batch Render] Email failed (network error): {e}")

def _notify_discord(title, message, prefs):
    if not prefs.discord_webhook_url:
        print("[Batch Render] Discord notification skipped: no webhook URL set.")
        return

    payload = json.dumps({
        "content": f"**{title}**\n{message}"
    }).encode('utf-8')

    req = urllib.request.Request(
        prefs.discord_webhook_url,
        data=payload,
        headers={
            "Content-Type": "application/json",
            "User-Agent": "DiscordBot (blender_batch_render, 1.0)",
        },
        method="POST",
    )

    try:
        with urllib.request.urlopen(req, timeout=10) as response:
            if response.status not in (200, 204):
                print(f"[Batch Render] Discord webhook returned status {response.status}")
            else:
                print("[Batch Render] Discord notification sent.")
    except urllib.error.URLError as e:
        print(f"[Batch Render] Discord notification failed: {e}")
    except Exception as e:
        print(f"[Batch Render] Discord notification failed: {e}")
Python

Cross-Version Notes

The add-on targets Blender 3.6 as its minimum via bl_info. Here’s what changes — or doesn’t — across versions you’re likely to encounter.

Blender 3.6 and 4.1 (legacy format only)

Everything works as written. The legacy zip is the only installation path. blender_manifest.toml is ignored — these versions don’t know what to do with it, but its presence doesn’t cause any errors.

Blender 4.2+ (legacy and Extensions)

The Extensions platform becomes available here. The manifest is read when the add-on is installed as an extension. The bl_info dictionary coexists in __init__.py for backwards compatibility; Blender 4.2+ will prefer the manifest’s metadata but won’t complain if both are present.

One API change to be aware of: layout.progress() was introduced in Blender 4.0. If you ever need to support 3.x (outside the scope of this series), you’d need to replace the progress widget with a plain layout.label() or a custom drawn box. Everything else in this add-on works cleanly on 3.6.

EEVEE samples in Blender 4.x

In the build_python_expr() function, we set scene.eevee.taa_render_samples. In Blender 4.2, EEVEE was rewritten as EEVEE Next, and some internal property names changed. The taa_render_samples property specifically was renamed to taa_samples in some 4.2 builds. If you’re targeting a wide version range and EEVEE sample overrides matter to you, the safest approach is to wrap the assignment in a try/except:

if job.override_samples:
    lines.append(f"scene.cycles.samples = {job.samples}")
    lines.append(
        f"try:\n"
        f"    scene.eevee.taa_render_samples = {job.samples}\n"
        f"except AttributeError:\n"
        f"    scene.eevee.taa_samples = {job.samples}"
    )

This is admittedly awkward inside a --python-expr string — the indentation needs to survive being joined with semicolons. For a production add-on targeting multiple versions this carefully, a --python-file approach (writing a temporary script to disk and pointing Blender at it) gives you much cleaner multi-line code. That’s a meaningful extension to this add-on and a good exercise if you want to keep going after the series.


What We Built

Let’s actually count what we made across these ten posts.

Ten Python files, one TOML manifest. Roughly 750 lines of working code, not counting all the incremental stubs and revisions along the way. Four complete features:

Feature 1 — Multi-.blend Queue — a UIList-backed queue with add/remove operators, status tracking, and a non-blocking runner driven by bpy.app.timers that keeps the UI responsive during long renders. The trickiest part wasn’t the rendering — it was understanding that subprocess.run() had to become subprocess.Popen() with a polling timer, so Blender’s main thread never blocks waiting for a render to finish.

Feature 2 — Per-Item OverridesPropertyGroup fields with toggle-and-value pairs for resolution, samples, output path, and frame range, injected into each subprocess via --python-expr. No source files are ever modified. This is the feature that makes the add-on genuinely useful rather than just a fancy batch script.

Feature 3 — NotificationsAddonPreferences housing credentials for two independent notification channels (email and Discord), a dispatch function that checks toggles and routes cleanly, and graceful error handling for every channel so one bad configuration doesn’t silence the others.

Feature 4 — Error Handling — pre-flight validation that surfaces problems before a long queue starts, an error log that accumulates render output to a file you can actually read, retry logic that resets failed jobs cleanly, and queue persistence via JSON with a load_post handler for automatic resume.


What We’d Do Differently

No project makes it through ten posts without accumulating some things you’d handle differently with hindsight. Here are the honest ones.

The queue should be global, not per-scene. Attaching batch_render_jobs to bpy.types.Scene made sense as a teaching example — it introduced CollectionProperty in a natural context and showed how to store data that persists with a file. But in practice, a batch render queue isn’t really a scene-level concept. You’re probably rendering across multiple files, and you don’t want to lose the queue if you switch scenes. A better design stores the queue in AddonPreferences or the user config directory exclusively, with no scene attachment at all. The JSON persistence in file_io.py is actually the more correct model — the bpy.types.Scene binding became a bit redundant once we had that.

--python-expr has limits. The string-building approach in build_python_expr() works for simple assignments, but it gets fragile fast — especially the EEVEE samples cross-version issue noted above, and the path escaping on Windows. A more robust approach is to write a temporary Python script to a temp file, pass its path to --python-file, and delete it after the render. More file I/O, but much cleaner code and no escaping gymnastics.

No logging abstraction. Every module peppers the console with print(f"[Batch Render] ...") calls. That works, but it’s not structured. A simple module-level logger using Python’s logging standard library would have made it easier to toggle verbosity, redirect output, and surface log lines in the UI. Not critical for a personal tool, but worth doing for anything you’re sharing.

These aren’t regrets — the series would have been less useful if we’d started with the fully optimized architecture. Building the thing, noticing the friction, and naming it is how you develop judgment for the next project.


Where to Go From Here

The add-on is done, but add-ons are never really finished. A few directions worth exploring if you want to keep building on this one.

Mid-session file loading. Opening a new .blend file while a queue is running triggers load_post, which calls load_queue() — and without a guard, that wipes the in-memory queue state the timer is tracking mid-render. The guard in load_queue_on_startup handles this by skipping the reload if batch_render_is_running is set. The consequence is that jobs added while the queue is active won’t be picked up until the next run. A more complete solution would decouple queue state from scene attachment entirely, watch the JSON file for changes on a timer, and merge new entries into the live queue without a full reload. That’s a meaningful architectural change, but it would enable the workflow of queuing up additional files on the fly while a render is already running.

True background execution. The timer-based runner keeps Blender’s UI responsive and uses Popen() for non-blocking subprocess launch. One remaining gap: if you close Blender mid-queue, the subprocess keeps running orphaned. Adding a terminate() call to the unregister() function handles the add-on-disable case, but a clean solution would also write the subprocess PID to disk so it can be checked and cleaned up on next launch.

Multi-scene targeting. Right now, each job renders bpy.data.scenes[0]. Add a scene name field to RenderJobItem and pass --scene scene_name on the command line to target a specific scene within a file. Useful for files that contain multiple camera setups or lighting variants as separate scenes.

Camera and layer overrides. The same --python-expr mechanism could inject camera or view layer switches. scene.camera = bpy.data.objects["Camera.002"] and scene.view_layers["Diffuse"].use = True are the kinds of per-job settings that would make this a genuinely powerful production tool.

Per-render progress. The progress bar tracks completed jobs vs. total, but gives no feedback during a long individual render — you just see “Rendering…” until it finishes. A better experience would show the current frame and total frames, or elapsed time. Blender exposes frame progress through bpy.app.handlers.render_pre / render_post and bpy.context.scene.frame_current. The approach would be to update a counter in those handlers and surface it through the polling timer’s state so the panel can display it on each tick.

A proper UI for the error log. Right now the error log opens in a text editor. A panel that reads and displays the log inline — with clear/dismiss per entry — would be much nicer for iterating on a failing job without leaving Blender.

Publishing to extensions.blender.org. If you want to put this on the official platform, you’ll need an account, a repo, and a review pass to make sure the manifest is complete, the permissions are accurate, and the add-on doesn’t do anything that would cause the reviewers to bounce it. The official documentation covers the submission process in detail.


Wrapping Up the Series

Ten posts is a long commitment, both to write and to read. Thank you for making it this far.

The stated goal when we started was to build one real, useful add-on — not a toy example, not a collection of disconnected snippets, but something that actually solves a problem. A batch render manager that handles overrides, sends notifications, persists state, and recovers from failures. I think we got there.

More than the specific add-on, though, the goal was to demystify the process. Blender’s Python API is large, and the official documentation covers the what well but the why and how do I structure this less so. Hopefully these posts filled some of that gap — not just teaching the API surface, but building up the intuitions that let you read an unfamiliar part of the API and know roughly what to do with it.

The code is yours. Use it, break it, improve it, ship it.

See you in whatever comes next.


Have questions or ran into a snag? Drop a comment below. And if you want work-in-progress updates, upcoming tutorials, and early looks at what I’m building, subscribe to the newsletter.

Visited 3 times, 3 visit(s) today

Leave a Reply

Your email address will not be published. Required fields are marked *