VidViz #webcodecs #streaming #ffmpeg

Getting pixels out: WebCodecs export, NDI, and a Rust LAN caster

A visualiser that can't leave the preview panel is a screensaver. This week VidViz finished growing its full set of exits: file export rewritten on WebCodecs, WebRTC streaming to any browser on the network, NDI for the pro-video crowd, and — the fun one — a Rust LAN caster with hardware HEVC and its own little receiver app. This post is the tour, including the corpses.

v1: FFmpeg over a WebSocket, and the classic deadlock

The first export pipeline (week one) was honest plumbing: render each frame to an offscreen canvas at time = frame / fps, read the RGBA pixels, stream raw buffers over a WebSocket to the Python backend, which pipes them into FFmpeg's stdin. Flow control by acking every tenth frame. It worked, then it hung on longer exports, and the cause is one every subprocess user eventually meets: FFmpeg chats progress onto stderr, the code only read stderr after proc.wait(), the OS pipe buffer filled, FFmpeg blocked mid-write, and everyone waited for everyone forever. The fix is a daemon thread draining stderr from the moment of spawn. If you take one line from this post: a pipe you don't drain is a deadlock you've scheduled.

v2: WebCodecs, and export as a pure function

This week's rewrite removes the WebSocket, the Python hop, and FFmpeg from the encode path entirely. The renderer encodes with the browser's VideoEncoder (hardware-accelerated H.264 or H.265, level chosen automatically from resolution and framerate), muxes MP4/WebM in the renderer with mp4-muxer/webm-muxer, and streams chunks to disk through small IPC file-stream handlers. Backpressure is the encoder's own queue depth. Audio still uses FFmpeg for one job it's unbeatable at — decoding the source track to raw PCM — muxed in parallel with the video encode.

The part I care most about isn't speed, it's determinism. Export doesn't capture the live preview; it re-executes the graph frame by frame with exporting: true and the seeded RNG reset per frame from a fixed seed. Same project, same seed, same bytes of visual content — render farms and A/B comparisons become meaningful, and "the export looks different from the preview" is a bug class that structurally can't exist. The quality ladder runs from draft (x264 CRF 28) through ultra and master (x265 CRF 5 and 1) to lossless, plus ProRes and VP9 for people with workflows; resolutions to 4K, framerates to 120.

Streaming: three audiences, three transports

WebRTC, for "just look at this". Electron's main process runs a tiny HTTP+WebSocket server; any browser on the LAN opens the viewer page, signaling happens over the socket, and the canvas streams via captureStream. One gotcha worth recording: with preserveDrawingBuffer: false, capturing the WebGL canvas directly yields blank frames — so a proxy 2D canvas drawImages the WebGL output each frame and it gets captured. Wasteful, reliable, shipped.

NDI, for people with video mixers. Rather than a native addon build, the main process talks to libndi.dylib directly through FFI. The bug of the week here: sending frames as RGBA FourCC corrupted downstream compositing, because our alpha channel is full of compositing residue that NDI receivers dutifully honoured. Switching the FourCC to RGBX — same bytes, "ignore the fourth one" — fixed every downstream mixer at once. Sometimes the fix is telling the truth about your data.

LAN Cast, for a projector across the room. This one went native: a Rust child process reads RGBA frames from a shared-memory file the renderer mmaps (an 8-byte frame counter as the world's smallest synchronisation protocol), encodes with VideoToolbox hardware HEVC, and ships RTP, advertising itself over mDNS as _vidviz-cast._tcp. On the other end sits vidviz-view, a separate minimal Electron app wrapping the matching Rust receiver: browse mDNS, connect, VideoToolbox decode, frames to a canvas. Laptop renders, random Mac by the projector displays, nothing but the LAN in between.

The same shared-memory trick currently powers the fullscreen output window — renderer writes frames to an mmap'd file, the fullscreen window reads and draws them via WebGL2. Two Electron windows exchanging pixels through /tmp is the kind of solution that works perfectly while feeling like a note from your future self saying "revisit this".

The scoreboard

Four transports, one source of truth: everything downstream of the graph executor consumes the same RGBA frames, whether they're headed for an encoder queue, a proxy canvas, an FFI call, or a shared-memory file. That discipline — one render path, many exits — is what let three streaming transports land in a single week without touching the engine. The pipeline count says the app has opinions about being seen now. Next problem: making sure what everyone sees lands on the beat.