stplugin::LiveKitSession wraps livekit::Room for exactly one subscribed slot: connect with the wsUrl/lkToken the API client minted, find the chosen participant's camera (and microphone), and hand decoded frames to callback-shaped handlers the OBS adapter can consume directly. Two architectural decisions worth recording, both forced by reading the SDK rather than guessed: 1. Frames come from VideoStream/AudioStream::fromTrack with our own reader threads, NOT from Room::setOnVideoFrameCallback. The dispatcher API is keyed by (participant identity, track NAME), which we cannot know before the track is published -- and disassembling liblivekit.so confirms that both Room::setOnVideoFrameCallback and the dispatcher's own setOnVideoFrameCallback merely record the registration: neither starts a reader for a track that is already subscribed. Registering after the subscription event, which is the only time the track name exists, would therefore have silently produced no video. Taking the shared_ptr<Track> straight off the TrackSubscribedEvent sidesteps the name entirely, and lets us pick the camera by TrackSource (streamer-tools publishes cameras as Source.Camera and screenshares separately -- apps/web/src/avatar/ publish.ts), which is what we actually mean. 2. Every stream operation runs on one owned worker thread, never on a room event thread. The SDK documents that Room::disconnect() from inside a delegate callback deadlocks, and Room's own event dispatch holds a mutex, so delegate callbacks only ever enqueue a command here. VideoStream::Options::capacity is set (3 frames) so the SDK's queue is a drop-oldest ring buffer: a stalled consumer can only fall three frames behind, and what it then sees is the newest frame rather than a backlog. That is the structural answer to the stale-media bug that motivated this plugin. The pure decision-making -- the state machine, track selection, frame geometry validation -- lives in session_types.h/.cpp with no LiveKit or OBS types, so it is unit-testable headlessly (81 checks in test_session, including the publisher-swap and reconnect transitions, plus the real connect() failure paths against the real SDK: unreachable host, garbage token, incomplete config, and destruction mid-connect). test_integration_livekit is the test that proves media actually flows. It publishes a synthetic camera and microphone into a real LiveKit room using the same SDK, subscribes through LiveKitSession, and asserts on the exact fields the OBS adapter will dereference. It skips (exit 0) unless STPLUGIN_IT_* is set, so the three build runners stay green; scripts/livekit-dev-room.py mints the tokens for a local `livekit-server --dev`. Verified locally against livekit-server 1.13.6 in dev mode: integration_livekit: 36 video frames, 323 audio frames, 10 state changes integration_livekit: 32 checks passed covering: connect; subscribe to the named participant's camera; 320x240 I420 frames with three planes, non-null plane pointers and strides >= the frame's own width; 48kHz audio; unpublish -> hasVideo() false, state stays Connected (a dark camera is the placeholder state, never an error) and NO further frames arrive from the dead publisher; republish -> video resumes; clean disconnect. One real finding from that run, now handled: WebRTC ramps a new subscription up from a downscaled spatial layer, so the first frames after (re)subscribing legitimately arrive smaller than what is being published. The OBS adapter must cope with a mid-stream resolution change; the test asserts per-frame geometry rather than the publisher's, and separately asserts the stream does reach full size. Full suite: ctest -> 6/6 passed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
obs-streamer-tools-plugin
Native OBS Studio source plugin that will pull streamer-tools camera feeds
directly from LiveKit over WebRTC (via LiveKit's livekit-ffi), replacing
the current SRT/RTSP-via-VLC-or-Media-Source path for directors. Full
design: docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md in
the streamer-tools repo (as of this writing, that doc lives on the
worktree-obs-plugin-server-api branch there, not yet merged to main).
Status: scaffolding only
This repository does not talk to LiveKit or streamer-tools yet. This
first pass exists to prove the CMake toolchain, the core-library/OBS-adapter
split, and the three-platform Gitea Actions CI pipeline all actually work,
so the next phase (real livekit-ffi integration) can be planned against
verified facts instead of assumptions. See the design doc's "Components"
and "CI / build pipeline" sections for the target architecture this scaffold
is standing up.
Layout
core/ - core library (C++17, no OBS dependency, headless-testable)
include/stplugin/
core.h C++ API (ConnectionConfig, core_version())
core_c.h C ABI wrapper the OBS adapter calls into
src/core.cpp
tests/ dependency-free CTest unit tests
obs-adapter/ - thin OBS glue (C, adapted from obsproject/obs-plugintemplate)
src/plugin-main.c obs_module_load/unload + a stub source registration
src/plugin-support.{h,c.in}
data/locale/en-US.ini
.gitea/workflows/build.yml - 3-platform CI matrix (see below)
Everything in core/ is real, working, unit-tested code -- it just doesn't
do anything useful yet (a version string, a config struct with non-empty
validation). Everything in obs-adapter/ is real OBS module code -- it
registers an actual obs_source_info and builds as a real, dynamically
loadable OBS module (see "Verified" below) -- but the source is a stub:
create/destroy allocate/free a dummy blob, there is no properties UI,
and no frames are ever pushed. That's the boundary this task was scoped to.
What's real vs. deliberately deferred
Deferred, per the task that produced this scaffold (out of scope for this pass, in scope for the next one):
- No
livekit-ffilinkage of any kind. - No streamer-tools API client (auth, slot-listing, token minting).
- No properties UI (server URL / room slug / read key / camera dropdown).
- No frame output (
obs_source_output_video/_audio). - No packaging/release step (the design doc's "on a version tag" job).
Toolchain notes (verified on this machine: Ubuntu 24.04 / Linux)
- CMake 3.28.3, Ninja 1.11.1, GCC 13.3.0 -- all installed via
apt-get install cmake ninja-build. Top-levelCMakeLists.txtrequires CMake >= 3.16 (deliberately lower than the official obsproject/obs-plugintemplate's3.28...3.30floor -- see below). - OBS plugin template used as reference: obsproject/obs-plugintemplate,
commit
3e7d7ac3b5342cd7d9b88890b9c70b472d1520fc(2025-12-09, "Fix typo of Visual Studio in README"), fetched fresh from GitHub.src/plugin-main.c,src/plugin-support.{h,c.in}, and the emptydata/locale/en-US.iniinobs-adapter/are adapted directly from it. - Deliberate deviation from the template's own build system: the
official template's
CMakeLists.txtchains intocmake/common/bootstrap.cmake, which in turn readsbuildspec.jsonand downloads full OBS source archives (pinned to OBS 31.1.1) plus prebuilt dependency bundles for macOS and Windows. That machinery is real, actively maintained, and probably the right long-term answer for cross-platform reproducible builds -- but it's heavy (multi-hundred-MB downloads, a wholecmake/{macos,windows,common}support tree, Qt6, code-signing hooks) and out of scope to stand up and debug in one pass. This scaffold instead uses a much simpler hand-written top-levelCMakeLists.txtthat callsfind_package(libobs)directly. - On Linux, this actually works far better than expected: Ubuntu ships
a real
libobs-devpackage (30.0.2+dfsg-3build1on 24.04, i.e. not the 31.1.1 the template's buildspec.json pins -- worth reconciling before the next phase if API surface matters) with genuine CMake package config files (/usr/lib/x86_64-linux-gnu/cmake/libobs/libobsConfig.cmake,libobsTargets.cmake) that export anOBS::libobsimported target -- the exact target name the official template expects.find_package(libobs QUIET)finds it with zero extra plumbing. This means the OBS adapter in this repo links against real OBS headers and a reallibobs.so, not a stub -- confirmed bylddshowinglibobs.so.0andnm -Dshowing realobs_module_*exports (see "Verified" below). Install viaapt-get install libobs-dev(pulls in Qt6 as a dependency chain, ~seconds on a fast mirror). - macOS/Windows have no equivalent system package (there's no Homebrew
formula or winget package that ships
libobsConfig.cmakethe way Ubuntu'slibobs-devdoes). For those platforms the choices are: (a) adopt the template's full buildspec-driven source/prebuilt-deps download, or (b) find/produce a lighter prebuilt SDK bundle. This is now a concrete, scoped decision for the next phase, not a guess -- the CI workflow in this repo currently takes option (c) for this pass only: skip building the OBS adapter on macOS/Windows and build+test just the core library, via the samefind_package(libobs QUIET)fallback the top-levelCMakeLists.txtalready has for exactly this situation. ENABLE_QT/ENABLE_FRONTEND_APItemplate options were not carried over -- this scaffold's properties-UI-free stub doesn't need Qt yet; the real adapter will need to revisit this once the properties UI (server URL / room slug / read key / camera dropdown) is built.
What actually builds, and how it was verified
$ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
-- libobs found (/usr/lib/x86_64-linux-gnu/cmake/libobs) -- building OBS adapter module
-- Configuring done
-- Generating done
$ cmake --build build
[1/7] Building C object obs-adapter/CMakeFiles/streamer-tools-camera.dir/plugin-support.c.o
[2/7] Building C object obs-adapter/CMakeFiles/streamer-tools-camera.dir/src/plugin-main.c.o
[3/7] Building CXX object core/CMakeFiles/stplugin_core.dir/src/core.cpp.o
[4/7] Linking CXX static library core/libstplugin_core.a
[5/7] Building CXX object core/tests/CMakeFiles/stplugin_core_tests.dir/test_core.cpp.o
[6/7] Linking CXX shared module obs-adapter/streamer-tools-camera.so
[7/7] Linking CXX executable core/tests/stplugin_core_tests
$ ctest --test-dir build --output-on-failure
1/1 Test #1: stplugin_core_tests .............. Passed 0.00 sec
100% tests passed, 0 tests failed out of 1
$ ldd build/obs-adapter/streamer-tools-camera.so | grep obs
libobs.so.0 => /lib/x86_64-linux-gnu/libobs.so.0 (...)
$ nm -D build/obs-adapter/streamer-tools-camera.so | grep obs_module
0000000000001430 T obs_module_free_locale
0000000000001450 T obs_module_load
...
0000000000001490 T obs_module_unload
This is a genuine, dynamically-linked OBS module -- not the "standalone shared library without linking OBS" fallback the scaffolding task's scope explicitly allowed as an acceptable compromise. That fallback path is still exercised (and needed) on macOS/Windows CI for now; see above.
CI
.gitea/workflows/build.yml runs on every push/PR, matrixed across the
three runners confirmed available to this repo by living under the
CyberCoveLLC org (see the design doc's "CI / build pipeline" section):
| Job | runs-on |
Runner |
|---|---|---|
linux |
ubuntu-latest |
gitea-runner.internal.cloud-hosting.io (Global) or localhost.localdomain (org-scoped; note: now online with ubuntu-latest/ubuntu-24.04/ubuntu-22.04 labels -- the design doc recorded it as offline, that's since changed) |
macos |
macos-latest |
home-mac (Global) |
windows |
windows-latest |
winvm-builder (org-scoped to CyberCoveLLC) |
The Linux job installs libobs-dev and builds the real OBS adapter module
plus the core library, then runs ctest. The macOS/Windows jobs build and
test only the core library for now (see toolchain notes above for why).
No packaging/release step yet -- out of scope for this pass.