Status: Stages 0–4 implemented, revised after review. The implementation
lives in packages/blender-nodes (TypeScript: binary discovery, job
contract, LocalBlenderRunner, WorkerBlenderRunner, runBlenderJob, the
five nodes), packages/blender-nodes/blender_ops (the Python op scripts
each node runs headless), packages/protocol/src/bridge-frames.ts
(blender.execute / blender.event), and
packages/runtime/src/blender-executor.ts (executeBlender, the bridge
client). The worker-side blender_handler.py lives in nodetool-core.
Blender runs as a headless processor over the glTF document NodeTool already
has. A new packages/blender-nodes package ships nodetool.blender.* nodes
that take a Model3DRef and return an image, a video, a model, or an exported
file. Every node builds a JSON job and hands it, with its input bytes, to a
BlenderRunner that works on logical file names only. The local runner owns a
scratch directory and spawns blender -b --python on a vendored Python op
script through the bounded host binary runner ffmpeg already uses. The worker
runner, a later stage, sends the same job and bytes as bridge blobs to a GPU
worker, the way the ComfyUI worker works. Nodes never know which tier ran
them, and the local tier is hardened but not sandboxed: the worker is where an
isolation boundary exists.
- Render a glTF scene to a still, to render passes (depth, normal, mask), and to an animation, at a quality the three.js preview renderer cannot reach.
- Prepare a generated mesh for a game engine: remesh, decimate, unwrap, bake, export FBX, OBJ, USD, or GLB.
- Feed video models with camera-consistent frames and control passes from the same scene.
- Keep the existing 3D editor,
ui_3d_*tools, andmodel3dagent capability working on the same asset before and after a Blender step. - Fail with a message that names the fix when Blender is missing, too old, or the scene cannot be imported.
- A Blender scene editor in the web app. The document stays glTF and the existing editor stays the editor.
- Arbitrary user Python inside Blender in the first release. A gated
RunScriptnode is listed under alternatives, not in scope. - Bundling Blender into the Electron installer or the Fly server image.
- Simulation, physics, geometry nodes, and rigging. The op catalog can grow, but none of these are in the stages below.
- Background jobs with a ledger row. Long renders stay synchronous under the node's timeout until the worker tier exists.
packages/model3dis the scene document: glTF 2.0,parseModel3D,serializeModel3D, theModel3DOperationunion,validateModel3D.Model3DRefis{ type: "model_3d", uri, asset_id?, temp_id? }inpackages/protocol/src/api-types.ts. It has noformatfield. Node code reads the looserModel3DRefLikewithdata,format,vertices,faces(packages/video-nodes/src/nodes/model3d/types.ts), andformatdoes not survive a crossing into the protocol type. The generic stored-file type isAssetRef(type: "asset",uri,asset_id,metadata).packages/video-nodes/src/nodes/model3d/ships 18nodetool.model3d.*nodes: load and save,FormatConverter,Transform3D,Decimate,Boolean3D,RepairMesh,MergeMeshes,TextTo3D,ImageTo3D, andRenderToImage. They are pure TypeScript over@gltf-transform/core,manifold-3d, andmeshoptimizer.RenderToImagelaunches headless Chromium over CDP with SwiftShader and renders through three.js (render3d-headless.ts). It produces previews: one still, preset lighting, no animation, no shadows of quality, no passes.packages/agents/src/capabilities/model3d.tsexposeslist_model3ds,create_model3d,get_model3d,edit_model3d,validate_model3d. They applyModel3DOperations to the asset bytes headlessly.
packages/agents/src/host-binaries.tsownsrunHostBinary(cmd, args, { cwd, timeoutMs, artifactPath?, maxArtifactBytes? }). It spawns without a shell, bounds wall clock (SIGTERM then SIGKILL), captured output (MAX_CAPTURED_BYTES), artifact size, and concurrency (maxConcurrentHostBinaries, envNODETOOL_HOST_BINARY_CONCURRENCY). A missing binary isHostBinaryMissingError.host-binary-guard.tsconfines model-authored argv to the workspace.@nodetool-ai/agentsdepends on@nodetool-ai/video-nodes, so a node package cannot importrunHostBinarywithout a dependency cycle.- Files reach a binary through
Workspace(packages/runtime/src/workspace.ts):materialize,absorb,scratchDir, andlocalDir, which is null on a cloud workspace. ProcessingContext.signalis the run-levelAbortSignalthe kernel aborts onWorkflowRunner.cancel().
createPythonBridge(packages/runtime/src/python-bridge-factory.ts) returns a websocket bridge whenNODETOOL_WORKER_URLis set and a stdio bridge otherwise. The stdio bridge refuses to run in production.- The ComfyUI worker image co-locates a loopback-only ComfyUI and proxies it
over
comfy.executeandcomfy.eventframes (packages/protocol/src/bridge-frames.ts,packages/runtime/src/comfy-executor.ts). Media inputs travel as bridge blobs.packages/computeprovisions and reaps such workers on RunPod and Vast.
- The Fly image (
Dockerfile,node:22-slim) installs ffmpeg, chromium, pandoc, and a Python venv. Blender is not there. - Electron installs tools on demand through
CondaRuntimePackage(electron/src/runtime/packages/definitions.ts). conda-forge has noblenderpackage, so that path is closed for the executable. PyPI shipsbpywheels pinned to one Python minor per release. - Package runtime files are declared in
PACKAGE_RUNTIME_ASSETSandPACKAGE_RUNTIME_ASSET_DIRS(packages/config/src/package-asset-registry.ts) so the Electron bundle stages them.
packages/cli/src/harness/registry.tsmaps diff paths to surfaces and checks.capability-table.tsis generated bynpm run capabilities:syncand failscapabilities:checkon an uncovered capability.
packages/agents/src/host-binaries.tsmoves topackages/runtime.- New
packages/blender-nodes. packages/base-nodes/src/index.tsregisters the new node array.packages/config/src/package-asset-registry.tsdeclares the op script directory.packages/agents/src/capabilities/model3d.tsgainsrender_model3d.packages/cli/src/harness/registry.tsgains ablendersurface.packages/protocol/src/bridge-frames.tsandpackages/runtimegainblender.executein the worker stage.packages/config/src/setting-catalog.tsgainsBLENDER_PATH, andstart.sh doctora Blender line, in Stage 1.
- A1. Blender 5.2 LTS is the floor. The glTF importer and exporter are core
add-ons enabled under
--factory-startup. EEVEE Next and Cycles both render headless on CPU with no display. - Revision (Stage 3): the floor moved from 4.2 LTS to 5.2 LTS. Nothing ever
ran on 4.2:
render_passes.pyreadsscene.compositing_node_group(the 5.x compositor entry point; 4.2 has only the legacyuse_nodes/node_treepair) andrender_animation.pysetsimage_settings.media_type(the 5.x image/video split; 4.2 exposesfile_formatdirectly). On a 4.2 binary both ops raiseAttributeError, so the old floor promised what the code could not do. A 4.2 backport would need the compositor tree rebuilt on the legacyScene.node_treeAPI and the animation output set throughfile_format = "FFMPEG"with nomedia_typeflip — untested here, since the only binary available is 5.2 LTS. - A2. Desktop users who want Blender nodes install Blender themselves. The node discovers it. This holds until a first-use download exists.
- A3. The Python worker image is built outside this repository. Stage 4 here covers the protocol and the TypeScript client only.
- A4. Cycles on CPU is minutes per frame at production samples. Stills at preview samples and EEVEE animations are seconds to a minute.
- U1. Explainer animation needs authored camera paths and text objects. The
Model3DOperationunion has no camera or text primitive. This design adds camera presets on the render node and defers scene-side camera authoring. If explainers need per-shot camera keyframes soon,add_objectneeds acamerakind andset_transformneeds keyframes, which is a change topackages/model3d, not to this package. - U2. Cloud profile. On Fly there is no Blender and no worker by default. The nodes are excluded from the cloud profile until a Blender-enabled worker image ships (D8), so a cloud user never sees a node that cannot run.
packages/blender-nodes (@nodetool-ai/blender-nodes), node types
nodetool.blender.*. Dependencies: node-sdk, runtime, protocol,
config, nodes-utils. Layout mirrors video-nodes: src/nodes/*.ts, a
BLENDER_NODES array, tests/, and the ./nodes/* subpath export so
base-nodes imports the array. The Python op script lives at
packages/blender-nodes/blender_ops/ next to the sources.
A new package rather than a subdirectory of video-nodes because the op
script is a runtime asset directory with its own staging rule, and because a
cloud profile allowlists by namespace.
packages/agents/src/host-binaries.ts moves to
packages/runtime/src/host-binaries.ts with the same exports.
@nodetool-ai/agents re-exports it so media.ts and its tests change only
their import path. host-binary-guard.ts stays in agents. It confines argv a
model wrote. Blender argv is built by the node from typed props, and every
path in it is a file the node itself wrote into the scratch directory.
RunHostBinaryOptions gains four optional fields. signal?: AbortSignal:
on abort the runner sends SIGTERM and follows the existing SIGKILL path.
env?: Record<string, string>: the child's whole environment when set,
instead of process.env. onStderrLine?: (line: string) => void: fed from
the same stream the capture reads. concurrencyClass?: string: selects the
semaphore. The default class keeps the existing NODETOOL_HOST_BINARY_CONCURRENCY
cap for ffmpeg and yt-dlp. A render class is capped by
NODETOOL_BLENDER_CONCURRENCY, default 1, so a two-minute Cycles render never
holds a slot a two-second ffmpeg call is waiting for. Existing callers pass
none of the four and see no change.
packages/blender-nodes/src/blender-binary.ts:
export interface BlenderBinary {
path: string;
version: [number, number, number];
}
export async function resolveBlenderBinary(): Promise<BlenderBinary>;Order: BLENDER_PATH, then blender on PATH, then well-known locations
(/Applications/Blender.app/Contents/MacOS/Blender, /usr/bin/blender,
/snap/bin/blender, %ProgramFiles%\Blender Foundation\Blender *\blender.exe).
The first candidate that runs --version wins. Below 5.2 throws
BlenderVersionError naming the found version and the floor. No candidate
throws HostBinaryMissingError("blender"). The result is cached per process
and invalidated when BLENDER_PATH changes.
Every node produces a BlenderJob. The Python side consumes it and writes a
BlenderResult. Both are versioned so the TypeScript and Python halves can
drift by one version during an upgrade. The job names every file on both
sides. The result never names a file: it reports which declared outputs were
produced, and the host reads only the paths the job itself declared. This is
the invariant that keeps a buggy or compromised op script from turning result
processing into an arbitrary file read, and T2 tests it.
// packages/blender-nodes/src/job.ts
export const BLENDER_JOB_VERSION = 1;
export type BlenderOp =
| { op: "render_image"; params: RenderImageParams }
| { op: "render_passes"; params: RenderPassesParams }
| { op: "render_animation"; params: RenderAnimationParams }
| { op: "prepare_for_engine"; params: PrepareForEngineParams }
| { op: "export_model"; params: ExportModelParams };
/** A bare file name: no separator, no `..`, no leading dot. */
export const jobFileNameSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
export interface BlenderJob {
version: typeof BLENDER_JOB_VERSION;
/** Logical input name -> bare file name. The runner writes these. */
inputs: { model: string };
/** Logical output name -> bare file name the op must write. */
outputs: Record<string, string>;
job: BlenderOp;
}
export const blenderResultSchema = z.discriminatedUnion("ok", [
z.object({
ok: z.literal(true),
/** Logical output names the op wrote. Must be a subset of job.outputs. */
produced: z.array(z.string()),
stats: z.object({
blender_version: z.string(),
render_seconds: z.number(),
frames: z.number().int().optional(),
objects: z.number().int().optional()
})
}),
z.object({
ok: z.literal(false),
error: z.object({
code: z.enum([
"import_failed", "no_geometry", "unsupported_format",
"render_failed", "export_failed", "bad_job"
]),
message: z.string()
})
})
]);Camera params reuse the RenderToImage vocabulary so a user can swap the
preview node for the Blender node without relearning: azimuth, elevation,
fov, zoom, lighting, light_intensity, background_color,
transparent. Blender-specific additions: engine (eevee | cycles),
samples, denoise, resolution_percentage.
camera_mode decides whose camera renders, because the orbit props always
carry a default and a default cannot signal intent:
camera_mode |
Behavior |
|---|---|
auto (default) |
The scene's first camera when the glTF carries one, else an orbit camera from the props. |
scene |
The scene's first camera. No camera is no_camera, an error. |
orbit |
Always an orbit camera from the props. The scene's cameras are ignored. |
Lights follow the same rule without a switch: the scene's lights when it has
any, else the lighting preset. A test pins each branch.
Output contracts the ops must honor:
depth: linear distance along the camera's view axis, in scene units, from the Z pass.depth_formatispng16(default) orexr. Inpng16the value is normalized to[0, 65535]betweendepth_nearanddepth_far, the min and max finite depth in the frame, both returned as floats, and background pixels are65535. Inexrthe value is the raw float, and background is+inf: the staged EXR carries the1e10no-hit sentinel (measured 5.2.1 on both engines), which the op rewrites to+infbefore the output is written. Control-pass consumers getpng16. Anything that needs precision getsexr.normal: camera-space normals from the Normal pass, mapped from[-1, 1]to 8-bit RGB. Background is(128, 128, 255).mask: 8-bit alpha of the object index pass, foreground255. The pass is enabled before the Render Layers node is created (measured 5.2.1: Cycles then lists Image/Alpha/Depth/Object Index/Noisy Image) and every mesh renders with object index 1, so the mask keys on a positive index with background 0. Deviation (recorded, not silent, EEVEE only): EEVEE'sCompositorNodeRLayersexposes no index socket (measured 5.2.1: only Image/Alpha/Depth appear withuse_pass_object_indexon), so EEVEE keys the mask on finite Z instead. The gate agrees with the index pass on opaque geometry and disagrees for alpha-blended, holdout, and volume materials. EEVEE's no-hit Z value1e10is a measured EEVEE behavior on 5.2.1, not a documented API value: re-measure it if the version floor moves.render_animation: the scene fps is set tofps. A glTF animation channel's timestamptseconds lands on frameround(t * fps).frame_startandframe_endare frames in that timeline. When the glTF has no animation andcamera_modeisorbit, the orbit turnsorbit_degreesacross the frame range.
packages/blender-nodes/blender_ops/run_job.py plus one module per op.
Declared in PACKAGE_RUNTIME_ASSET_DIRS as
{ pkg: "@nodetool-ai/blender-nodes", path: "blender_ops", bundleDir: "_blender_ops", files: [...] }
with every file named, so an unstaged module fails the bundle verifier
instead of the product.
Invocation, built by LocalBlenderRunner:
blender -b --factory-startup --disable-autoexec \
--python-exit-code 64 --python <asset_dir>/run_job.py -- job.json
run_job.py reads job.json, dispatches on job.op, and always writes
result.json, including on exception (ok: false, code from the exception
class, message from the exception). It sets sys.excepthook before importing
op modules so an import error also lands in result.json. Exit code 64 is
reserved for "the script raised", distinct from Blender's own crash codes.
Scene setup is the same for every render op: bpy.ops.wm.read_factory_settings(use_empty=True),
bpy.ops.import_scene.gltf(filepath=inputs.model), then the camera
camera_mode selects (D4). An orbit camera is placed by the same
auto-framing math render3d-core.ts uses (computeFraming, orbitOffset),
ported once into blender_ops/framing.py and pinned by a fixture test on both
sides (T3).
Video output for render_animation uses Blender's own FFMPEG writer
(image_settings.file_format = "FFMPEG", MPEG-4 container, H.264, yuv420p),
so the package needs no ffmpeg on PATH and no Mediabunny dependency.
The runner works on logical files and nothing else. No scratch directory, no argv, and no path crosses the interface, so the local and worker implementations differ only in where the bytes go.
// packages/blender-nodes/src/runner.ts
export interface BlenderRunOptions {
timeoutMs: number;
signal: AbortSignal;
onProgress?: (frame: number, total: number) => void;
/** Per-output byte cap. Default MAX_OUTPUT_BYTES (512 MiB). */
maxOutputBytes?: number;
/** Cap on the sum of all outputs. Default MAX_TOTAL_OUTPUT_BYTES (1 GiB). */
maxTotalOutputBytes?: number;
}
export interface BlenderRunResult {
outputs: Record<string, Uint8Array>; // keyed by job.outputs name
stats: BlenderStats;
}
export interface BlenderRunner {
readonly kind: "local" | "worker";
run(
job: BlenderJob,
inputs: Record<string, Uint8Array>, // keyed by job.inputs name
options: BlenderRunOptions
): Promise<BlenderRunResult>;
}runBlenderJob(context, modelBytes, op, outputs, options) is the thin
function nodes call. It validates every input and output file name with
jobFileNameSchema, refuses more than MAX_OUTPUT_COUNT (32) declared
outputs, builds the BlenderJob, picks a runner (D7), and returns the
runner's result. A node never touches a runner.
LocalBlenderRunner.run:
const cwd = await context.workspace.scratchDir(). Write each input under its declared bare file name, thenjob.json.- Spawn through
runHostBinary(binary.path, argv, { cwd, timeoutMs, signal, concurrencyClass: "render", env }).envis an allowlist:PATH,HOME,TMPDIR,LANG,SYSTEMROOT,CUDA_VISIBLE_DEVICES, plusBLENDER_USER_CONFIG,BLENDER_USER_SCRIPTS, andBLENDER_USER_EXTENSIONSpointed at an empty directory undercwd, so the user's add-ons and startup scripts never load. NoartifactPath: the host runner's single-file watchdog cannot cover a multi-output job, so enforcement lives in step 5. - Read
result.json. Parse withblenderResultSchema. A missing or unparsable file isBlenderJobError("bad_result")carrying the last 4 KiB of stderr. - On
ok: false, throwBlenderJobError(code, message). - For every name in
job.outputs,statthe declared path before reading it. A declared output missing from disk or absent fromproducedismissing_output. A file abovemaxOutputBytes, or a running total abovemaxTotalOutputBytes, isoutput_too_largenaming the output and the cap, thrown before the file is read into memory. A name inproducedthat the job did not declare is ignored and logged at warn. - Delete the scratch directory in
finally, including on abort and on the cap errors above.
WorkerBlenderRunner.run (Stage 4) sends the same inputs as bridge blobs,
the job inside a blender.execute frame, and applies the same step 5 caps
to the blobs that come back, by declared size before transfer where the
bridge reports one.
Progress: Blender prints Fra:<n> lines on stderr during animation renders.
The local runner turns them into onProgress calls through onStderrLine;
the worker runner reads blender.event frames. The node turns either into
node_progress messages through context.postMessage, the way the ComfyUI
node does.
export async function resolveBlenderRunner(): Promise<BlenderRunner>;Stage 1 ships LocalBlenderRunner only. Stage 4 adds WorkerBlenderRunner,
selected when no local Blender resolves, NODETOOL_WORKER_URL is set, and
the worker reports worker.status.blender.enabled, the same selector
createPythonBridge uses. A local binary wins when both exist, so a desktop
with Blender installed never pays for a worker. A configured worker that
fails its status check is an error, never a silent fallback to local: the
URL is a deployment choice.
All nodes: model input (model_3d), timeout prop in seconds (default
600, the ComfyUI node's default), and @prop metadata in the video-nodes
style. Outputs are inline refs ({ type, uri: "", asset_id: null, data: <base64> }) like RenderToImage, so downstream save nodes decide
persistence.
| Node | Op | Inputs beyond model |
Outputs |
|---|---|---|---|
nodetool.blender.RenderImage |
render_image |
camera_mode, camera and engine params (D4) |
image |
nodetool.blender.RenderPasses |
render_passes |
same, plus passes multi-select and depth_format |
color, depth, depth_near, depth_far, normal, mask (contracts in D4) |
nodetool.blender.RenderAnimation |
render_animation |
camera_mode, frame_start, frame_end, fps, orbit_degrees |
video |
nodetool.blender.PrepareForEngine |
prepare_for_engine |
target_faces, unwrap, bake (none | ao | normal | both), bake_resolution, lod_count |
model (GLB), lods (list of GLB) |
nodetool.blender.ExportModel |
export_model |
format (fbx | obj | usd) |
file (AssetRef) |
ExportModel does not return a Model3DRef. FBX, OBJ, and USD are not glTF
documents, and Model3DRef has no format field, so a socket typed
model_3d carrying an FBX would misread the moment it crossed a strict
boundary. The node persists the export through context.createAsset and
returns { type: "asset", uri: "asset://<id>", asset_id, metadata: { format, mime } }. GLB is not an ExportModel format because PrepareForEngine and
the existing FormatConverter already produce a Model3DRef for it. The
model_3d socket keeps meaning glTF everywhere.
Cloud profile: the nodetool.blender namespace stays out of the cloud
allowlist in packages/protocol/src/cloud-profile.ts, Stage 4 included. The
worker image is built outside this repository (A3), so a cloud server cannot
assume a Blender-enabled worker is reachable, and a namespace that is listed
but cannot run is worse than one that is absent. validate_workflow reports
the nodes as unavailable on a cloud server instead of failing at run time.
The namespace joins the allowlist when a Blender-enabled worker image ships
with the cloud deployment. A self-hosted install gets the worker tier now:
NODETOOL_NODE_PROFILE=full keeps the namespace, and NODETOOL_WORKER_URL
pointing at a worker whose status reports Blender selects
WorkerBlenderRunner (D7).
render_model3d joins packages/agents/src/capabilities/model3d.ts: input
model_id plus the render_image params, output { image_id, url, stats }.
It reuses runBlenderJob and stores the PNG through context.createAsset.
Permission category write (it creates an asset). Extending the existing
module keeps the 3D capabilities in one place for the eval surface.
Availability follows the yt_dlp / browser_* pattern exactly: the
cloud profile drops render_model3d from the offered belt
(availableBuiltinToolNames, via isBlenderEnabled in
packages/agents/src/blender-gate.ts, the same NODETOOL_NODE_PROFILE
switch D8 uses for the nodetool.blender namespace), and the
implementation refuses on its own when reached by name, so a guest that
imports the module still gets a deployment answer instead of a run failure.
The gate stays closed on the cloud profile until a Blender-enabled worker
image ships, for the reason D8 gives. A self-hosted install with
NODETOOL_NODE_PROFILE=full serves the capability, and with
NODETOOL_WORKER_URL it renders through the worker tier. A non-cloud server
with neither binary nor worker still serves the capability and fails with
the cause named ("this server has no Blender installed...").
| Setting | Where | Default |
|---|---|---|
BLENDER_PATH |
env, settings catalog (Stage 1) | unset |
NODETOOL_BLENDER_CONCURRENCY |
env, the render class cap |
1 |
NODETOOL_HOST_BINARY_CONCURRENCY |
env, existing, the default class | 2 |
NODETOOL_WORKER_URL, NODETOOL_WORKER_TOKEN |
env, existing | unset |
MAX_OUTPUT_BYTES, MAX_TOTAL_OUTPUT_BYTES, MAX_OUTPUT_COUNT |
constants in runner.ts, overridable per call |
512 MiB, 1 GiB, 32 |
No new database rows. No feature flag: a namespace that is absent from a profile and a node that throws a named error when Blender is missing are the gates.
RenderImage.process(context):
resolveModelBytes(this.model, context), shared withRenderToImage(moved tonodes-utilsso both packages import it). Empty bytes throw before Blender is touched.- Build
op = { op: "render_image", params }andoutputs = { image: "render.png" }. runBlenderJob(context, bytes, op, outputs, { timeoutMs, signal: context.signal, onProgress }), which resolves the runner and callsrunner.run(job, { model: bytes }, options).- Return
{ image: { type: "image", uri: "", asset_id: null, data: bytesToBase64(result.outputs.image) } }.
Failure paths:
- Blender absent:
HostBinaryMissingError("blender")from step 3 before any file is written. The message namesBLENDER_PATH. - Blender too old:
BlenderVersionErrorwith both versions. - glTF the importer rejects:
result.jsonwithimport_failedand the importer's message. The node rethrows it with the node name prefixed. - Timeout:
runHostBinarykills the child. The scratch directory is deleted. The error names the timeout and suggests lower samples or EEVEE. - Cancellation:
context.signalaborts, the runner kills the child, the node rejects with the abort reason. No partial output is returned. - Blender crashes (segfault, exit code outside 0 and 64) with no
result.json:BlenderJobError("bad_result")with the stderr tail. - An output over its cap:
output_too_largefrom thestatin D6 step 5, before any byte is read. The scratch directory is deleted. camera_mode: sceneon a glTF with no camera:no_camerafrom the op, before any render time is spent.
@nodetool-ai/runtimeexportsrunHostBinary,HostBinaryMissingError,MAX_CAPTURED_BYTES,MAX_ARTIFACT_BYTES,maxConcurrentHostBinaries.@nodetool-ai/agentskeeps re-exporting them.RunHostBinaryOptionsgainssignal?,env?,onStderrLine?, andconcurrencyClass?(D2). All optional, so existing callers are unchanged.BlenderRunner,BlenderRunOptions,BlenderRunResult, andrunBlenderJobin@nodetool-ai/blender-nodes(D6).- New node types listed in D8.
ExportModelreturns anAssetRef. - New capability
render_model3d(D9). - Stage 4:
blender.executerequest with{ job, inputs: blob keys, timeout }andblender.eventprogress frames inpackages/protocol/src/bridge-frames.ts,executeBlenderinpackages/runtime/src/blender-executor.ts, andworker.status.blender.enabledin the status schema. Shapes mirrorcomfy.executeandcomfy.event.
None. Model3DRef is unchanged and keeps meaning glTF. Rendered outputs are
ordinary image and video assets. An ExportModel output is an ordinary
stored asset with format and mime in its metadata. BlenderJob and
BlenderResult are files in a scratch directory that is deleted after the
run, never persisted.
Advantages: pip-installable into the existing Python venv or conda env,
so the Electron Package Manager could install it, and the Python worker
already has Python. Disadvantages: each bpy release pins one Python minor
(the current one requires 3.13 exactly), the wheel is a few hundred MB, and
the op script would run inside NodeTool's Python process instead of a child
the host runner can bound and kill. Rejected for now. The job contract in D4
is process-agnostic, so a bpy runner can be added as a third BlenderRunner
without changing a node.
Advantages: no new package, one render node. Disadvantages: the node's
props would fork on engine, video-nodes would take on the op script asset
directory, and the cloud profile could not exclude Blender without excluding
the preview renderer. Rejected.
Advantages: explicit worker_url and worker_token props, no hidden
selection. Disadvantages: every Blender node doubles, and a graph authored
on a desktop stops working on a worker without editing every node.
Rejected in favor of the createPythonBridge selector, which the repository
already uses for the same question.
Advantages: one 3D socket type, FBX and USD flow through the same edges as
GLB. Disadvantages: every consumer of model_3d assumes glTF, from the
editor to validate_model3d to RenderToImage, so the field would need a
check at each of them, and a missing format on old refs would have to mean
glb forever. Rejected until NodeTool decides Model3DRef is a general 3D
asset. An AssetRef with metadata.format carries the same information
without changing what an existing edge means.
Advantages: unbounded flexibility, the Blender community's scripts run as
is. Disadvantages: bpy reaches the filesystem, the network, and
subprocess. Confining it needs a sandbox this design does not have.
Deferred. If it ships, it goes in the Developer Tools group, off in the
cloud profile, and runs only on a local workspace.
- Two nodes render at once. The
renderclass semaphore queues the second. Each has its own scratch directory, so no file collides. An ffmpeg call in the default class runs alongside either. - The same asset rendered twice with the same params produces the same
bytes only when
samplesand the seed are fixed. Cycles is seeded fromscene.cycles.seed, which the op sets to a constant. Idempotency is by construction, not by caching. - A glTF with no mesh:
no_geometry, before any render time is spent. - A glTF with its own camera and lights under
camera_mode: auto: both used as is. Underorbit: the node's camera, the scene's lights. A test pins each of the three modes with and without a scene camera. - An animation whose MP4, or a
RenderPassesrun whose four images together, pass a cap:output_too_largefrom the post-runstat, before the bytes are read. Blender is not killed mid-write, because the caps are checked after exit, so a single runaway frame sequence is bounded by the timeout and the scratch disk, not by the cap. That is accepted for Stage 1 and noted in R6. result.jsonlists aproducedname the job did not declare, or a path appears anywhere in it: the name is ignored, the path is never read, and the run is logged at warn.BLENDER_PATHpoints at a file that is not Blender:--versionfails, the error quotes the path and the first stderr line.- Blender writes a
.crash.txtinto its temp directory ($TMPDIR) on a segfault, never next to the scratch files. It is included in thebad_resultmessage when present. - Process crash of the NodeTool server mid-render: the child is orphaned. Stage 1 accepts this. Stage 4's worker owns its process tree.
- Cloud workspace:
scratchDir()returns a real temp directory on the server, so the node runs withoutlocalDir. Nothing is written to the workspace itself.
The hot path is Blender itself. Startup is one to three seconds. An EEVEE still at 1024 px is a few seconds on CPU. Cycles at 128 samples is tens of seconds to minutes per frame. Node overhead is one GLB write, one PNG read, and JSON parsing, all negligible against that.
Concurrency is bounded by the render class cap,
NODETOOL_BLENDER_CONCURRENCY, separate from the ffmpeg and yt-dlp class
because a render holds a core for minutes. A render farm needs the worker
tier, not a higher local cap.
Output memory is bounded by maxTotalOutputBytes, and the check runs on file
sizes before any file is read, so the peak is one job's outputs, never an
unbounded record of buffers.
No caching in this design. When the same scene is rendered repeatedly with the same job, the win is real, but no evidence yet says it is a bottleneck, and the hash key (bytes plus job) is a small follow-up if it becomes one.
What the local runner is: hardening. What it is not: a sandbox. Blender runs as the NodeTool OS user with that user's filesystem and network reach, and nothing in this design changes that. The claims below are the ones the implementation can keep.
- Trust boundary: the glTF bytes and the props come from the workflow
author. The op script and Blender come from the install. The only paths
NodeTool passes to Blender are files it wrote into the scratch directory,
and the only paths it reads back are the ones the job declared (D4).
Neither stops
bpycode from opening other paths. Nothing in Stage 1 runsbpycode NodeTool did not ship. --disable-autoexecstops scripts embedded in a scene from running.--factory-startupand theBLENDER_USER_*redirects stop the user's add-ons and startup scripts from loading. A glTF carries no script, so the first of these matters only once.blendinputs exist.- The env allowlist keeps the secrets in
process.envout of the child. It does not stop outbound connections. The op script opens no socket, and the job carries no URL, but Blender itself is not network-isolated here. - Blender's argv is fixed in
LocalBlenderRunner: every param travels insidejob.json, which the op script reads withjson.load, so no param value can become a Blender flag and no flag-injection check exists here. Thepasseslist is filtered against known constants by the node. File names in the job passjobFileNameSchema, so..and separators never reachjob.json. - Untrusted Blender execution, meaning a
RunScriptnode or a.blendfrom someone else, needs the worker tier. The worker is a container NodeTool provisions and reaps, with a bearer token and blobs as the only file channel. That is the isolation boundary, and the cloud profile keeps the nodes off the shared server until it exists (D8). - Logs carry Blender's stderr tail. It can contain file names from the scratch directory, never user secrets.
- The kernel's
node.processspan wraps the run.runBlenderJobadds attributes:blender.version,blender.op,blender.engine,blender.runner(local|worker),blender.render_secondsfromstats,blender.exit_code, andblender.queued_mswhen the concurrency slot waited. node_progressper frame on animation renders.- One structured log line per run at info with the same fields, and at warn on non-zero exit with the stderr tail.
nodetool node run nodetool.blender.RenderImage --props ...is the diagnostic entry point.--no-secretsapplies because the node needs none.
- Stage 0 is a pure move plus additive options. It ships alone and can be reverted alone.
- Stages 1 to 3 add a package that no existing graph references. Absent Blender, the nodes throw a named error and nothing else changes.
- Stage 4 adds bridge frames with a new
type, ignored by older workers, and a status flag that defaults to false. It changes no profile: the cloud allowlist is untouched, so the cloud product is the same before and after. - Rollback at any stage is a revert. No migration, no persisted schema.
- Hard to reverse: the
BlenderJobversion 1 shape once graphs and worker images depend on it. Version it from day one (D4) and reject an unknown version inrun_job.pywithbad_job.
- T1 unit (
packages/runtime/tests/host-binaries.test.ts): abort throughsignalkills asleepchild,onStderrLinereceives lines, env allowlist excludes an injectedSECRETvariable. Invert each once. - T2 unit (
packages/blender-nodes/tests/job.test.ts): every node builds the expectedBlenderJobfrom its props,blenderResultSchemarejects a result with an unknown error code,jobFileNameSchemarejects../x.pngand/tmp/x.png. Against the fake Blender: aresult.jsonwhoseproducednames an undeclared output, or that carries a path, is ignored and the path is never opened (asserted with a sentinel file that must stay unread). A fake that writes one output overmaxOutputBytes, and one that writes four outputs whose sum passesmaxTotalOutputBytes, each fail withoutput_too_largebefore any read, with the scratch directory gone. Invert each once. - T2b unit (
camera-mode.test.ts): the threecamera_modevalues against a fixture with a camera and one without, asserting which camera the job selects and thatscenewithout a camera isno_camera. - T3 unit, two sides (
framing.test.tsandblender_ops/tests/test_framing.py): the same fixture bounds and camera params produce the same camera position to four decimals. The Python test runs under Blender's own interpreter in CI when Blender is present and is skipped otherwise. - T4 integration (
packages/blender-nodes/tests/render-image.test.ts): render a fixture GLB and assert the PNG decodes, has the requested size, and is not uniform. Skipped without Blender, the waymodel3d-render.test.tsskips without Chrome. CI installs Blender on the Linux leg so the test runs there. - T5 failure paths: a fake
blenderscript on PATH that exits 64 withoutresult.json, one that writesok: false, one that never exits (timeout at 1 s), and a job aborted mid-run. Each asserts the error class and that the scratch directory is gone. - T6 concurrency: two fake renders with
NODETOOL_BLENDER_CONCURRENCY=1assert the second starts only after the first finishes, and a fake ffmpeg call in the default class starts while a render holds therenderslot. - T6b runner seam: a
FakeBlenderRunnerimplementingBlenderRunnerrecords thejobandinputsit receives, and every node test runs against it. This is what proves a node never reaches past the interface. - T7 regression:
RenderToImagetests pass unchanged afterresolveModelBytesmoves tonodes-utils. - T8 capability:
packages/agents/tests/capabilities-model3d.test.tsgainsrender_model3dagainst the fake Blender, andcapabilities:checkpasses. - T9 harness: the
blendersurface inregistry.tsnames the suites above, with aselfcheckrunningnode run nodetool.blender.RenderImageon a fixture, costexpensive, soharness gateruns it on diffs touchingpackages/blender-nodes/. - T10 worker stage: bridge frame round-trip tests in
packages/protocolmirroring thecomfy.executeones, and a fake worker inpackages/runtime/teststhat answersblender.executewith blobs.
- Stage 0, runner move. Move
host-binaries.tsto runtime, addsignal,env,onStderrLine,concurrencyClass. Update the agents import. Tests: T1, T6. - Stage 1,
RenderImagelocal tier. New package,blender-binary.ts,job.ts,runner.tswithBlenderRunnerandLocalBlenderRunner,run-job.ts,run_job.pywithrender_imageandframing.py, asset dir registration,base-nodesregistration,resolveModelBytesmove, registry surface, CI Blender install, theBLENDER_PATHsettings catalog entry, and a Blender line instart.sh doctor. Tests: T2, T2b, T3, T4, T5, T6b, T7, T9. - Stage 2,
RenderPassesandRenderAnimation. Compositor node tree for passes with the D4 contracts, FFMPEG output,Fra:progress. Tests: T2 additions, T4 for each output including a depth image whose near and far match a known fixture, a progress test against the fake Blender. - Stage 3,
PrepareForEngine,ExportModel, andrender_model3d. Tests: T2 additions, T4 asserting exported FBX magic bytes, theAssetRefmetadata, and GLB validity throughvalidateModel3D, T8. - Stage 4, worker tier. Bridge frames,
blender-executor.ts,WorkerBlenderRunner, status flag. The cloud allowlist stays as it is (D8). Tests: T10, plus T6b re-run with the worker runner behind the fake worker. The worker image is a separate deliverable outside this repository, and the cloud allowlist entry waits for it.
- Q1. Does the explainer use case need scene-side camera and text authoring
(U1) in the same release? If yes,
packages/model3dgrows acameraandtextprimitive first, andRenderAnimationundercamera_mode: autopicks them up with no further change. - Q2. Resolved.
render_model3dsupportsbackground: truethrough the existing generation ledger andMAX_BACKGROUND_GENERATIONScap; agents useawait_generationfor completion. The fivenodetool.blender.*nodes stay synchronous because their downstream outputs remain media values and the kernel already runs independent nodes concurrently.
- R1. Blender availability. Highest. Desktop users must install it, and the
cloud server has none until a Blender-enabled worker image ships.
Mitigation: named errors, the
BLENDER_PATHsetting, and the profile exclusion, so no user meets a node that fails silently. - R2. Renderer parity drift. The preview node and the Blender node share camera params but not a renderer, so the same numbers frame differently. Mitigation: T3 pins the framing math on both sides.
- R3. Render time versus timeouts. Mitigation: EEVEE and low samples as
defaults, the
timeoutprop, progress messages, and Q2 for the long tail. - R4. Code execution in Blender. The local runner is not a sandbox.
Mitigation: no user Python in scope,
--disable-autoexec,--factory-startup, the env allowlist, and the rule that untrusted execution waits for the worker tier. - R5. Job contract lock-in once worker images exist. Mitigation: the
version field, a
bad_jobrejection path, and a runner interface that carries no paths, so Stage 4 adds an implementation instead of rewritingrunBlenderJob. - R6. Disk use during a run. The output caps are checked after Blender
exits, so a runaway animation can fill the scratch disk up to the timeout.
Mitigation: the timeout, and a follow-up that watches the scratch
directory's total size while the child runs, which
runHostBinary's watchdog can be extended to do.