Make the OBS adapter real: properties UI, connect, and frame output

The stub source becomes an actual streamer-tools camera. On create it reads
server URL / room slug / read key / camera identity from obs_data_t, mints a
subscribe-only token through ApiClient, connects LiveKitSession, and pushes
decoded frames into obs_source_output_video / obs_source_output_audio. The
source is now OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO |
OBS_SOURCE_DO_NOT_DUPLICATE with an OBS_ICON_TYPE_CAMERA icon.

The file is C++ rather than C now: the core library's API is C++ and the C ABI
shim existed only to avoid that. obs-module.h already declares the module
entry points extern "C", so nothing is lost.

Properties UI: server URL, room slug, a masked read-key field (it is a
credential and is masked everywhere else in streamer-tools), a camera dropdown,
a "Refresh camera list" button, and a status line.

- The dropdown is built from a cache the worker keeps warm on every connect,
  so opening properties never blocks on the network. The button is the
  explicit way to force a round trip, with a shortened 5s timeout -- for which
  ApiClient's two calls gained a timeout_ms parameter.
- The currently-selected identity is always in the list, labelled "(not in
  this room)" if absent, so OBS cannot silently clear a working setting just
  because the room happens to be dark.
- The status line is the OBS_TEXT_INFO property's description (which is what
  OBS actually renders) and switches to the warning info type on a real error.

Threading: OBS's UI and graphics threads are never blocked on the network.
Each source owns a worker thread that mints, connects, and reconnects with
exponential backoff (1s -> 30s), waking early on any settings change via a
generation counter. Frames are pushed from LiveKitSession's reader threads
directly; obs_source_output_video/_audio are thread-safe.

Two details that matter operationally:
 - A null frame is pushed whenever the session leaves Connected, so a camera
   that stopped publishing clears instead of leaving its last frame on screen.
   Leaving stale media up is precisely the failure this plugin exists to avoid.
 - The SDK's own logging is routed into OBS's log file via
   livekit::setLogCallback, instead of stderr where a director would never
   see it. The adapter also logs the first frame and every later geometry
   change, so a log answers "did video ever arrive, and at what size".

Packaging: the build now stages a runnable layout into build/package/ --
the module (RPATH $ORIGIN / @loader_path, so it resolves the LiveKit
libraries from beside itself rather than from the build tree), liblivekit +
liblivekit_ffi, the locale data, and the licence files. third_party/livekit/
carries client-sdk-cpp's Apache-2.0 LICENSE and NOTICE from the pinned tag.

Its README records a correction to the design doc: the "bundled LICENSE.md
with ~28 third-party licence blocks" the doc expects DOES NOT EXIST at
v1.10.1 -- not in any of the five release archives (which contain only
include/, lib/, bin/ and build-info.json) and not in the repo at that tag,
which has only LICENSE and NOTICE. The aggregated third-party notice covering
the WebRTC/OpenH264 code inside liblivekit_ffi.so has not been located, and
that is flagged as an open licensing question rather than papered over.

Verified on Ubuntu 24.04 against real libobs 30.0.2, a real
livekit-server 1.13.6, and a stand-in API serving plugin.routes.ts's exact
shapes, using a headless libobs harness (obs_startup + obs_reset_audio +
obs_reset_video + obs_open_module + obs_source_create):

  registered=1  output_flags=0x87
  [streamer-tools-camera] connected to ws://127.0.0.1:7880 as
      obs:main-room:qY85r9D0PaPt, watching cam-test
  [streamer-tools-camera] video frame 640x360 I420
  camera dropdown has 3 items:
    [0] (no camera selected) =
    [1] Test Camera = cam-test
    [2] Dark Camera (offline) = other-cam
  status: connected (info_type=0)

and with a deliberately wrong read key:

  status: unknown room slug, or the read key is wrong or has been rotated
      (info_type=1)

with retry-and-backoff and no crash. ctest: 6/6 passed.

Still unverified, and the README says so plainly: the OBS GUI on any platform,
macOS/Windows beyond compiling, A/V sync, and end-to-end latency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
This commit is contained in:
2026-09-06 21:54:11 -07:00
co-authored by Claude Sonnet 5
parent 80904a3e85
commit a484abec61
12 changed files with 1040 additions and 215 deletions
+165 -128
View File
@@ -1,159 +1,196 @@
# obs-streamer-tools-plugin # obs-streamer-tools-plugin
Native OBS Studio source plugin that will pull streamer-tools camera feeds Native OBS Studio source plugin that pulls streamer-tools camera feeds
directly from LiveKit over WebRTC (via LiveKit's `livekit-ffi`), replacing directly from LiveKit over WebRTC, replacing the current SRT/RTSP-via-VLC-or-
the current SRT/RTSP-via-VLC-or-Media-Source path for directors. Full Media-Source path for directors. Full design:
design: `docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md` in `docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md` in the
the `streamer-tools` repo (as of this writing, that doc lives on the `streamer-tools` repo.
`worktree-obs-plugin-server-api` branch there, not yet merged to `main`).
## Status: scaffolding only ## Status
**This repository does not talk to LiveKit or streamer-tools yet.** This The plugin is **functionally complete on Linux and verified end to end there**
first pass exists to prove the CMake toolchain, the core-library/OBS-adapter (module loads into real libobs, connects to a real LiveKit server through the
split, and the three-platform Gitea Actions CI pipeline all actually work, real streamer-tools API shape, and pushes decoded frames into
so the next phase (real `livekit-ffi` integration) can be planned against `obs_source_output_video`/`_audio`). It has **not** been run in the OBS GUI on
verified facts instead of assumptions. See the design doc's "Components" any platform, and macOS/Windows have only ever been built by CI, never loaded.
and "CI / build pipeline" sections for the target architecture this scaffold See "What is verified, and how" below for exactly what that means, and
is standing up. "Testing this by hand" for what a human still needs to do.
## Layout ## Layout
``` ```
cmake/LiveKitSDK.cmake - downloads + unpacks the pinned client-sdk-cpp release
core/ - core library (C++17, no OBS dependency, headless-testable) core/ - core library (C++17, no OBS dependency, headless-testable)
include/stplugin/ include/stplugin/
core.h C++ API (ConnectionConfig, core_version()) core.h version + ConnectionConfig
core_c.h C ABI wrapper the OBS adapter calls into json.h small strict JSON reader
src/core.cpp http.h injectable HTTP client interface
tests/ dependency-free CTest unit tests api_client.h the two /api/obs/:slug/* calls
session_types.h media/state types + the pure session logic
obs-adapter/ - thin OBS glue (C, adapted from obsproject/obs-plugintemplate) session.h LiveKitSession, the livekit::Room wrapper
src/plugin-main.c obs_module_load/unload + a stub source registration src/
src/plugin-support.{h,c.in} http_curl.cpp libcurl backend (Linux/macOS)
http_winhttp.cpp WinHTTP backend (Windows)
tests/ dependency-free CTest suites
obs-adapter/ - thin OBS glue (C++)
src/plugin-main.cpp obs_source_info, properties UI, frame output
data/locale/en-US.ini data/locale/en-US.ini
scripts/livekit-dev-room.py - mints tokens for the integration test
.gitea/workflows/build.yml - 3-platform CI matrix (see below) third_party/livekit/ - redistribution notices for the LiveKit binaries
.gitea/workflows/build.yml - 3-platform CI matrix
``` ```
Everything in `core/` is real, working, unit-tested code -- it just doesn't ## How it works
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 1. The operator fills in the streamer-tools server URL, room slug and read key,
and picks a camera from the dropdown.
2. The source's own worker thread calls `POST /api/obs/:slug/token?key=…` to
mint a hidden, subscribe-only LiveKit token
(identity `obs:<slug>:<nonce>` — a fresh nonce per mint, so two OBS
installs watching the same room can never kick each other).
3. `LiveKitSession` connects `livekit::Room` to the returned `wsUrl`, waits for
the chosen participant's `Source.Camera` video track (and their microphone),
and reads decoded frames off `VideoStream`/`AudioStream`.
4. The adapter hands those straight to `obs_source_output_video` /
`obs_source_output_audio`.
Deferred, per the task that produced this scaffold (out of scope for this Nothing on the OBS UI thread ever blocks on the network. The one deliberate
pass, in scope for the next one): exception is the "Refresh camera list" button, which the operator pressed and
is waiting on; it uses a shortened 5s timeout.
- No `livekit-ffi` linkage of any kind. ### Design decisions worth knowing before changing this
- 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) - **Frames come from `VideoStream::fromTrack` with our own reader threads, not
from `Room::setOnVideoFrameCallback`.** The dispatcher API is keyed by
(participant identity, track *name*), which is only knowable once the track
is published — and disassembly of `liblivekit.so` 1.10.1 confirms that
neither `Room::setOnVideoFrameCallback` nor the dispatcher's own version
starts a reader for an already-subscribed track; they only record the
registration. Registering at the only moment the name exists would therefore
have silently produced no video.
- **Every stream operation runs on one owned worker thread**, never on a
LiveKit room event thread: `Room::disconnect()` from inside a delegate
callback is documented to deadlock.
- **`VideoStream::Options::capacity` is 3**, making the SDK queue a
drop-oldest ring buffer. A stalled consumer can only fall three frames
behind and then sees the *newest* frame, not a backlog — the structural
answer to the stale-media bug that motivated this plugin.
- **Video and audio are both timestamped with `os_gettime_ns()` at arrival.**
The SDK gives video a WebRTC capture timestamp and audio none; mixing two
epochs inside one OBS source would guarantee A/V drift. This relies on the
SDK's jitter buffering having already aligned them — the assumption the
design doc flags for verification on real hardware. **Still unverified.**
- **WebRTC changes resolution mid-stream.** Observed directly in the
integration test: the first frames after (re)subscribing arrive at a
downscaled spatial layer before ramping to the published size. The adapter
passes each frame's own geometry through, and logs geometry changes.
- **CMake 3.28.3**, **Ninja 1.11.1**, GCC 13.3.0 -- all installed via ## Building
`apt-get install cmake ninja-build`. Top-level `CMakeLists.txt` requires
CMake >= 3.16 (deliberately lower than the official
obsproject/obs-plugintemplate's `3.28...3.30` floor -- 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 empty `data/locale/en-US.ini` in
`obs-adapter/` are adapted directly from it.
- **Deliberate deviation from the template's own build system**: the
official template's `CMakeLists.txt` chains into
`cmake/common/bootstrap.cmake`, which in turn reads `buildspec.json` and
*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 whole `cmake/{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-level
`CMakeLists.txt` that calls `find_package(libobs)` directly.
- **On Linux, this actually works far better than expected**: Ubuntu ships
a real `libobs-dev` package (`30.0.2+dfsg-3build1` on 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 an `OBS::libobs` imported 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 real `libobs.so`**, not
a stub -- confirmed by `ldd` showing `libobs.so.0` and `nm -D` showing
real `obs_module_*` exports (see "Verified" below). Install via
`apt-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.cmake` the way Ubuntu's
`libobs-dev` does). 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 same `find_package(libobs QUIET)` fallback the top-level
`CMakeLists.txt` already has for exactly this situation.
- **`ENABLE_QT`/`ENABLE_FRONTEND_API` template 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 Linux (the platform that is fully verified):
``` ```
$ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release sudo apt-get install -y cmake ninja-build libobs-dev libcurl4-openssl-dev
-- libobs found (/usr/lib/x86_64-linux-gnu/cmake/libobs) -- building OBS adapter module cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
-- Configuring done cmake --build build
-- Generating done ctest --test-dir build --output-on-failure
$ 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 The configure step downloads the pinned `client-sdk-cpp` release (~13 MB) into
shared library without linking OBS" fallback the scaffolding task's scope `build/_deps/livekit-sdk`. Point `-DSTPLUGIN_LIVEKIT_SDK_DIR=<path>` at a
explicitly allowed as an acceptable compromise. That fallback path is still persistent directory to cache it across builds; `-DSTPLUGIN_LIVEKIT_SDK_VERSION`
exercised (and needed) on macOS/Windows CI for now; see above. and `-DSTPLUGIN_LIVEKIT_SDK_TRIPLE` override the pin and the release triple.
The build stages a runnable layout into `build/package/`:
```
build/package/bin/streamer-tools-camera.so (RPATH=$ORIGIN)
build/package/bin/liblivekit.so
build/package/bin/liblivekit_ffi.so
build/package/data/locale/en-US.ini
build/package/licenses/...
```
`build/package/bin` is what gets installed — the module resolves the LiveKit
libraries from `$ORIGIN` / `@loader_path`, not from the build tree.
## Testing this by hand
**Nobody has yet run this in the OBS GUI. That test is still outstanding on
all three platforms.** To do it on Linux:
```
mkdir -p ~/.config/obs-studio/plugins/streamer-tools-camera
cp -r build/package/bin build/package/data \
~/.config/obs-studio/plugins/streamer-tools-camera/
obs
```
Then: Sources → `+` → "streamer-tools Camera" → fill in the server URL, room
slug and read key from the room's settings page → "Refresh camera list" → pick
a camera. Check `~/.config/obs-studio/logs/` for
`[streamer-tools-camera] connected to …` and `[streamer-tools-camera] video
frame WxH I420`.
What to look for that automated testing could not answer:
- Does video actually *appear*, right way up, right colours?
- Is A/V sync acceptable? (see the timestamp caveat above)
- What is the end-to-end latency versus the existing egress path?
- Does a publisher restarting mid-show recover cleanly on screen?
### Running the LiveKit integration test
`core/tests/test_integration_livekit` publishes a synthetic camera into a real
room and subscribes to it through the wrapper. It skips unless
`STPLUGIN_IT_*` is set:
```
livekit-server --dev --bind 127.0.0.1 &
eval "$(python3 scripts/livekit-dev-room.py)"
ctest --test-dir build -R test_integration_livekit --output-on-failure
```
## What is verified, and how
Verified on Ubuntu 24.04 (libobs 30.0.2, client-sdk-cpp 1.10.1,
livekit-server 1.13.6 in dev mode):
| Claim | How it was checked |
|---|---|
| The pinned LiveKit SDK links and is callable | `test_livekit_smoke`: `initialize()`/`shutdown()` round-trip, header version asserted equal to the CMake pin |
| The JSON reader handles real and hostile input | `test_json`, 158 checks, including truncated bodies, HTML error pages, binary garbage, lone surrogates, and a depth-limit case |
| The API client parses the real response shapes and every error branch | `test_api_client`, 121 checks, against a fake HTTP client **and** a real loopback HTTP server driving the actual platform backend |
| A dead/stalled/garbage server cannot hang or crash the plugin | loopback cases: truncated JSON, connection closed with no reply, non-HTTP bytes, dead port, stalled server cut off by the client timeout |
| Session state transitions, track selection, frame geometry | `test_session`, 81 checks, plus real `connect()` failures against the real SDK |
| **Media actually flows** | `test_integration_livekit` against a real LiveKit server: 36 video frames + 323 audio frames, correct I420 geometry and plane pointers, publisher unpublish → `hasVideo()` false with **no further frames from the dead publisher**, republish → video resumes |
| **The module loads into real libobs and pushes frames** | a headless libobs harness (`obs_startup` + `obs_reset_audio`/`obs_reset_video` + `obs_open_module`) driving the built module against a stand-in streamer-tools API in front of a real LiveKit server. Log: `connected to ws://… watching cam-test` then `video frame 640x360 I420`; the camera dropdown populated as `Test Camera` / `Dark Camera (offline)`; status `connected`; clean destroy and unload |
| A wrong read key is reported, not silently swallowed | same harness with a bad key: status `unknown room slug, or the read key is wrong or has been rotated`, warning info type, retry with backoff, no crash |
**Not verified anywhere:**
- The OBS GUI, on any platform. No human has looked at this in OBS.
- macOS and Windows beyond "CI compiles and the core tests pass". The WinHTTP
backend has never run against a real streamer-tools server.
- A/V sync and end-to-end latency against the existing egress path.
- Behaviour against the real production streamer-tools server (only against a
stand-in serving the same shapes).
- Token expiry after an hour. Expiry is handled *reactively*: a fatal
disconnect makes the worker mint a fresh token and reconnect. The design
doc's "proactively refreshed before expiry" is **not** implemented —
`client-sdk-cpp` 1.10.1 exposes no way to hand a live `Room` a new token.
## CI ## CI
`.gitea/workflows/build.yml` runs on every push/PR, matrixed across the `.gitea/workflows/build.yml` runs on every push, matrixed across the three
three runners confirmed available to this repo by living under the runners available to this repo under the `CyberCoveLLC` org.
`CyberCoveLLC` org (see the design doc's "CI / build pipeline" section):
| Job | `runs-on` | Runner | | 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) | | `linux` | `ubuntu-latest` | `gitea-runner.internal.cloud-hosting.io` (Global) |
| `macos` | `macos-latest` | `home-mac` (Global) | | `macos` | `macos-latest` | `home-mac` (Global) |
| `windows` | `windows-latest` | `winvm-builder` (org-scoped to `CyberCoveLLC`) | | `windows` | `windows-latest` | `winvm-builder` (org-scoped) |
The Linux job installs `libobs-dev` and builds the real OBS adapter module Linux uses Ubuntu's `libobs-dev` and builds the real OBS adapter. macOS and
plus the core library, then runs `ctest`. The macOS/Windows jobs build and Windows use the `obsproject/obs-plugintemplate` buildspec bootstrap, trimmed
test only the core library for now (see toolchain notes above for why). to drop `qt6` (this plugin's properties UI is plain `obs_properties_*`), with
`obs-studio.version` pinned no newer than what Linux builds against — OBS
No packaging/release step yet -- out of scope for this pass. rejects a module built against a newer libobs than the one running it.
+5
View File
@@ -43,6 +43,11 @@ endif()
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
target_link_libraries(stplugin_core PUBLIC Threads::Threads) target_link_libraries(stplugin_core PUBLIC Threads::Threads)
target_compile_definitions(stplugin_core PRIVATE
STPLUGIN_CORE_VERSION="${PROJECT_VERSION}"
STPLUGIN_LIVEKIT_SDK_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}"
)
set_target_properties(stplugin_core PROPERTIES set_target_properties(stplugin_core PROPERTIES
POSITION_INDEPENDENT_CODE ON POSITION_INDEPENDENT_CODE ON
) )
+6 -2
View File
@@ -98,8 +98,12 @@ public:
/// Takes ownership of the HTTP client, so tests can inject a fake. /// Takes ownership of the HTTP client, so tests can inject a fake.
explicit ApiClient(std::shared_ptr<HttpClient> http); explicit ApiClient(std::shared_ptr<HttpClient> http);
SlotsResult fetchSlots(const ConnectionConfig &config) const; /// @param timeout_ms whole-request timeout. Kept as a parameter because
TokenResult requestToken(const ConnectionConfig &config) const; /// the properties dialog's "Refresh" button runs on OBS's UI thread with
/// an operator waiting, and must give up sooner than a background
/// reconnect would.
SlotsResult fetchSlots(const ConnectionConfig &config, int timeout_ms = 10000) const;
TokenResult requestToken(const ConnectionConfig &config, int timeout_ms = 10000) const;
/// Accepts what an operator would actually paste: a bare hostname, a URL /// Accepts what an operator would actually paste: a bare hostname, a URL
/// with a trailing slash, extra whitespace. Returns an empty string if /// with a trailing slash, extra whitespace. Returns an empty string if
+4 -2
View File
@@ -120,7 +120,7 @@ std::string ApiClient::redactedUrl(const std::string &url)
return url.substr(0, value) + "***" + url.substr(end); return url.substr(0, value) + "***" + url.substr(end);
} }
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const
{ {
SlotsResult result; SlotsResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
@@ -132,6 +132,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
HttpRequest request; HttpRequest request;
request.method = "GET"; request.method = "GET";
request.url = buildUrl(config, "/slots"); request.url = buildUrl(config, "/slots");
request.timeout_ms = timeout_ms;
const HttpResponse response = http_->send(request); const HttpResponse response = http_->send(request);
const ApiStatus status = classify(response, result.message); const ApiStatus status = classify(response, result.message);
@@ -170,7 +171,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
return result; return result;
} }
TokenResult ApiClient::requestToken(const ConnectionConfig &config) const TokenResult ApiClient::requestToken(const ConnectionConfig &config, int timeout_ms) const
{ {
TokenResult result; TokenResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
@@ -184,6 +185,7 @@ TokenResult ApiClient::requestToken(const ConnectionConfig &config) const
request.url = buildUrl(config, "/token"); request.url = buildUrl(config, "/token");
request.content_type = "application/json"; request.content_type = "application/json";
request.body = "{}"; request.body = "{}";
request.timeout_ms = timeout_ms;
const HttpResponse response = http_->send(request); const HttpResponse response = http_->send(request);
const ApiStatus status = classify(response, result.message); const ApiStatus status = classify(response, result.message);
+3 -1
View File
@@ -22,7 +22,9 @@ with this program. If not, see <https://www.gnu.org/licenses/>
namespace stplugin { namespace stplugin {
const char *core_version() { const char *core_version() {
return "0.0.1-scaffold"; // Injected by CMake from the top-level project() version, so the string
// OBS logs on load is the actual build, not a hand-maintained literal.
return STPLUGIN_CORE_VERSION;
} }
bool ConnectionConfig::is_valid() const { bool ConnectionConfig::is_valid() const {
+54 -5
View File
@@ -1,9 +1,8 @@
# streamer-tools OBS Camera Plugin - OBS adapter # streamer-tools OBS Camera Plugin - OBS adapter
# #
# Thin glue only, per the design doc: source registration, (eventually) # Thin glue only, per the design doc: source registration, the properties UI,
# properties UI, and pushing frames into OBS. All real logic lives in # and pushing frames into OBS. All real logic lives in ../core. Only added to
# ../core. Only added to the build when find_package(libobs) succeeds # the build when find_package(libobs) succeeds (see top-level CMakeLists.txt).
# (see top-level CMakeLists.txt) -- see README.md for why.
set(STPLUGIN_PROJECT_NAME "streamer-tools-camera") set(STPLUGIN_PROJECT_NAME "streamer-tools-camera")
set(STPLUGIN_PROJECT_VERSION "${PROJECT_VERSION}") set(STPLUGIN_PROJECT_VERSION "${PROJECT_VERSION}")
@@ -15,7 +14,7 @@ configure_file(
) )
add_library(${STPLUGIN_PROJECT_NAME} MODULE add_library(${STPLUGIN_PROJECT_NAME} MODULE
src/plugin-main.c src/plugin-main.cpp
${CMAKE_CURRENT_BINARY_DIR}/plugin-support.c ${CMAKE_CURRENT_BINARY_DIR}/plugin-support.c
) )
@@ -35,3 +34,53 @@ set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
PREFIX "" PREFIX ""
OUTPUT_NAME ${STPLUGIN_PROJECT_NAME} OUTPUT_NAME ${STPLUGIN_PROJECT_NAME}
) )
# The module has to find liblivekit / liblivekit_ffi next to itself once it is
# installed into an OBS plugin directory, not at the build-tree path CMake's
# default RPATH would bake in.
# BUILD_WITH_INSTALL_RPATH is ON deliberately: the artifact that ships is a
# straight copy of the built module (see the staging step below), so the
# build-tree RPATH must never be baked in -- it would work on the build
# machine and nowhere else.
if(APPLE)
set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
BUILD_WITH_INSTALL_RPATH ON
INSTALL_RPATH "@loader_path"
)
elseif(UNIX)
set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
BUILD_WITH_INSTALL_RPATH ON
INSTALL_RPATH "$ORIGIN"
)
endif()
# --- staged, runnable layout ------------------------------------------------
# Everything a human needs to copy into an OBS plugin directory ends up under
# build/package/, with the LiveKit shared libraries and the licence files
# beside the module. Without this the module loads on the build machine only,
# via the build-tree RPATH.
set(STPLUGIN_PACKAGE_DIR "${CMAKE_BINARY_DIR}/package")
add_custom_command(TARGET ${STPLUGIN_PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/bin"
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:${STPLUGIN_PROJECT_NAME}>" "${STPLUGIN_PACKAGE_DIR}/bin/"
COMMAND ${CMAKE_COMMAND} -E copy ${LIVEKIT_SDK_RUNTIME_LIBS} "${STPLUGIN_PACKAGE_DIR}/bin/"
COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/data/locale"
COMMAND ${CMAKE_COMMAND} -E copy
"${CMAKE_CURRENT_SOURCE_DIR}/data/locale/en-US.ini"
"${STPLUGIN_PACKAGE_DIR}/data/locale/"
# Redistributing LiveKit's prebuilt binaries means shipping their licence
# and notice with them. See third_party/livekit/README.md -- including
# what upstream does NOT ship, which is an open question, not a solved one.
COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/licenses/livekit"
COMMAND ${CMAKE_COMMAND} -E copy
"${CMAKE_SOURCE_DIR}/third_party/livekit/LICENSE"
"${CMAKE_SOURCE_DIR}/third_party/livekit/NOTICE"
"${CMAKE_SOURCE_DIR}/third_party/livekit/README.md"
"${STPLUGIN_PACKAGE_DIR}/licenses/livekit/"
COMMAND ${CMAKE_COMMAND} -E copy
"${CMAKE_SOURCE_DIR}/LICENSE"
"${STPLUGIN_PACKAGE_DIR}/licenses/"
COMMENT "Staging plugin + LiveKit runtime libraries + licences into ${STPLUGIN_PACKAGE_DIR}"
VERBATIM
)
+11 -2
View File
@@ -1,2 +1,11 @@
# streamer-tools OBS Camera Plugin - en-US locale StreamerToolsCamera="streamer-tools Camera"
# No user-facing strings yet -- this is scaffolding (see plugin-main.c). ServerUrl="streamer-tools server URL"
RoomSlug="Room"
ReadKey="Read key"
Camera="Camera"
RefreshCameras="Refresh camera list"
Status="Status"
NoCameraSelected="(no camera selected)"
OfflineSuffix=" (offline)"
NotInRoomSuffix=" (not in this room)"
CamerasFound=" cameras found"
-74
View File
@@ -1,74 +0,0 @@
/*
streamer-tools OBS Camera Plugin
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
/* SCAFFOLDING. This registers a source type so the OBS-adapter/core-
* library split and OBS's module-loading toolchain can be proven end to
* end, but it does not do anything real yet: no LiveKit FFI session, no
* frames pushed via obs_source_output_video/audio, no properties UI.
* See docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md in
* the streamer-tools repo for what this becomes. */
#include <obs-module.h>
#include <util/bmem.h>
#include <plugin-support.h>
#include <stplugin/core_c.h>
OBS_DECLARE_MODULE()
OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US")
static const char *stcam_source_get_name(void *unused)
{
UNUSED_PARAMETER(unused);
return "streamer-tools Camera (scaffold - not yet functional)";
}
static void *stcam_source_create(obs_data_t *settings, obs_source_t *source)
{
UNUSED_PARAMETER(settings);
UNUSED_PARAMETER(source);
/* No LiveKit session, no state to speak of yet -- just proving the
* source registers and OBS can instantiate/destroy it cleanly. */
return bzalloc(1);
}
static void stcam_source_destroy(void *data)
{
bfree(data);
}
static struct obs_source_info streamer_tools_camera_source = {
.id = "streamer_tools_camera_source",
.type = OBS_SOURCE_TYPE_INPUT,
.output_flags = OBS_SOURCE_ASYNC_VIDEO,
.get_name = stcam_source_get_name,
.create = stcam_source_create,
.destroy = stcam_source_destroy,
};
bool obs_module_load(void)
{
obs_log(LOG_INFO, "streamer-tools camera plugin scaffold loaded (core library version %s)",
stplugin_core_version());
obs_register_source(&streamer_tools_camera_source);
return true;
}
void obs_module_unload(void)
{
obs_log(LOG_INFO, "streamer-tools camera plugin scaffold unloaded");
}
+567
View File
@@ -0,0 +1,567 @@
/*
streamer-tools OBS Camera Plugin
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
// The thin glue layer, per the design doc: source registration, the
// properties UI, and pushing frames into OBS. Everything that can be tested
// headlessly lives in ../core.
//
// Two threading rules shape this whole file:
// - OBS calls create/update/destroy/get_properties on its UI or graphics
// thread. Nothing here may block them on the network, so every API call
// and every LiveKit connect happens on the source's own worker thread.
// The one exception is the explicit "Refresh camera list" button, where
// the operator asked for a round trip and is waiting for its result.
// - Frames arrive on LiveKitSession's reader threads. obs_source_output_video
// and obs_source_output_audio are safe to call from any thread, so they
// are called directly from there with no extra copy.
#include <obs-module.h>
#include <util/platform.h>
#include <plugin-support.h>
#include <stplugin/api_client.h>
#include <stplugin/core.h>
#include <stplugin/http.h>
#include <stplugin/session.h>
#include <stplugin/session_types.h>
#include <livekit/logging.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
using namespace stplugin;
OBS_DECLARE_MODULE()
OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US")
namespace {
constexpr const char *kSettingServerUrl = "server_url";
constexpr const char *kSettingRoomSlug = "room_slug";
constexpr const char *kSettingReadKey = "read_key";
constexpr const char *kSettingCamera = "camera";
constexpr const char *kSettingStatus = "status";
constexpr const char *kPropRefresh = "refresh";
/// Shorter than the core default: this one runs while an operator is staring
/// at a properties dialog they pressed a button in.
constexpr int kPropertiesTimeoutMs = 5000;
/// Reconnect backoff bounds. A dark room or a stopped server must not turn
/// into a request storm, but a transient blip should recover quickly.
constexpr int kBackoffStartMs = 1000;
constexpr int kBackoffMaxMs = 30000;
std::string settingString(obs_data_t *settings, const char *key)
{
const char *value = obs_data_get_string(settings, key);
return value ? std::string(value) : std::string();
}
video_format toObsVideoFormat(PixelFormat format)
{
switch (format) {
case PixelFormat::I420: return VIDEO_FORMAT_I420;
case PixelFormat::NV12: return VIDEO_FORMAT_NV12;
case PixelFormat::BGRA: return VIDEO_FORMAT_BGRA;
}
return VIDEO_FORMAT_I420;
}
// ---------------------------------------------------------------------------
// Source instance
// ---------------------------------------------------------------------------
struct CameraSource {
obs_source_t *source = nullptr;
// --- configuration, guarded by `mutex` ---
std::mutex mutex;
ConnectionConfig config;
std::string camera_identity;
/// Bumped every time settings change; the worker compares it to what it
/// last connected with, so a stale in-flight connect is abandoned rather
/// than fought over.
std::uint64_t generation = 0;
std::vector<SlotInfo> slot_cache;
std::string status_text = "not configured";
// --- worker ---
std::thread worker;
std::condition_variable wake;
std::atomic<bool> stopping{false};
std::shared_ptr<ApiClient> api;
std::unique_ptr<LiveKitSession> session;
std::atomic<std::uint64_t> frames_out{0};
/// width<<16 | height of the last frame pushed, so a geometry change can
/// be logged exactly once.
std::atomic<std::uint32_t> last_geometry{0};
/// Whether the current status line is a problem the operator must act on
/// (a wrong read key), rather than ordinary progress.
std::atomic<bool> status_is_error{false};
void setStatus(std::string text)
{
std::lock_guard<std::mutex> guard(mutex);
status_text = std::move(text);
}
std::string statusText()
{
std::lock_guard<std::mutex> guard(mutex);
return status_text;
}
};
// ---------------------------------------------------------------------------
// Frame output
// ---------------------------------------------------------------------------
void outputVideoFrame(CameraSource *self, const VideoFrameData &frame)
{
obs_source_frame out = {};
out.width = static_cast<std::uint32_t>(frame.width);
out.height = static_cast<std::uint32_t>(frame.height);
out.format = toObsVideoFormat(frame.format);
// Both video and audio are stamped with the SAME clock (os_gettime_ns at
// arrival) rather than video using WebRTC's timestamp_us and audio using
// arrival time. The SDK's VideoFrameCallback carries a capture-time
// timestamp but its AudioFrameCallback carries none, and mixing two
// epochs inside one OBS source is a guaranteed A/V drift. This relies on
// the SDK's jitter buffering having already aligned the two -- the
// assumption the design doc flags for verification on real hardware.
out.timestamp = os_gettime_ns();
for (int i = 0; i < frame.plane_count && i < MAX_AV_PLANES; ++i) {
out.data[i] = const_cast<std::uint8_t *>(frame.planes[i].data);
out.linesize[i] = frame.planes[i].stride;
}
// WebRTC delivers limited-range BT.709 for anything at or above SD.
video_format_get_parameters_for_format(VIDEO_CS_709, VIDEO_RANGE_PARTIAL, out.format, out.color_matrix,
out.color_range_min, out.color_range_max);
out.full_range = false;
obs_source_output_video(self->source, &out);
self->frames_out.fetch_add(1);
// Log the first frame, and any later change of geometry. A director's
// log then answers "did video ever arrive, and at what size" without
// anyone having to reproduce the problem -- and WebRTC really does
// change resolution mid-stream as it ramps a subscription up.
const std::uint32_t geometry = out.width << 16 | out.height;
const std::uint32_t previous = self->last_geometry.exchange(geometry);
if (previous != geometry)
obs_log(LOG_INFO, "video frame %ux%u %s", out.width, out.height, describePixelFormat(frame.format));
}
void outputAudioFrame(CameraSource *self, const AudioFrameData &frame)
{
if (!frame.samples || frame.samples_per_channel <= 0)
return;
obs_source_audio out = {};
out.data[0] = reinterpret_cast<const std::uint8_t *>(frame.samples);
out.frames = static_cast<std::uint32_t>(frame.samples_per_channel);
out.format = AUDIO_FORMAT_16BIT; // interleaved int16, which is what the SDK hands us
out.samples_per_sec = static_cast<std::uint32_t>(frame.sample_rate);
out.timestamp = os_gettime_ns();
switch (frame.channels) {
case 1: out.speakers = SPEAKERS_MONO; break;
case 2: out.speakers = SPEAKERS_STEREO; break;
default:
// Anything else would need a channel-map decision we have no reason
// to guess at; a streamer-tools mic is mono or stereo.
return;
}
obs_source_output_audio(self->source, &out);
}
// ---------------------------------------------------------------------------
// Worker: mint a token, connect, keep it connected
// ---------------------------------------------------------------------------
void workerLoop(CameraSource *self)
{
std::uint64_t connected_generation = 0;
bool connected = false;
int backoff_ms = kBackoffStartMs;
for (;;) {
ConnectionConfig config;
std::string camera;
std::uint64_t generation = 0;
{
std::unique_lock<std::mutex> lock(self->mutex);
if (self->stopping.load())
break;
config = self->config;
camera = self->camera_identity;
generation = self->generation;
}
const bool config_changed = generation != connected_generation;
const bool needs_connect =
!connected || config_changed ||
(self->session && (self->session->state() == SessionState::Failed ||
self->session->state() == SessionState::Disconnected));
if (needs_connect) {
if (connected || config_changed) {
if (self->session)
self->session->disconnect();
obs_source_output_video(self->source, nullptr);
connected = false;
}
if (!config.is_valid() || camera.empty()) {
self->setStatus("not configured -- set the server URL, room, read key and camera");
connected_generation = generation;
backoff_ms = kBackoffStartMs;
} else {
self->setStatus("connecting...");
const TokenResult token = self->api->requestToken(config);
if (!token.ok()) {
// token.message is the specific one ("the read key is
// wrong or has been rotated"); describeApiStatus is the
// generic fallback. Printing both just reads as noise.
const std::string message =
token.message.empty() ? describeApiStatus(token.status) : token.message;
self->setStatus(message);
self->status_is_error.store(true);
obs_log(LOG_WARNING, "token request failed: %s", message.c_str());
} else {
// Refresh the dropdown cache while we are here; the
// properties UI then opens instantly instead of blocking
// on the network.
const SlotsResult slots = self->api->fetchSlots(config);
if (slots.ok()) {
std::lock_guard<std::mutex> guard(self->mutex);
self->slot_cache = slots.slots;
}
SessionConfig session_config;
session_config.ws_url = token.ws_url;
session_config.token = token.lk_token;
session_config.participant_identity = camera;
if (self->session->connect(session_config)) {
connected = true;
connected_generation = generation;
backoff_ms = kBackoffStartMs;
self->status_is_error.store(false);
obs_log(LOG_INFO, "connected to %s as %s, watching %s", token.ws_url.c_str(),
token.identity.c_str(), camera.c_str());
} else {
self->setStatus(self->session->stateDetail());
}
}
if (!connected) {
backoff_ms = backoff_ms * 2 < kBackoffMaxMs ? backoff_ms * 2 : kBackoffMaxMs;
connected_generation = generation;
}
}
}
// Poll rather than push: the state handler could notify us, but it
// runs on a LiveKit thread and this keeps the wake-up path single.
std::unique_lock<std::mutex> lock(self->mutex);
self->wake.wait_for(lock, std::chrono::milliseconds(connected ? 1000 : backoff_ms),
[self, generation] { return self->stopping.load() || self->generation != generation; });
if (self->stopping.load())
break;
}
if (self->session)
self->session->disconnect();
}
// ---------------------------------------------------------------------------
// obs_source_info callbacks
// ---------------------------------------------------------------------------
const char *sourceGetName(void *)
{
return obs_module_text("StreamerToolsCamera");
}
void sourceGetDefaults(obs_data_t *settings)
{
obs_data_set_default_string(settings, kSettingServerUrl, "");
obs_data_set_default_string(settings, kSettingRoomSlug, "");
obs_data_set_default_string(settings, kSettingReadKey, "");
obs_data_set_default_string(settings, kSettingCamera, "");
}
void applySettings(CameraSource *self, obs_data_t *settings)
{
ConnectionConfig config;
config.server_url = settingString(settings, kSettingServerUrl);
config.room_slug = settingString(settings, kSettingRoomSlug);
config.read_key = settingString(settings, kSettingReadKey);
const std::string camera = settingString(settings, kSettingCamera);
{
std::lock_guard<std::mutex> guard(self->mutex);
const bool changed = config.server_url != self->config.server_url ||
config.room_slug != self->config.room_slug ||
config.read_key != self->config.read_key || camera != self->camera_identity;
if (!changed)
return;
self->config = config;
self->camera_identity = camera;
++self->generation;
}
self->wake.notify_all();
}
void *sourceCreate(obs_data_t *settings, obs_source_t *source)
{
auto *self = new CameraSource();
self->source = source;
self->api = std::make_shared<ApiClient>(std::shared_ptr<HttpClient>(createPlatformHttpClient()));
self->session = std::unique_ptr<LiveKitSession>(new LiveKitSession());
self->session->setVideoHandler([self](const VideoFrameData &frame) { outputVideoFrame(self, frame); });
self->session->setAudioHandler([self](const AudioFrameData &frame) { outputAudioFrame(self, frame); });
self->session->setStateHandler([self](SessionState state, const std::string &detail) {
self->setStatus(detail.empty() ? describeSessionState(state) : detail);
self->status_is_error.store(state == SessionState::Failed);
// A camera that stopped publishing must not leave its last frame on
// screen -- that is precisely the stale-media failure this plugin
// exists to avoid. A null frame clears the source.
if (state != SessionState::Connected)
obs_source_output_video(self->source, nullptr);
});
{
std::lock_guard<std::mutex> guard(self->mutex);
self->config.server_url = settingString(settings, kSettingServerUrl);
self->config.room_slug = settingString(settings, kSettingRoomSlug);
self->config.read_key = settingString(settings, kSettingReadKey);
self->camera_identity = settingString(settings, kSettingCamera);
self->generation = 1;
}
self->worker = std::thread([self] { workerLoop(self); });
return self;
}
void sourceUpdate(void *data, obs_data_t *settings)
{
applySettings(static_cast<CameraSource *>(data), settings);
}
void sourceDestroy(void *data)
{
auto *self = static_cast<CameraSource *>(data);
if (!self)
return;
self->stopping.store(true);
self->wake.notify_all();
if (self->worker.joinable())
self->worker.join();
// The worker already disconnected, but do it again explicitly: the
// session's own destructor would too, and all three are idempotent.
if (self->session)
self->session->disconnect();
self->session.reset();
delete self;
}
/// Rebuild the camera dropdown from the cached slot list, always including
/// whatever identity is currently selected so OBS cannot silently clear a
/// setting just because the room is dark right now.
void populateCameraList(CameraSource *self, obs_property_t *list, const std::string &selected)
{
obs_property_list_clear(list);
obs_property_list_add_string(list, obs_module_text("NoCameraSelected"), "");
bool saw_selected = selected.empty();
std::vector<SlotInfo> slots;
{
std::lock_guard<std::mutex> guard(self->mutex);
slots = self->slot_cache;
}
for (const SlotInfo &slot : slots) {
std::string label = slot.display_name;
if (!slot.live)
label += obs_module_text("OfflineSuffix");
obs_property_list_add_string(list, label.c_str(), slot.identity.c_str());
if (slot.identity == selected)
saw_selected = true;
}
if (!saw_selected) {
std::string label = selected + obs_module_text("NotInRoomSuffix");
obs_property_list_add_string(list, label.c_str(), selected.c_str());
}
}
bool refreshButtonClicked(obs_properties_t *props, obs_property_t *, void *data)
{
auto *self = static_cast<CameraSource *>(data);
if (!self)
return false;
ConnectionConfig config;
{
std::lock_guard<std::mutex> guard(self->mutex);
config = self->config;
}
// Deliberately synchronous: the operator pressed a button and is waiting
// for the list to change. The timeout is shortened from the core default
// so a dead server cannot freeze the properties dialog for ten seconds.
const SlotsResult result = self->api->fetchSlots(config, kPropertiesTimeoutMs);
if (result.ok()) {
std::string selected;
{
std::lock_guard<std::mutex> guard(self->mutex);
self->slot_cache = result.slots;
selected = self->camera_identity;
}
if (obs_property_t *list = obs_properties_get(props, kSettingCamera))
populateCameraList(self, list, selected);
self->setStatus(std::to_string(result.slots.size()) + std::string(obs_module_text("CamerasFound")));
self->status_is_error.store(false);
} else {
const std::string message = result.message.empty() ? describeApiStatus(result.status) : result.message;
self->setStatus(message);
self->status_is_error.store(true);
obs_log(LOG_WARNING, "slot listing failed: %s", message.c_str());
}
if (obs_property_t *status = obs_properties_get(props, kSettingStatus)) {
const std::string text = self->statusText();
obs_property_set_description(status, text.c_str());
obs_property_text_set_info_type(status, self->status_is_error.load() ? OBS_TEXT_INFO_WARNING
: OBS_TEXT_INFO_NORMAL);
}
return true; // properties changed, redraw them
}
obs_properties_t *sourceGetProperties(void *data)
{
auto *self = static_cast<CameraSource *>(data);
obs_properties_t *props = obs_properties_create();
obs_properties_add_text(props, kSettingServerUrl, obs_module_text("ServerUrl"), OBS_TEXT_DEFAULT);
obs_properties_add_text(props, kSettingRoomSlug, obs_module_text("RoomSlug"), OBS_TEXT_DEFAULT);
// The read key is a credential and is masked everywhere else in
// streamer-tools; it is masked here too.
obs_properties_add_text(props, kSettingReadKey, obs_module_text("ReadKey"), OBS_TEXT_PASSWORD);
obs_property_t *list = obs_properties_add_list(props, kSettingCamera, obs_module_text("Camera"),
OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING);
if (self) {
std::string selected;
{
std::lock_guard<std::mutex> guard(self->mutex);
selected = self->camera_identity;
}
// Built from the cache the worker keeps warm, so opening properties
// never blocks on the network. The button below is the way to force
// a round trip.
populateCameraList(self, list, selected);
}
obs_properties_add_button(props, kPropRefresh, obs_module_text("RefreshCameras"), refreshButtonClicked);
// An OBS_TEXT_INFO property renders its *description* as the visible
// label, so the status line goes there rather than into a tooltip an
// operator would never hover over mid-show.
const std::string status_text = self ? self->statusText() : std::string(obs_module_text("Status"));
obs_property_t *status = obs_properties_add_text(props, kSettingStatus, status_text.c_str(), OBS_TEXT_INFO);
if (self && self->status_is_error.load())
obs_property_text_set_info_type(status, OBS_TEXT_INFO_WARNING);
return props;
}
struct obs_source_info cameraSourceInfo()
{
struct obs_source_info info = {};
info.id = "streamer_tools_camera_source";
info.type = OBS_SOURCE_TYPE_INPUT;
info.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE;
info.icon_type = OBS_ICON_TYPE_CAMERA;
info.get_name = sourceGetName;
info.create = sourceCreate;
info.destroy = sourceDestroy;
info.update = sourceUpdate;
info.get_defaults = sourceGetDefaults;
info.get_properties = sourceGetProperties;
return info;
}
struct obs_source_info streamer_tools_camera_source = cameraSourceInfo();
void livekitLogToObs(livekit::LogLevel level, const std::string &, const std::string &message)
{
int obs_level = LOG_INFO;
switch (level) {
case livekit::LogLevel::Error:
case livekit::LogLevel::Critical: obs_level = LOG_ERROR; break;
case livekit::LogLevel::Warn: obs_level = LOG_WARNING; break;
case livekit::LogLevel::Info: obs_level = LOG_INFO; break;
default: obs_level = LOG_DEBUG; break;
}
obs_log(obs_level, "livekit: %s", message.c_str());
}
} // namespace
bool obs_module_load(void)
{
LiveKitSession::globalInitialize();
// Route the SDK's own logging into OBS's log file instead of stderr,
// where a director would never see it.
livekit::setLogCallback(livekitLogToObs);
obs_register_source(&streamer_tools_camera_source);
obs_log(LOG_INFO, "streamer-tools camera plugin loaded (core %s)", core_version());
return true;
}
void obs_module_unload(void)
{
livekit::setLogCallback(nullptr);
LiveKitSession::globalShutdown();
obs_log(LOG_INFO, "streamer-tools camera plugin unloaded");
}
+175
View File
@@ -0,0 +1,175 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
+13
View File
@@ -0,0 +1,13 @@
Copyright 2023 LiveKit, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+36
View File
@@ -0,0 +1,36 @@
# LiveKit client-sdk-cpp redistribution notices
This plugin links and **redistributes** prebuilt binaries from
[`livekit/client-sdk-cpp`](https://github.com/livekit/client-sdk-cpp) — the
`liblivekit` / `liblivekit_ffi` shared libraries that ship next to the plugin
module — so the SDK's licence and notice files ship with it.
`LICENSE` and `NOTICE` here are copied verbatim from the pinned release tag
`v1.10.1` (Apache License 2.0). They are staged into `build/package/licenses/`
by `obs-adapter/CMakeLists.txt` on every build, alongside this plugin's own
GPL-2.0 `LICENSE`.
## A correction to the design doc
The design doc's open questions say:
> `client-sdk-cpp`'s bundled `LICENSE.md` (~28 distinct third-party license
> blocks — Google WebRTC, OpenH264, etc.) must ship inside the plugin package
**No such file exists at `v1.10.1`.** Checked, on 2026-09-06:
- The five release archives for this tag (`livekit-sdk-<triple>-1.10.1.tar.gz`
/ `.zip`) contain only `include/`, `lib/`, `bin/` and
`share/livekit/build-info.json`. No licence file of any kind.
- The repository at tag `v1.10.1` has `LICENSE` (Apache-2.0, 10142 bytes) and
`NOTICE` (553 bytes) at its root. There is no `LICENSE.md`, no `NOTICE.md`,
and no `THIRD_PARTY_LICENSES` file.
So what ships here is the Apache-2.0 licence and notice, which is what
actually exists upstream. **The aggregated third-party notice the design doc
expected — covering the WebRTC/OpenH264/etc. code statically linked inside
`liblivekit_ffi.so` — has not been located and is not being shipped.** That
is a real, open licensing question for whoever signs off on distributing
release binaries, not something this packaging step has resolved. Worth
raising upstream, or asking counsel whether the Apache-2.0 NOTICE alone
suffices for a binary redistribution of that library.