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
+54 -5
View File
@@ -1,9 +1,8 @@
# streamer-tools OBS Camera Plugin - OBS adapter
#
# Thin glue only, per the design doc: source registration, (eventually)
# properties UI, and pushing frames into OBS. All real logic lives in
# ../core. Only added to the build when find_package(libobs) succeeds
# (see top-level CMakeLists.txt) -- see README.md for why.
# Thin glue only, per the design doc: source registration, the properties UI,
# and pushing frames into OBS. All real logic lives in ../core. Only added to
# the build when find_package(libobs) succeeds (see top-level CMakeLists.txt).
set(STPLUGIN_PROJECT_NAME "streamer-tools-camera")
set(STPLUGIN_PROJECT_VERSION "${PROJECT_VERSION}")
@@ -15,7 +14,7 @@ configure_file(
)
add_library(${STPLUGIN_PROJECT_NAME} MODULE
src/plugin-main.c
src/plugin-main.cpp
${CMAKE_CURRENT_BINARY_DIR}/plugin-support.c
)
@@ -35,3 +34,53 @@ set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
PREFIX ""
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
# No user-facing strings yet -- this is scaffolding (see plugin-main.c).
StreamerToolsCamera="streamer-tools Camera"
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");
}