A Blender → After Effects pass pipeline built for machines that don't have
enough RAM. Blender renders separated passes headlessly and exits; After
Effects then rebuilds the shot from a generated .jsx. The two applications
never hold memory at the same time.
New here? Start with USAGE.md — how to install it and get a shot from Blender into After Effects, start to finish.
The rest of this file is engineering notes: what was built, and where reality turned out to differ from the spec. Full design and rationale: SPEC.md.
M6 — Shot templates. Build a shot from one of five templates, check what it will cost before committing to it, degrade it automatically if it will not fit, configure the EEVEE passes, render in a separate background Blender — optionally quitting the UI as it launches, which is the whole point of the project — then run one script in After Effects to get the whole shot back. Passes land in the layout SPEC.md §7.3 specifies:
<output_root>/<shot_name>/
├── beauty/ beauty_0001.exr ...
├── emission/ emission_0001.exr ...
├── mist/ mist_0001.exr ...
├── normal/ normal_0001.exr ...
└── crypto/ crypto_0001.exr ...
The .jsx is written to <output_root>/<shot_name>/<shot_name>.jsx. Running it
builds a comp matching the Blender scene, imports every pass as its own
sequence, stacks them with beauty visible at the bottom and the rest as disabled
guide layers, and adds the camera plus a null for every Blender empty.
The headless render peaks at 484 MB on the fixture scene, against the 6 GB
budget M3 sets. Progress and cancellation are driven from render.log in the
shot folder, which outlives the Blender UI. Remaining milestones are in
SPEC.md §8.
blender_addon/passthrough/ the extension (zip root must match the manifest id)
tests/ pytest suite, runs without Blender
Requires system Python 3.11+ (separate from Blender's bundled interpreter).
python -m venv .venv
.venv\Scripts\python -m pip install pytest ruff
.venv\Scripts\python -m pytest
Five scenes, each built from a JSON schema with one button press and no node editing:
Liminal corridor · volumetric light room · camera move rig · 3D text in space · debris field.
python tools/build_extension.pyProduces dist/passthrough-<version>.zip and verifies it: the build command only
validates the manifest, so the ExtendScript runtime and the template schemas —
which are data, not code — can go missing without anything complaining until a
user presses a button and gets nothing.
To test on a Blender older than the manifest allows:
python tools/build_extension.py --dev-version 5.1.0That writes a separate, suffixed zip and never touches the shipped manifest.
Blender 5.2+ is required (see the note in SPEC.md §5).
Build a zip with blender --command extension build --source-dir blender_addon/passthrough, then install it from
Edit ▸ Preferences ▸ Add-ons ▸ Install from Disk. The "Passthrough" panel
appears in Render Properties.
For development, point Blender at the source instead: add blender_addon as a
local extension repository in Preferences ▸ Get Extensions ▸ Repositories.
Two things verified against a real Blender install that differ from SPEC.md:
- §7.5 zip layout. The spec says the distributed zip must contain a folder
named after the
id. That is the rule for legacybl_infoadd-ons. Blender's own--command extension buildemits a flat zip withblender_manifest.tomlat the archive root, and creates thepassthrough/directory at install time from the manifestid. Verified on 5.1.2: installs, enables, and registers. Do not hand-repack the zip into a folder. - §6 runtime location. The layout puts
pt_runtime.jsxinae_scripts/at the repo root. Nothing outsideblender_addon/passthrough/ends up in the built zip, so the runtime would not ship. It lives atblender_addon/passthrough/ae_runtime/pt_runtime.jsxinstead, and a test asserts the built zip contains it. - §5 target version.
blender_version_minis5.2.0per the spec. On an older Blender the zip still installs — files are copied and the extension shows up in the add-on list — but it will not enable. The console reportsThis Blender version (5.1.2) doesn't meet the minimum supported version (5.2.0), nothing registers, and no panel appears. The failure looks like a broken add-on rather than a version gate, so check the console first. The development machine currently has Blender 5.1.2.
SPEC.md §7.2 says to port Blender's io_export_after_effects rather than derive
the maths. That add-on is no longer bundled with Blender (it moved to
extensions.blender.org), so the reference was read from a source mirror. Porting
it surfaced three things:
- The camera correction is −90° on X, not 180°. §7.2 describes a "180 degree reconciliation". The reference subtracts 90 from X, because an AE layer stands upright while a Blender object lies in the XY plane.
- After Effects composes orientation as
Rx @ Ry @ Rz. Determined empirically by projecting known points: of the six possible orders, only this one reproduces Blender's projection, and it does so to ~1e-4 px. The others are wrong by 519–1626 px. sensor_fit='AUTO'must fit the larger image dimension. The reference treats AUTO as horizontal. At this project's 1080×1920 target the sensor spans the height, so zoom is50 × 1920 / 36 = 2666.67, not1500. Following the reference would have given a 78% field-of-view error on every portrait shot.
Two further things this implementation adds:
- Orientation tracks are unwrapped.
atan2wraps at ±180°, and After Effects interpolates that literally — a camera would spin 358° between two frames. Tracks are shifted by whole turns to stay continuous. mathutilsis never imported.camera_convert.pydoes its own matrix and Euler arithmetic on plain tuples, because §6 requires it to be testable without Blender. The port is checked element-wise againstmathutils.Matrix.to_euler('ZYX')in the integration tests.
tools/build_extension.pyverifies what it built.blender --command extension buildvalidates the manifest and nothing else, so a dropped data file is silent. The tool checks every module, the runtime.jsxand all five schemas are in the archive, and that the archive is flat rather than nested.- The build tool used to overwrite its own output. Blender names the zip from the manifest, so a dev build written into the same directory silently replaced the release zip — leaving a "release" archive that declared the dev minimum version. Builds now go to a scratch directory and are moved into place under distinct names.
tests/test_install_integration.pyis the only test that proves the package. Every other integration test putsblender_addononsys.path, which papers over anything the build forgot. That one installs the zip into a throwaway Blender configuration and drives the whole workflow throughbpy.opswith the repository unreachable.
Five templates — liminal corridor, volumetric light room, camera move rig, 3D
text in space, debris field — each built procedurally from a JSON schema in
blender_addon/passthrough/templates/.
- There are no
.blendfiles. §6's layout lists them; a checked-in.blendcannot be reviewed in a diff, has to be rebuilt by hand for every change, and is tied to the Blender version that wrote it. The schemas are the source of truth: the panel's properties are generated from them at registration, so a new parameter means editing one JSON file rather than three Python ones. - Looking at the output caught what the tests did not. Every template passed its "is the frame black" checks while the light room was rendering a single flat grey wall and the text camera was sitting inside a letter. The tests now check the standard deviation of the beauty pass, which is what actually separates a shot from a blank surface, and that the scene contains nothing outside the template collection — the startup cube used to appear in all five.
- Camera distance for the text template is derived from the text's bounding box, so any wording frames correctly. Mist range follows it, because a fixed range reads as solid white once the camera is far enough back.
use_volumetric_shadowsis what turns a spot in fog into a visible shaft. Without it the light room is just a glow.
The memory model's coefficients are measured, not guessed. Seventeen scenes were
built as .blend files, rendered headlessly through render_job.py, and their
peak working set sampled from the parent process:
| term | value |
|---|---|
| Blender headless, empty scene | 480 MB |
| render buffers | 175 bytes per output pixel |
| evaluated geometry | 135 bytes per triangle |
| textures | 1.45 × the raw image bytes |
A least-squares fit lands within 5.7% on every calibration scene. The shipped constants are that fit rounded upward: worst case 16.1%, but it under-estimates only once in seventeen. Under-estimating is the dangerous direction — it tells you a scene fits when it does not.
Three things that surfaced while building this:
- Construction has to be measured separately from rendering. Building a
2M-triangle grid with a Blender operator spikes memory in ways that loading
the finished
.blendnever does. A first calibration that built and rendered in one process put the geometry term three times too high and missed the ±25% target by 37%. - EEVEE has no Simplify texture limit. §M5 says to "cap texture size via
Simplify", but
texture_limit_renderis a Cycles property andscene.renderexposes no texture options at all. The auto-fix scales the image datablocks instead. image.scale()does not survive a.blendround trip. The image's source is stillFILE, so a fresh Blender re-reads the full-size file and the saving silently evaporates — and only in the headless path, which renders from a saved snapshot. The scaled buffer has to be packed into the file. Reversible withunpack()thenreload().
- Everything is parented to one identity null,
PT World. The camera and the nulls all carry world-space baked transforms, so mirroring Blender's object hierarchy with AE parenting would apply each parent's transform twice. A single null whose transform is the identity gives you one handle on the whole 3D scene without disturbing anything. It is only the identity whenpositionequalsanchorPoint, andaddNull()sets neither to zero — both are zeroed explicitly, and the camera's alignment depends on it. - Nulls are created from Blender empties, and only empties. One predictable rule beats an option nobody asked for; to get a null on a mesh, parent an empty to it.
- Imported sequences are conformed to the comp frame rate. An image sequence otherwise takes its rate from the user's AE import preferences, so a 24 fps comp can silently end up holding 30 fps footage and every pass drifts out of sync with the camera.
- Alpha is interpreted as premultiplied over black, which is what Blender
writes. The generated script exposes this as
PT_ALPHA_PREMULTIPLIEDso it is a one-word edit if a pass shows dark fringing. - Lights are deliberately not exported. AE lights only affect 3D layers that accept them, and every pass here is a flat 2D footage layer, so they would be inert — and mapping Blender's watts onto AE's intensity percentage is a guess. §1 lists them; M4's acceptance criteria do not.
- Blender 5.1 does not emit the progress lines §7.6 describes. There is no
Fra:12 Mem:412.35Manywhere in a background render's output — that is older-Blender/Cycles formatting. What Blender 5.1 prints is00:02.125 render | Saved: '<path>', one line per written image. Sorender_job.pyemits its ownPT_FRAME n/totalmarkers, andqueue.pyparses all three forms. - Frames are rendered one at a time with
write_still=False, not as an animation. Blender still substitutes the####token in the File Output nodes, but writes no main render output — so the shot folder contains exactly the §7.3 tree and nothing else. - The child's output goes to a log file, not a pipe. A pipe has to be drained by a reader thread or it fills and stalls the render, and it dies with the parent. A log file is non-blocking to poll and survives Blender closing, which is what M3 is for.
view_transformreports onlyNONEin its enum in background mode while still accepting real values, sorender_job.pyassignsRawrather than validating against the enum first.- Peak memory is measured through
GetProcessMemoryInfovia ctypes, so the §9 memory regression check needs no third-party dependency.
The compositor changed enough in Blender 5 that most tutorials and older add-on code are wrong. All of the following was established by introspecting Blender 5.1.2 and by reading the bytes of the EXRs it wrote, not from documentation.
- The scene's tree is
scene.compositing_node_group, a node group datablock.scene.node_treeno longer exists, andscene.use_nodesis deprecated for removal in 6.0. CompositorNodeCompositehas been removed.- One File Output node writes exactly one file. Its
file_output_itemsbecome layers inside that file, and the node-level format enum accepts onlyOPEN_EXR_MULTILAYER. So one sequence per pass means one node per pass. base_pathandfile_slotsare nowdirectoryandfile_output_items.color_depthandexr_codecare read from the node format, never from an item's — even when the item overrides the file format. Setting them on the item is accepted, reported back correctly, and silently ignored at write time, producing 32-bit uncompressed files.- A named File Output item becomes an EXR layer prefix. An item called
beautywrites channelsbeauty.R,beauty.Gand so on. After Effects reads the unprefixed names, so the item must be left unnamed to get plainR/G/B/A. - The cryptomatte pass writes lowercase
r/g/b/achannels while every other pass writes uppercase. It is also kept at 32-bit, because half float would round away the object-id hashes.
GPL-3.0-or-later.


