Skip to content

The live video-out: ritmolux --stream

Every other instrument on this page writes a file. This one writes a live video stream into another program, with no window on our side, no codec anywhere, and a latency of a frame or two (ADR-0125).

There are two sinks, and --sink picks between them.

Terminal window
# Spout, the default. Open a Syphon Spout In TOP in TouchDesigner and set its
# Sender Name to `ritmolux`.
ritmolux --stream --size 1280x720 --fps 60
# A pipe. Raw RGBA8 frames on stdout, for a parent process that spawned this.
ritmolux --stream --sink stdout --events | your-program

--sink spout: another application on the same machine

TouchDesigner, Resolume, OBS — anything that receives Spout. It exists only in a build with the spout feature. The shipped release ritmolux.exe has it; a plain cargo build does not, and --sink spout there fails with a named error rather than starting and publishing nowhere. To build it yourself you need the SDK staged first — it is third-party, pinned by hash and never committed:

Terminal window
powershell -File packaging/spout/fetch-sdk.ps1
cargo run -p standalone --bin ritmolux --features spout --release -- --stream

--sink stdout: raw frames on a pipe

Every platform, no feature, nothing installed. The frames go to standard output as tight width x height x 4 bytes of RGBA8, in order, with nothing between them — no header, no length prefix, no padding. A reader cuts the stream into frames by multiplying the geometry, which it learns from the stream event on standard error before the first byte arrives:

{"v":1,"ev":"stream","width":640,"height":360,"fps":30,"format":"rgba8"}

That event needs --events, which is what turns the structured report on at all (Configuration). Without it the frames still flow and the reader has to know the geometry some other way — which is why the studio always passes both.

format is rgba8 here because a headless run renders into an offscreen this engine chooses, and it is read off that texture rather than asserted. The windowed mirror below is the path where it can be the other value, so a reader that hard-codes one order works on this sink and paints red and blue swapped on that one.

The default is 640x360 at 30 fps, not the Spout path’s 1280x720 at 60: this sink exists to feed a preview canvas, and asking the engine for four times the pixels to shrink them into a panel costs the readback and the pipe for a picture nobody sees at that size. --size and --fps override it in either direction.

The writer blocks; it never drops. A reader that stops reading fills the pipe and stalls this process, and the deadline pacing absorbs that: frame n stays due at n * period from the start of the run, so a stalled second costs the frames that fell inside it and the run resumes at the frame index the wall clock has reached rather than drifting behind it. That is the right policy for a loop with no present deadline; a windowed preview has one and drops instead.

A headless run is a whole show, and it is drivable. It resolves and seeds the same per-user preset directory a window does, watches it, and hot-reloads a file you save into it; and it reports the same events — roster on every reload, preset when the picture changes, preset_error and preset_warning with the file and the line, health once a second. --control binds a listener here exactly as it does for a window, and hello carries the port it actually got, so a parent can move a parameter on the next frame. The window and the sink are what differ between the two modes; nothing in this paragraph does (ADR-0183).

On a machine with no per-user data directory at all — a bare CI runner — the run says so in one line and carries on with the presets built into the binary, which is the same degrade every other reader of that directory performs.

Standard output carries nothing else while this sink is open — every human-readable line goes to standard error, and a test holds the whole crate to that.

--preview stdout: the windowed show, mirrored

The sink above is a headless run: no window, and the pipe is the only picture there is. --preview stdout is the other half — a windowed run keeps its show on the projector and sends a copy of it up the same kind of pipe, announced by the same stream event. It is how a studio watches the picture the audience is actually seeing.

The two pipes are not the same thing, and the difference is the point.

--stream --sink stdout--preview stdout
the showheadless, no windowa window, unchanged
the framesexact, at --sizea scaled, letterboxed copy
the geometrythe size you asked forfixed at 640x360, or @WIDTHxHEIGHT
formatalways rgba8rgba8 or bgra8 — read it
a slow readerblocks the rundrops frames, the show carries on

Read format. The mirror carries whatever the swapchain negotiated, which on a DX12 backend is commonly BGRA, and the stream event names the order the bytes are actually in. A reader that assumes RGBA paints the show’s oranges blue and its blues orange — a picture that looks like a colour-grading mistake rather than a decoding one, which is why the player refuses to open the pipe at all rather than guess a name for a format it cannot describe.

--sink stdout is an exact feed for ffmpeg, so its frames are the size named on the command line and nothing scales them. --preview is a mirror for a canvas, so it is fixed-size on purpose: the show’s window may be dragged, maximized or thrown fullscreen mid-run, and the mirror’s geometry does not move with it. A reader is told the size once, before the first byte, and never again — which is the only arrangement a headerless byte stream can survive, since bytes already in the pipe carry no mark saying where a new size began.

The show’s aspect is kept: a window of a shape the mirror is not gets bars rather than a squash. What that costs is pixel-scale judgement — a one-texel seam or the exact width of a contour is not a thing to read off a scaled copy. Look at the show window for those.

Terminal window
ritmolux --events --preview stdout # 640x360
ritmolux --events --preview stdout@960x540 # something else

The TouchDesigner side

The operator is called Syphon Spout In, not “Spout In” — Derivative ships the two app-to-app transports as one TOP, Spout on Windows and Syphon on macOS, so that is the name in the OP Create Dialog. Set its Sender Name to whatever the mode prints, which is not always what you asked for:

publishing 1280x720 at 60 fps as Spout sender 'Ritmolux'

SetSenderName increments on collision — a run that was force-killed leaves its registration behind, and the next one comes up as rlx_1, then rlx_2. The mode prints the name it actually got for exactly this reason. A TOP pointed at a name nobody is publishing reports No Active Sender Found.

No colour setting is needed on either side. The engine reads back display-referred sRGB bytes and Spout publishes them untouched (SetSenderFormat(DXGI_FORMAT_R8G8B8A8_UNORM) matches the readback, so nothing swaps red and blue and nothing re-encodes). A receiving TOP put beside a Movie File In TOP of the same frame as a PNG is indistinguishable from it. If your picture looks washed out or crushed, something in your network is re-interpreting it, not the sender.

Which GPU, and why it is not a preference

On a machine with one GPU, skip this. On a hybrid laptop it is the difference between a picture and nothing at all.

A Spout sender shares a D3D11 texture by handle, and the receiver opens that handle on its own device — which succeeds only when both devices are the same physical GPU. Windows hands a plain console process the integrated GPU to save power while TouchDesigner runs on the discrete one, and the receiver then reports only Unable to open shared Spout Texture, naming neither adapter nor the mismatch.

Terminal window
ritmolux --list-adapters # both rosters, with their own indices
ritmolux --stream --gpu "RTX 3080" # one name moves the renderer AND the sender

--list-adapters prints two lists because there are two enumerations, and they are not assumed to agree on order — the renderer selects through wgpu, the sender through the Spout SDK, and on a machine with a software rasterizer installed the two lists are not even the same length. --gpu takes a name (a substring is enough, case-insensitively) or an index, and each side resolves it against its own roster; an ambiguous name is an error listing what it matched, never an arbitrary pick.

Unset, the renderer asks for the high-performance adapter and the sender follows it by name. Both resolved choices are printed at startup, always:

renderer : NVIDIA GeForce RTX 3080 Laptop GPU (Dx12, DiscreteGpu), driver 32.0.15.8142
sender : adapter [1] NVIDIA GeForce RTX 3080 Laptop GPU

Presets, and stopping

Presets rotate on the operator config’s [rotate] dwell timer exactly as they do in the window — and rotation is on here even when auto is off, because a headless source has nobody to press Space and a four-hour set on one scene is not what this mode is for. --preset <name> holds one scene and turns rotation off. Rotations are announced:

rotate : frame 5400, AutoTimer -> 'Clifford Gallery'

Ctrl-C stops the run through its own exit path, which is what makes it print the three numbers a measurement needs — frames emitted, wall clock, scene clock. --frames N bounds a run so it terminates and reports on its own.

What it costs, and what those numbers mean

Every 30 s and at exit, the mode reports its per-stage cost and its resident set:

stream: render+readback 7.79 ms, spout send 0.79 ms, mean over 1800 frames
render: resident set 277 MB, growth +0.2 MB across 5400 frames ...
stream: 36000 frames, 600.00 s wall, 599.99 s scene clock, on NVIDIA GeForce RTX 3080 ...

Two stages, not three. render+readback is the engine drawing the frame and pulling it back to the CPU: the readback blocks, so no CPU-visible instant separates them and splitting them would need GPU timestamp queries. The second stage is the sink’s own, and it is named for the sinkspout send is the upload into the sender’s device, pipe write is the blocking write to standard output — so a figure copied out of a log says which one produced it. The split answers the question that matters, whether the sink is what limits the rate, and on the development machine it is not, by an order of magnitude.

Measured, on one machine, once (RTX 3080 Laptop, 1280x720 at 60 fps, one preset held, nothing else on the GPU): a 30-minute run emitted 108,000 frames in 1800.00 s wall against 1799.99 s scene, at 3.67-7.82 ms of render+readback and 0.27-0.58 ms of Spout send per frame, with the resident set at a 280 MB peak growing 2.0 MB across the whole run. That is a reading from one box and one driver, not a specification — a machine that cannot hold the rate reports it the way the next paragraph describes.

Wall clock against scene clock is the honest frame-rate reading. They track each other because dt is measured per frame rather than assumed, so a run that cannot hold the requested rate renders in correct real time and simply delivers fewer frames: the animation is never slow, the frame count is low. Compare the frames emitted against fps x wall to see whether the rate was held.

What it does not do

  • --sink spout is Windows only. Spout has no macOS form; the analogue there is Syphon, a different SDK against a Metal/IOSurface seam. --sink stdout runs wherever the player does.
  • No audio, on either sink. Both are video transports. A receiver takes audio from its own source.
  • Spout is same-machine only. It shares GPU memory between processes on one box; there is nothing to send over a network. The pipe reaches whatever spawned the player and no further — a remote sink would be --render’s ffmpeg pipe pointed at SRT or RTSP, which is not built.
  • No golden covers the picture. The mode is wall-clock paced, so its output is not reproducible and no baseline can assert on it. What is asserted, from outside the process, is the pipe’s shape: a bounded run puts exactly one frame’s bytes on stdout per frame, the geometry is announced before them, and a reader stalled for a second loses none. Whether the picture is right is still a byte-identity claim against a deterministic capture, or a human looking at a receiver.

Built from a8ce055 at version 0.115.0. This site tracks main and is not versioned per release.