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
+5
View File
@@ -43,6 +43,11 @@ endif()
find_package(Threads REQUIRED)
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
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.
explicit ApiClient(std::shared_ptr<HttpClient> http);
SlotsResult fetchSlots(const ConnectionConfig &config) const;
TokenResult requestToken(const ConnectionConfig &config) const;
/// @param timeout_ms whole-request timeout. Kept as a parameter because
/// 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
/// 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);
}
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const
{
SlotsResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
@@ -132,6 +132,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
HttpRequest request;
request.method = "GET";
request.url = buildUrl(config, "/slots");
request.timeout_ms = timeout_ms;
const HttpResponse response = http_->send(request);
const ApiStatus status = classify(response, result.message);
@@ -170,7 +171,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
return result;
}
TokenResult ApiClient::requestToken(const ConnectionConfig &config) const
TokenResult ApiClient::requestToken(const ConnectionConfig &config, int timeout_ms) const
{
TokenResult result;
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.content_type = "application/json";
request.body = "{}";
request.timeout_ms = timeout_ms;
const HttpResponse response = http_->send(request);
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 {
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 {