Why write yet another camera viewer
A client runs a warehouse and workshop with fifteen IP cameras and one old office PC to watch them on. Every NVR package he tried either ate the machine alive or stopped at a handful of streams. The ask was simple: show all cameras at once, keep an archive, let me scroll back in time, do not crash, do not need a new computer.
The answer was to write it in C with two dependencies I control: FFmpeg for demuxing and decoding, SDL2 for rendering, audio and threads. Everything else — text rendering, the ONVIF client, the UI — is hand-written. The result is about 17 000 lines of C and a portable distribution of an exe plus two DLLs.
Threading discipline is the whole trick
Each camera gets one worker thread that opens the RTSP session, decodes and hands frames to the renderer. Per-camera locks, nothing shared between cameras. Decode threads never touch the disk; the recorder for each camera is fully asynchronous and never blocks decoding. The UI thread only peeks at the latest frame. An RTSP open-throttle semaphore avoids the handshake bursts that make NVRs refuse connections when all cameras start at once.
The grid switches between preview and main streams per tile, pre-buffers on warm start and shows cached thumbnails immediately, so the application is usable before the first key frame arrives.
Recording and the time machine
Recording writes 1-hour MPEG-TS segments per camera into recordings/<cam>/<dd.MM.yyyy>/<start__end>.ts; the file is renamed with its exact span on close. An archive trimmer enforces a MaxArchiveGB cap.
The DVR mode (F9) scans the recordings into a coverage index and draws a timeline strip at the bottom that you can pan, zoom and seek in; playback runs at 1× and is drawn over the live tile, so you never lose the context of which camera you are looking at.
ONVIF without gSOAP
PTZ heads are driven from the mouse. The ONVIF client — device probe, media profile discovery, ContinuousMove/Stop — is a hand-rolled SOAP 1.2 implementation with WS-Security UsernameToken digest authentication. It lives in its own thread with a one-slot mailbox (the newest command wins) and a safety watchdog that stops the head if commands stop arriving.
A plugin ABI, and a face counter with zero dependencies
Analysis is not compiled in. Plugins are C libraries with a versioned ABI (magic number, ABI version, size-checked structs); they receive YUV420P frames and S16 audio and return overlay rectangles. The shipped Face Counter plugin detects faces with a pico cascade, re-identifies them with LBP histograms and counts unique visitors per day — no OpenCV, no external libraries, installed from its own MSI.
Robustness and packaging
SEH crash logging, a UI-hang watchdog thread, a startup trace log and automatic reconnect cover the failure modes seen on site. Settings live in settings.ini written by a background writer; camera manager, settings and plugin manager are in-app overlays (F1/F2/F10) with English and Russian text. The build is CMake + Ninja under MSYS2 UCRT64, with a script that produces the minimal static FFmpeg; installers are a WiX MSI and an MSIX.
What I would do differently
Start the plugin ABI earlier. It was added after the face-counter request, and retrofitting it meant touching the frame path in every worker. Everything else — the single-worker-per-camera rule, no disk access in decode threads — I would keep exactly as it is; that is why the old office PC copes.