icey

icey

Build camera gateways, recorders and live video applications in C++20. icey connects FFmpeg, WebRTC and async networking through a shared packet pipeline.

Real-time is one of the few disciplines in software where the deadline is physics. At 60 fps, a video frame is about sixteen milliseconds. A late frame can become a visible stall or more delay in the queue. Once capture, encoding and transport each have their own idea of time and ownership, finding where that delay entered becomes a job of its own.

RTSP cameras are everywhere: doorways, warehouses, loading bays, street corners. A browser cannot open that stream as native WebRTC. icey connects the two: pull from RTSP, decode and encode through FFmpeg as needed, then send over WebRTC inside your application. The same modules can record the stream or pass decoded frames to native processing.

The quickest way to see it is Icey Server, the runnable application built on the library. Its demo starts with a bundled media source:

docker run --rm --network host 0state/icey-server:latest

Open http://localhost:4500 and click Watch on the icey peer. The video came from a single C++ process that captured, decoded, signalled, and transported it without leaving the runtime.

Connect the libraries once

FFmpeg handles codecs. libdatachannel handles WebRTC transport. libuv handles asynchronous I/O. Your application still has to get data from one to the next, keep it alive for the right amount of time and shut the whole path down without a callback touching freed memory.

icey supplies that integration through PacketStream: a source emits packets, processors transform them, and sinks consume them. A camera, a file and a network stream can enter the same processing model. Signals report the events and state changes around it.

┌─────────────────────────────────────────────────────────────────┐
│                        PacketStream                             │
│                                                                 │
│  ┌──────────┐    ┌──────────────┐    ┌───────────────────────┐  │
│  │  Source  │───▶│  Processor   │───▶│        Sink           │  │
│  │          │    │              │    │                       │  │
│  │ Camera   │    │ FFmpeg H.264 │    │ WebRTC Track Sender   │  │
│  │ File     │    │ Opus encode  │    │ Network socket        │  │
│  │ Network  │    │ OpenCV       │    │ File recorder         │  │
│  │ Device   │    │ Custom       │    │ HTTP response         │  │
│  └──────────┘    └──────────────┘    └───────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

WebRTC send path:
  MediaCapture → VideoEncoder → WebRtcTrackSender → [libdatachannel]
                                                        │
  Browser ◀── RTP/SRTP ◀── DTLS ◀── ICE (libjuice) ◀───┘
                                      │
                              icey TURN server
                              (relay for symmetric NATs)

WebRTC receive path:
  [libdatachannel] → WebRtcTrackReceiver → FFmpeg decode → file/display
        │
        └─── ICE → DTLS → SRTP decrypt → RTP depacketise → raw frames

Signalling (Symple v4):
  C++ server/client ◀──── WebSocket ────▶ Browser (symple-player)
  Auth, presence, rooms, call protocol (init/accept/offer/answer/candidate)

Adapters declare whether they borrow, clone or retain a packet. Borrowing keeps the synchronous path cheap; a queued processor needs storage that survives the call. The graph makes that handoff explicit.

PacketStream stream;
stream.attachSource(videoCapture);
stream.attach(new av::MultiplexPacketEncoder(opts), 5);
stream.attach(socket, 10);
stream.start();

Keep detection off the delivery path

A browser needs the next frame. A detector may still be working on the last one. Put both in the same synchronous path and the detector decides how smooth the video looks.

icey lets the application branch from decoded media. One branch encodes and sends; another samples frames for analysis and crosses an explicit queue boundary before slower work. AsyncPacketQueue clones packets for deferred processing, so the capture source can reuse its buffer without invalidating the detector’s input.

capture -> decoded frame -> encoder -> WebRTC sender
                    |
                    `-> sample -> queue -> detector -> events

That choice belongs to the application. A detector taking 100 milliseconds per frame cannot inspect every frame of a 60 fps feed without falling behind. Sampling current frames can be more useful than faithfully analysing an ever-older queue.

The work

A webcam to a browser

The same PacketStream graph: camera source, H.264 encoder, WebRTC track sender. Symple handles the call signalling. The excerpt below shows the wiring; setup, source selection and teardown live in the complete sample. The WebRTC walkthrough develops the path step by step.

session.IncomingCall += [&](const std::string& peerId) {
    session.accept();
};

session.StateChanged += [&](wrtc::PeerSession::State state) {
    if (state == wrtc::PeerSession::State::Active) {
        stream.attachSource(capture.get(), false, true);
        stream.attach(encoder, 1, true);
        stream.attach(&session.media().videoSender(), 5, false);
        stream.start();
    }
};

A browser to disk, server-side

The browser sends video over WebRTC; the server decodes with FFmpeg and writes to any container the codecs allow. Video depositions, telehealth recording, proctoring; recording that lives on infrastructure you control instead of someone else’s cloud.

av::EncoderOptions opts;
opts.oformat = av::Format{
    "MP4", "mp4",
    av::VideoCodec{"H.264", "libx264", 1280, 720, 30, "yuv420p"},
    av::AudioCodec{"AAC", "aac", 2, 48000, 128000, "fltp"}
};

Any file, real-time, to a browser

MP4 in, WebRTC out, with a data channel available alongside the media for application control. See src/webrtc/samples/file-streamer.

Your own TURN relay

RFC 5766 TURN with channel binding and TCP support. When peers cannot establish a direct path, TURN relays the traffic. Operating the relay yourself gives you control of that part of the deployment. See src/turn/samples/turnserver.

Capture and encode

The repository goes back to LibSourcey in 2012. In 2014, I was trying to mux a live webcam and microphone through FFmpeg. The timestamps looked right, but irregular frame arrival still produced crackly audio and sped-up video. The workaround buffered and repeated video frames when the camera fell behind the audio.

FFmpeg’s codec API is powerful and leaks the moment you stop paying attention. icey wraps it in RAII types with custom deleters, so capture, encode, and mux clean up after themselves. VideoCapture and AudioCapture emit decoded packets straight from system devices: V4L2 on Linux, AVFoundation on macOS, DirectShow on Windows. MediaCapture handles file input, with optional looping. Encoders chain into the same PacketStream as processors, and the encoder owns pixel-format conversion through libswscale, PTS tracking, and time-base conversion, then flushes cleanly on close. The whole AV surface adapts to FFmpeg 5, 6, 7 and 8 at compile time, so you build against whatever the distro ships.

Networking on libuv

Sockets, timers, DNS and process integration use libuv’s event-loop model. TCP, SSL, and UDP share a common interface with chainable adapters for middleware-style extension. The SSL layer enforces a TLS 1.2 floor with ALPN, SNI, hostname verification, and session caching through OpenSSL 3.x EVP. The HTTP module is a full client and server: WebSocket upgrade (RFC 6455), multipart forms, cookies, and streaming responses, built on llhttp, the same parser Node uses.

Everything is wired with typed signals. ThreadSignal<T> is thread-safe and multi-listener, carrying socket events, stream-state changes, and install progress through the same mechanism.

server.Connection += [](http::ServerConnection::Ptr conn) {
    conn->Payload += [](auto&, const MutableBuffer& buf) {
        // handle incoming data
    };
};

Under the budget

The repository includes an HTTP comparison using empty 200 OK responses, one vCPU on an Intel i7-11800H system, and wrk with four threads, 100 connections and ten-second runs. These are the reported HTTP/1.1 keep-alive results:

ServerReq/secLatency
Raw libuv+llhttp96,0881.04ms
icey72,2091.43ms
Go 1.25 net/http53,8782.31ms
Node.js v2045,5143.56ms

With a fresh connection for each request, the same harness reports 14,330 requests per second for icey and 14,723 for Go. Connection policy changes the comparison. The harness and methodology are available to rerun on the hardware and workload you care about.

For media, packet borrowing avoids copies along synchronous parts of the graph. Queues, retained adapters, codecs and transport boundaries have their own costs; the HTTP result does not measure those paths.

Modules

icey is sixteen modules, each self-contained, each compiled only when its dependency is present.

ModuleWhat it does
baseEvent loop, signals, packet streams, threading, timers, filesystem, logging
cryptoHMAC, SHA-256/512, RSA, X509; OpenSSL 3.x EVP
netTCP, SSL, UDP sockets, DNS, chainable adapters
httpClient and server, WebSocket RFC 6455, forms, cookies, streaming, keep-alive
jsonSerialisation via nlohmann/json
avFFmpeg capture, encode, decode, record (FFmpeg 5/6/7/8)
speechAudio intelligence primitives for decoded media
visionVideo intelligence primitives for decoded frames
symplePresence, messaging, rooms, WebRTC call signalling
stunRFC 5389 STUN for NAT traversal
turnRFC 5766 relay server with channel binding
webrtcWebRTC via libdatachannel: media bridge, peer sessions, codec negotiation
graftNative plugin ABI and shared-library loading
pacmPackage delivery for native extensions
archoZIP extraction with path traversal protection
schedDeferred and periodic scheduling with restore semantics

CMake fetches core bundled dependencies such as libuv and llhttp. Media and TLS builds also need their enabled system dependencies, including FFmpeg and OpenSSL development packages. Choose the modules and dependencies for the application you are building.

Build and test

cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON
cmake --build build --parallel
ctest --test-dir build --output-on-failure

For media and WebRTC, install the required development libraries and enable -DWITH_FFMPEG=ON -DWITH_LIBDATACHANNEL=ON. Both options default off; installing FFmpeg alone does not enable the media build. CI runs GCC 12+, Clang 15+, AppleClang 15+, and MSVC 2022, with ASan, TSan, and UBSan builds catching what unit tests miss.

Calling the pipeline from Rust

The developing Rust bindings use Graft’s host C interface. icey-sys exposes the raw ABI; the icey crate wraps the current pipeline handle and its lifecycle. That surface covers RTSP input and WebRTC sender setup without asking Rust to share the layout of a C++ class.

This is a narrow entry into the native runtime. The C++ modules remain the broader integration surface.

Install

# Homebrew
brew install nilstate/tap/icey

# Arch (AUR)
yay -S icey

# Nix
nix build github:nilstate/icey

# CMake FetchContent
FetchContent_Declare(icey
  GIT_REPOSITORY https://github.com/nilstate/icey.git
  GIT_TAG ${ICEY_RELEASE_TAG}
)

Set ICEY_RELEASE_TAG to an exact tag from the Icey releases when using CMake. Icey is also listed in vcpkg, ConanCenter, MacPorts, and Spack. Check each registry for its currently published version.

For the runnable Icey Server, use the server repository, or install it from a package manager:

# Homebrew
brew install nilstate/tap/icey-server

# Arch (AUR)
yay -S icey-server

# Ubuntu / Debian (signed APT repo)
curl -fsSL https://apt.0state.com/icey/icey-archive-keyring.gpg | sudo tee /usr/share/keyrings/icey-archive-keyring.gpg >/dev/null
curl -fsSL https://apt.0state.com/icey/icey-server.list | sudo tee /etc/apt/sources.list.d/icey-server.list >/dev/null
sudo apt update && sudo apt install icey-server

The complete samples cover webcam streaming, file streaming and recording. Start with the path closest to the application you are building.