← Kevin Yoder
AI / ML Tooling Β· Flagship

BlenderHub

A headless Blender 4.5 web IDE where you write or LLM-generate bpy scripts, turn text or images into 3D models, and render with Cycles β€” all from a browser, with no display attached.

Built Feb 2026 11+ hardening sessions Self-hosted Β· LAN-only

πŸ–Ό Screenshots βš™ How it works

01 Overview

BlenderHub is a single-page web IDE for headless Blender 4.5 LTS, running in a Docker container with the Blender binary baked into the image. From a browser on the LAN you create file-based projects, edit bpy Python in a Monaco editor, run scripts in background Blender processes with the console streaming live, render .blend files with Cycles, and preview the results. It also generates the scripts for you: an LLM writes bpy from a natural-language prompt, and separate pipelines turn text or an uploaded image into a 3D model. It doubles as a render backend that other services on the server can call.

Script

Write or generate bpy

A Monaco editor for Python, or an LLM that writes the bpy for you β€” a Standard mode and a parametric Geometry-Nodes mode β€” over a 13-model selector routed through LiteLLM.

Generate

Text / image β†’ 3D

Turn a prompt or an uploaded image into a 3D model: Shap-E locally, or Meshy.ai and Tripo3D in the cloud, dropped straight into the project.

Render

Cycles queue + preview

Run scripts and render .blend files headless with Cycles; renders land in a gallery and models open in an embedded Online3DViewer.

02 Why I built it

BlenderHub is one node of a small constellation of AI services on my home server. The goal was practical: make Blender scriptable and renderable from any browser on the LAN, with no desktop Blender install, and give other services a render backend to call. There isn't a formal motivation doc β€” this is reconstructed from the task summaries β€” but the rapid sequence of same-week fixes shows where the real problem was. It wasn't building the IDE; it was making an LLM's generated bpy script actually produce a visible render, unattended.

03 What I built & how it works

A Flask + Socket.IO app in front of a Blender binary, with a self-healing job runner in between.

Any browser on the LAN β€” no display attached Browser SPA Monaco editor Β· Socket.IO console Β· Online3DViewer REST /api/* + WebSocket Flask + Flask-SocketIO eventlet Β· app.py Β· :8106 serves the SPA Β· projects & files CRUD BlenderRunner: patch β†’ run β†’ self-heal β†’ sweep external services the app calls LiteLLM :4000 13 models Β· code-gen Text / image β†’ 3D Shap-E Β· Meshy Β· Tripo3D /projects bind-mounted disk blender --background --python Blender 4.5 LTS baked into image Β· Cycles (CPU; GPU optional)

Fig. 1 β€” the app orchestrates; Blender runs headless per job; generated scripts are patched and self-healed before their output is swept back into the project.

  1. Describe or write β€” type a bpy script in Monaco, or generate one from a prompt in Standard or Geometry-Nodes Procedural mode.
  2. Patch β€” a regex pass rewrites Blender 3.x API calls to 4.x and injects a camera auto-aim before the first render.
  3. Run β€” Blender runs headless in a background process; stdout streams to the console line by line.
  4. Self-heal β€” if a traceback appears, the runner strips the offending line, retries once, and saves the fix as a learned patch for future scripts.
  5. Collect β€” any image, .blend, or mesh written to /tmp is swept into the project; renders show in the gallery, models open in the 3D preview.

04 πŸ›  Skills & tech used

Languages
Python 3.11JavaScript (ES2017+)Blender Python (bpy)HTML / CSS
ML / AI
LLM code-gen (engineered prompts)LiteLLM multi-provider routinggrounding-by-introspectionself-healing generation loopShap-E Β· Meshy Β· Tripo3D pipelinesprompt-budget tuning
Infra / Ops
Docker image bake (Blender 4.5)headless Blender opsFlask-SocketIO / eventletprocess-group lifecycle (killpg)NVIDIA container runtimeoffline-first vendoring
Frontend
Monaco editor (AMD loader)Socket.IO streaming consoleOnline3DViewervanilla-JS SPABlender-dark theme
Data
file-type taxonomy & routingmtime-window /tmp collectionlearned-patch JSON persistence
Techniques
regex source-to-source transformtraceback parsingnamespaced code injectioncamera auto-aim (to_track_quat)path-traversal whitelistGeometry Nodes 4.x scaffolding

05 Notable challenges & decisions

Most of the work was making an LLM's generated bpy survive contact with a real Blender process.

Self-healing

LLMs hallucinate bpy attributes β€” so I stopped trusting them at three layers

Generated scripts used renamed 3.x properties or invented ones that don't exist. I attacked it at three layers: a Blender 4.x migration cheat-sheet in the system prompt, a runtime regex patcher that rewrites known API breaks, and build-time introspection that dumps the real Blender API into the prompt β€” "if a property isn't in this verified list, it doesn't exist." The design escalated from manual rules to auto-retry to learned patches to a grounded prompt.

Silent failures

Blender exits 0 even when a script raises

A crashed render still reported "done." The runner scans stdout for AttributeError tracebacks β€” tolerating Python 3.11's caret-underline lines β€” strips the offending line, retries once, and persists the (type, attribute) pair to a learned-patches file so future scripts are cleaned pre-emptively.

Geometry, not API

Solid-grey renders because the camera pointed at the floor

The default camera aims down its -Z axis, so many generated scenes rendered as flat grey. The fix was geometric, not a code fix: inject a bounding-box-centre auto-aim (to_track_quat) before the first render β€” but only when the camera is pointed steeply at the floor or ceiling.

Offline-first

CDNs aren't a given on a home LAN

Two separate incidents β€” Socket.IO, then Online3DViewer (which also had a wrong published filename) β€” drove vendoring those libraries locally. Bundling didn't fix it at first: Monaco's AMD loader was hijacking the UMD bundles' global registration, fixed purely by script order. Honest note: Monaco itself still loads from a CDN, so the editor pane needs internet.

Ephemeral disk

AI scripts wrote renders to /tmp, then they vanished

/tmp is unmounted and cleared, and scripts saved renders with arbitrary names. Two iterations of a collector β€” an exact-prefix glob, then all image / blend / mesh files filtered by modification time since the job started, with collision-safe renaming β€” swept them back into the project.

Where I drew the line. Jobs live in memory β€” a restart clears the history, but projects and files persist on disk; that was an accepted tradeoff. The service runs unauthenticated on the LAN only: the script runner is arbitrary code execution by design, so it must never be exposed publicly as-is.

06 Results

~3,488
first-party lines across 9 files
13
LLM models in the selector (LiteLLM)
16
Blender API types introspected into the prompt
3
layers of anti-hallucination defense
11+
same-week hardening sessions
~950MB
Docker image, Blender 4.5 baked in

Sources: source-tree line counts, task summaries, and the running container's config (survey 2026-07-31).

07 Screenshots

The BlenderHub web IDE: a Monaco bpy editor beside a live WebSocket console streaming a Blender render.
The headless Blender web IDE: a Monaco bpy editor and a WebSocket console driving Blender 4.5 with no display.
A shaded maxillary-molar render produced end-to-end through the BlenderHub run pipeline.
A shaded maxillary-molar render produced end-to-end through the run pipeline β€” no GPU required.

08 Honest status

The service was built in late February 2026 and hardened heavily over the following days, then ran essentially untouched β€” no code changes since early March, still up on the home server as a LAN-only tool. Some of that original setup has since drifted: the GPU rendering that once offloaded to a second machine was dropped from the current compose config, so renders run on CPU, and the demo projects directory is now empty. What ships here instead is a runnable, key-free clone (a three-tier recipe): Blender 4.5 is baked into the image, and three example scripts render end-to-end out of the box. In the original fleet, 3D generation was offloaded to a GPU machine; the clone falls back to CPU. Nothing here is production-grade β€” no authentication, and the script runner executes arbitrary Python by design β€” but as a personal tool and a render backend, it did its job.

BlenderHub β€” Build Recipe

Take a bare machine to a running copy of BlenderHub β€” a self-hosted, headless-3D web IDE. In the browser you create a project, write a Blender Python (bpy) script in a Monaco editor, and click β–Ά Run; the server executes it against a baked-in Blender 4.5 as a headless subprocess, streams the console live over WebSocket, and sweeps rendered images / 3D models into a per-project gallery and an in-browser 3D viewer. An optional AI Generate feature turns a natural-language prompt into a bpy script, and an optional Shap-E service does key-free local text-to-3D.

Everything runs on CPU β€” no GPU required. The default demo is key-free.

Status: βœ… Verified 2026-08-01 β€” built from this clean clone with a single docker compose up --build. The image builds (Blender 4.5.12 LTS baked in + its Python API introspected at build time), the container comes up healthy, and on first run it seeds an examples project. All three seed scripts were run through the app's own pipeline (/run β†’ headless Blender β†’ /tmp sweep β†’ gallery) and produced real Cycles/CPU renders key-free: a beveled metallic cube, a parametric Geometry-Nodes cube grid, and a shaded maxillary molar (see demo.png / molar_render.png). The AI Generate button correctly shows disabled with no LLM configured (/api/ai/models β†’ enabled:false), and Monaco is vendored locally (served from /static, no CDN). Both opt-in profiles were exercised: --profile llm flips the AI gate on (/api/ai/models β†’ enabled:true, models listed via the bundled LiteLLM gateway), and --profile 3d Shap-E boots, is auto-detected by the UI, downloads its model, and generated a 20-frame preview GIF on CPU (~7.6 min; it auto-detects and uses a GPU when present). The clean clone lives in clone/. All three tiers are covered β€” run the prebuilt container (a single blenderhub tarball, ~1505 MB, verified 2026-08-01), build the image from source, or run bare-metal on Linux/macOS.

Sensitive data β€” read this. Nothing personal ships. A grep of the whole source for names, LAN IPs, .local hosts, personal paths, and hardcoded secrets came back clean; a fresh install seeds zero user projects (the examples project is generic geometry). No API keys are embedded anywhere β€” every credential reads from the environment with an empty default.


What it is

A single container (Flask + Flask-SocketIO over eventlet, one port 8106):

  • app.py β€” REST + WebSocket server: project/file CRUD, job run/render, live output, optional AI-generate and text/imageβ†’3D routes.
  • blender_runner.py β€” runs Blender headless as a subprocess and streams stdout. Three layers keep AI-written scripts alive on Blender 4.5: a regex 3.xβ†’4.x compat patcher, an AttributeError scrape-and-retry, and a build-time-baked blender_api.json of verified API names injected into the LLM prompt.
  • ai_client.py β€” optional AI generation against any OpenAI-compatible endpoint (off by default).
  • Frontend β€” templates/index.html + static/app.js: Monaco editor (vendored), Online-3D-Viewer (vendored), Socket.IO (vendored). No CDN.

Prerequisites

New machine? Install the base tools first β€” see ../SETUP.md.

Tier You need
1 β€” prebuilt container (recommended) Docker Desktop / Engine (see ../SETUP.md). Nothing else β€” Blender, all system libs, and Python deps are baked into the image.
2 β€” build from source Docker Desktop / Engine. Builds Blender + all deps from the Dockerfile.
3 β€” bare-metal Blender 4.5 on PATH (or via BLENDER_PATH) and Python 3.11, on a Linux/macOS host (the job runner uses POSIX process groups; it does not run natively on Windows β€” use Docker or WSL there).

Configuration β€” everything is optional

docker compose up needs no .env and no keys. Everything below is opt-in (see .env.example):

Setting Unlocks Without it
--profile llm + LITELLM_* AI Generate (prompt β†’ bpy script) AI button shown but disabled
--profile 3d Local Shap-E text→3D (key-free) Text→3D shows "not configured"
MESHY_API_KEY / TRIPO_API_KEY Cloud text/image→3D (GLB meshes) those providers stay hidden
CYCLES_DEVICE=GPU GPU Cycles (needs a CUDA base + nvidia runtime) CPU rendering (default)

Tier 1 β€” Run the prebuilt container(s) (recommended)

Availability: the prebuilt image bundle is available on request β€” it is not published or linked anywhere. Ask Kevin for it, or build everything from source via Tier 2/3 below.

Already have the offline tarball? Load it and start β€” no build step:

docker load -i blenderhub-image.tar        # ~1505 MB (Blender 4.5 baked in)
cd clone
docker compose up -d                        # β†’ http://localhost:8106  (no --build)
curl -s localhost:8106/api/blender/version  # β†’ "Blender 4.5.12 LTS ..."

To produce the tarball, run this once on a machine that has already built the image (see Tier 2):

docker save blenderhub -o blenderhub-image.tar   # ~1505 MB

The tarball is honestly large because Blender 4.5 is baked into the single blenderhub image. The two opt-in profiles are not in it: --profile llm pulls ghcr.io/berriai/litellm:main-latest, and --profile 3d builds the separate blenderhub-shap-e image (~7.7 GB) β€” save that one on its own if you want it offline (docker save blenderhub-shap-e -o blenderhub-shape-image.tar).

Tier 2 β€” Build the image from source

cd clone
docker compose up -d --build          # β†’ http://localhost:8106

No keys, no .env. On first boot the entrypoint seeds an examples project (three bpy scripts). Open http://localhost:8106, pick a script in the sidebar, and click β–Ά Run Script β€” watch the Blender console stream and the render appear in the Gallery tab.

Tier 3 β€” Bare-metal (Linux / macOS)

cd clone
pip install -r requirements.txt
# Blender 4.5 must be installed; point BLENDER_PATH at its binary.
BLENDER_PATH=/opt/blender/blender PROJECTS_DIR=./projects python app.py   # :8106

(The core renderer needs no GPU and no display β€” headless Cycles/CPU.)


Optional: AI Generate (LLM)

The AI feature talks to any OpenAI-compatible endpoint and is off by default. Turnkey path = the bundled LiteLLM proxy:

# put a provider key in .env (see .env.example), then:
docker compose --profile llm up -d --build

.env.example documents both a cloud path (LiteLLM + OPENAI/ANTHROPIC/ GEMINI_API_KEY) and a fully local, key-free path (point LITELLM_URL at a host Ollama /v1 β€” no gateway needed). The app uses two generic aliases (cloud-model, local-model) mapped to real providers in litellm.config.yaml.

Optional: Shap-E local text→3D

Key-free local text-to-3D, bundled as an opt-in service:

docker compose --profile 3d up -d --build     # adds local Shap-E (no keys)

Heavy add-on: a multi-GB image + a ~1–2 GB model download on first use. It uses a local NVIDIA GPU automatically if present (auto-detected β€” see the commented deploy block in docker-compose.yml), otherwise CPU (slow, minutes per generation). The UI only offers Shap-E once the service is actually up.

Profiles compose freely: docker compose --profile llm --profile 3d up.

Verify

curl -s localhost:8106/api/blender/version        # β†’ "Blender 4.5.12 LTS ..."
curl -s localhost:8106/                           # β†’ 200 HTML "BlenderHub"
curl -s localhost:8106/api/ai/models              # β†’ {"enabled":false,...} (AI off)
curl -s localhost:8106/api/projects               # β†’ {"projects":["examples"]}

End-to-end render (key-free, no GPU) β€” run a seed script and confirm it collects a render into the gallery:

JID=$(curl -s -X POST localhost:8106/api/projects/examples/run \
       -H 'Content-Type: application/json' -d '{"filename":"example_cube.py"}' \
       | grep -oE '"job_id":"[^"]+"')
# poll GET /api/jobs/<id> until "status":"done", then:
curl -s localhost:8106/api/projects/examples/renders   # β†’ lists example_cube.png

Notes & gotchas

  • No GPU needed. Rendering always uses Cycles; CYCLES_DEVICE defaults to CPU. The GPU path is opt-in and self-guards back to CPU when no CUDA device is found. This is the "collapse the optional GPU offload β†’ local (single-machine)" step β€” the clone runs on any plain machine.
  • Blender download resilience. download.blender.org sits behind a Cloudflare bot-challenge that 403s automated (CI/Docker) wget. The Dockerfile therefore pulls Blender 4.5.12 from Blender's official public mirrors (falling back to download.blender.org last) and verifies a pinned SHA256. Bump BLENDER_VERSION + BLENDER_SHA256 together to update; any 4.5.x LTS patch shares the same API.
  • Monaco is vendored under static/monaco/ β€” the IDE works fully offline (Socket.IO and the 3D viewer were already vendored).
  • Blender exits 0 even on a script exception, so the runner scrapes stdout for AttributeError tracebacks and retries once with the offending lines stripped β€” one of three layers that make LLM-written 3.x-style scripts survive on 4.5.
  • POSIX-only runner. Cancel uses process groups (killpg), so bare-metal needs Linux/macOS; on Windows use Docker (the image is Linux) or WSL.

Provenance β€” data / model (core: no training, no GPU)

The core app trains nothing and needs no GPU. Blender 4.5.12 LTS is downloaded at build time (mirror + pinned SHA256) and baked into the image; its Python API is introspected once at build to produce blender_api.json. The three seed scripts are generic geometry (a cube, a Geometry-Nodes grid, an anatomical molar approximation) β€” no personal data, no external assets. The optional AI feature calls a user-supplied LLM (no weights ship). The optional Shap-E service pulls openai/shap-e from HuggingFace at first use (opt-in).

What was stripped from the personal version (de-identification + minimalism)

  • The home-server stack coupling β€” the live service ran in a multi-file compose wired to an external homeserver network, a host USB-drive mount, a live-source bind-mount (which shadowed the baked API file), a 2 GB mem cap, and LITELLM_API_KEY=${LITELLM_MASTER_KEY}. Replaced with a standalone docker-compose.yml (one service, named volume, bridge network) + opt-in --profile llm / --profile 3d.
  • The Juice (GPU-over-IP) shim on the Shap-E service (juice-entrypoint.sh LD_PRELOADing a fake libcuda.so) β†’ removed; the service keeps its existing GPU-auto-detect / CPU-fallback and simply runs python app.py.
  • The hard LiteLLM coupling β€” the app now depends only on "an OpenAI-compatible endpoint," off by default; the personal paid/free/backup model-alias roster (in ai_client.py and app.js) was genericized to cloud-model / local-model.
  • "Your homeserver" wording and the compose-service-specific setup instructions β†’ genericized; the internal shap-e:8209 / litellm:4000 default hosts made opt-in.
  • Author infrastructure (the home server's compose files, Caddy config, LiteLLM master key, Juice config) β†’ not copied. A net-new LLM-off gate was added so the key-free demo disables AI cleanly instead of erroring.

Known limitations (stated honestly β€” it's a personal-LAN tool)

No authentication, permissive CORS (*), no per-job timeout, and job output is held in memory and re-broadcast to all clients. Fine for local / trusted-network use; put a reverse proxy with auth in front before exposing it. The server runs Flask-SocketIO over eventlet in a single process. Shap-E on CPU is slow (~7–8 min for a 20-frame preview at frame_size 96, measured) β€” use the GPU opt-in for real use.