Export: Stills, Sequences, and a Real Output Resolution

Here’s a question that kept breaking me: what resolution does Lux render at?

Until this session, the answer was “whatever size the window happens to be.” Resize the editor and the output resolution silently changed with it. Open a patch on a different monitor and it looked different. Try to export a frame and you’d get whatever window size you had at that moment, which meant screenshots were different sizes on different days.

That’s not a tool. That’s a toy.

Project output resolution

Lux now has a concept of a project output resolution, separate from whatever the window is currently doing. The OutputConfig gained two fields, output_width and output_height, and nine presets:

  • Window Size: follows the window (the old behaviour, kept as the default for backwards compatibility)
  • 720p, 1080p, 1440p, 4K: the obvious ones
  • Square 1K, Square 2K: for installations and social
  • Instagram. 1080×1350, the portrait format nothing else calls by that name
  • Portrait. 1080×1920, vertical video

You pick a preset (or type custom width and height into the two DragValues below the dropdown) and the graph renders at that resolution regardless of window size. The editor canvas letterboxes the output: black bars on the sides or top/bottom to preserve aspect ratio, and the separate output window does the same. Resize the editor and the display rescales, but the underlying pixels stay at the project resolution.

This is the piece that makes everything else downstream consistent. Export is meaningless without a fixed canvas. Sharing projects is meaningless if “the output” means a different shape on different machines. Nodes that depend on iResolution need a stable answer. Everything benefits.

The resolution is saved and restored in .lux files. Open a project and it comes up at the same size it was when you saved it.

Letterbox, not stretch

The letterbox blit preserves aspect ratio. Always. A 1080×1920 portrait project on a widescreen monitor gets centred with black bars on the sides. A 16:9 project in a tall window gets bars on top and bottom. Nothing ever stretches.

This is the correct default for creative work, where the composition was designed against specific proportions and distorting it would be actively wrong. If you want to fill an arbitrary surface, you do that as part of the patch, not by accident because the window happens to be the wrong shape.

Ctrl+E: the screenshot

Press Ctrl+E, a file dialog opens, pick a name and PNG or JPEG. Lux renders the current output layer at the project resolution and writes it to disk. Format is determined by the file extension.

The rendering path is a separate render_frame_to_pixels() helper that runs the Vello pipeline at an explicit resolution, reads back the result as RGBA8 bytes, and hands them to the image crate for encoding. It’s the same pipeline the display uses, just targeting an offscreen texture sized to the project output instead of the window surface.

The file dialog remembers your last directory, which is the kind of detail that sounds trivial until you use a tool that doesn’t do it.

Ctrl+Shift+E: the sequence

Same shortcut with Shift adds an interesting dimension: time. Ctrl+Shift+E opens a folder picker, and Lux exports 150 frames (5 seconds at 30fps) as numbered PNGs. frame_0000.png, frame_0001.png, and so on.

Between each frame the graph gets evaluated at a synthetic time step. The Time node, oscillators, timers, and every other time-driven node see this synthetic time and produce values as if the animation were running in real-time, except the real-time clock is disconnected, so the export runs as fast as the GPU can render. A 5-second sequence doesn’t take 5 seconds to export; it takes however long 150 evaluate-and-render cycles take on your hardware, which is usually a couple of seconds.

This is the bridge from Lux-as-interactive-tool to Lux-as-rendering-engine. Put together a patch, hit Ctrl+Shift+E, get a folder full of PNGs, feed them to FFmpeg, and you’ve got a video file. Not ideal; ideal is a direct video export, and that’s coming, but it’s real export, today, with a well-defined output resolution and frame-accurate time stepping.

The ExportImage node

For programmatic exports, there’s a new ExportImage node:

  • Inputs: texture (TextureHandle), path (String), trigger (Bang), format (Enum: PNG/JPEG), quality (Number, JPEG only)
  • Outputs: (none, it’s a sink)

Wire a texture into it, wire a path string, and wire a trigger pin to something that bangs, a Metro, a keyboard event, a comparison going true. On the rising edge of the trigger, the engine queues a TextureOp::ExportImage that reads back the texture and writes it to disk.

Edge detection is important here. Without it, a sustained bang would write the same file every frame for as long as the bang stayed true, which is exactly the kind of bug that fills a disk while you’re debugging something else. The node only fires on the transition from not-triggered to triggered.

Combined with a Counter driving the output path, you can build a patch that dumps a numbered frame on every beat of a Metro. Or capture one frame per state transition in a StateMachine. Or snapshot on a mouse click. Anything a bang can drive, ExportImage can sink to a file.

The unpremultiply bug

One gotcha I hit while building this: the texture export path was producing images where anything with alpha looked wrong. Colours were too dark, edges had dark halos, transparent areas were muddy.

The cause was the pipeline’s colour format. Lux’s textures are stored premultiplied, the RGB channels have already been multiplied by the alpha. This is the right choice for GPU blending (saves a multiply per pixel on every composite) but the wrong choice for file export. PNG expects straight (non-premultiplied) colour, and feeding it premultiplied RGBA gives you an image that looks correct only against a black background.

The fix is a single shader pass during the readback that divides the RGB channels by alpha, with a safe-guard for alpha near zero. Unpremultiply on the way out, keep premultiplied internally. Every exported image now looks the same as the on-screen preview.

Where export lives now

Three ways to get frames out of Lux: the keyboard shortcut for quick captures, the shortcut-plus-shift for a sequence, and the node for scripted or trigger-driven exports. All three respect the project output resolution. All three go through the same render_frame_to_pixels() helper and the same PNG/JPEG encoder.

Output is no longer tied to the window. The window is just one view of the project; the project has its own canvas, its own size, and its own way to leave the app.

Next time, the canvas itself gets cleaner.

← Back to devlog