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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user