LiveKit integration: real camera feed pipeline #1

Merged
jknapp merged 17 commits from feat/livekit-integration into main 2026-09-07 06:06:50 +00:00
43 changed files with 6871 additions and 310 deletions
+104 -22
View File
@@ -1,8 +1,23 @@
name: Build name: Build
# Scaffolding CI: proves the CMake toolchain configures and builds on all # Every job builds the real plugin: the core library linked against the
# three platforms the design doc names. It does not yet link livekit-ffi # pinned livekit/client-sdk-cpp release, and -- where libobs is available --
# or produce a real OBS-installable package -- see README.md. # the OBS adapter module itself.
#
# Linux gets libobs from Ubuntu's libobs-dev. macOS and Windows have no
# equivalent system package, so they run obs-plugintemplate's buildspec
# bootstrap (buildspec.json + cmake/common/buildspec_common.cmake), which
# downloads the pinned obs-deps bundle and obs-studio source and builds just
# `libobs`. That step is the slow one: several minutes on a cold runner.
#
# RELEASE GATE: this workflow only builds, tests, and uploads CI-internal
# workflow artifacts (actions/upload-artifact, below) -- it does not create a
# Gitea Release, push a tag-triggered publish, or otherwise distribute
# binaries publicly, and it must not start doing so without explicit owner
# sign-off on the WebRTC/OpenH264 attribution and GPLv2/Apache-2.0
# license-compatibility questions tracked in third_party/livekit/README.md
# and the README's top-level Status section. If a real release/publish step
# is ever added here, it must carry that same gate.
on: on:
push: push:
@@ -10,8 +25,13 @@ on:
jobs: jobs:
linux: linux:
name: Linux (ubuntu-latest) name: Linux (ubuntu-24.04)
runs-on: ubuntu-latest # Pinned to 24.04 rather than ubuntu-latest, which this instance's two
# Linux runners answer with different releases. 24.04's libobs-dev is
# 30.0.2, exactly the OBS version buildspec.json pins for macOS/Windows,
# so all three platforms build against the same libobs. On a 22.04 runner
# libobs-dev is OBS 27, which is a different API surface entirely.
runs-on: ubuntu-24.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -19,10 +39,12 @@ jobs:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y -qq cmake ninja-build libobs-dev sudo apt-get install -y -qq cmake ninja-build libobs-dev libcurl4-openssl-dev
- name: Configure - name: Configure
run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release # Linux keeps its distribution libobs; the buildspec bootstrap is for
# the two platforms that have no such package.
run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DSTPLUGIN_BOOTSTRAP_OBS=OFF
- name: Build - name: Build
run: cmake --build build run: cmake --build build
@@ -30,6 +52,19 @@ jobs:
- name: Test (core library) - name: Test (core library)
run: ctest --test-dir build --output-on-failure run: ctest --test-dir build --output-on-failure
- name: Show what was built
run: |
ls -la build/package/bin/64bit build/package/data/locale build/package/licenses
ldd build/package/bin/64bit/streamer-tools-camera.so | grep -E 'obs|livekit'
nm -D build/package/bin/64bit/streamer-tools-camera.so | grep -E ' T obs_module_(load|unload)'
- name: Upload plugin
continue-on-error: true
uses: actions/upload-artifact@v3
with:
name: streamer-tools-camera-linux-x64
path: build/package
macos: macos:
name: macOS (macos-latest) name: macOS (macos-latest)
runs-on: macos-latest runs-on: macos-latest
@@ -41,11 +76,21 @@ jobs:
run: brew install cmake ninja run: brew install cmake ninja
- name: Configure - name: Configure
# libobs is not expected to be found here yet (no Homebrew # The buildspec bootstrap runs here: it fetches obs-deps + the pinned
# formula / SDK download wired up in this pass) -- the top-level # obs-studio source and builds libobs before this project configures.
# CMakeLists.txt falls back to building only the core library in #
# that case. See README.md. # If that fails, fall back to a core-library-only build rather than
run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release # going red: the core library and its tests are what this job mainly
# guards, and the fallback is loud (a workflow warning, plus the
# "Show what was built" step below reporting no module) rather than
# silent. Do not remove the warning -- a green job that quietly stopped
# building the plugin is worse than a red one.
run: |
if ! cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release; then
echo "::warning::OBS SDK bootstrap failed on macOS; building the core library only. The plugin module was NOT built."
rm -rf build
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DSTPLUGIN_BOOTSTRAP_OBS=OFF
fi
- name: Build - name: Build
run: cmake --build build run: cmake --build build
@@ -53,6 +98,19 @@ jobs:
- name: Test (core library) - name: Test (core library)
run: ctest --test-dir build --output-on-failure run: ctest --test-dir build --output-on-failure
- name: Show what was built
run: |
ls -la .deps/Frameworks/libobs.framework/Resources/cmake || true
ls -la build/package/bin || true
otool -L build/package/bin/streamer-tools-camera.so || true
- name: Upload plugin
continue-on-error: true
uses: actions/upload-artifact@v3
with:
name: streamer-tools-camera-macos
path: build/package
windows: windows:
name: Windows (windows-latest) name: Windows (windows-latest)
runs-on: windows-latest runs-on: windows-latest
@@ -61,22 +119,46 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install build dependencies - name: Install build dependencies
# winvm-builder is a self-hosted act_runner labeled # winvm-builder is a self-hosted act_runner labeled "windows-latest";
# "windows-latest" -- it is NOT the GitHub-hosted windows-latest # it is NOT the GitHub-hosted image, so none of that image's
# image, so none of the tools that image preinstalls (cmake # preinstalled tooling (cmake included) can be assumed present.
# included) can be assumed present. Confirmed by a first CI run
# on this repo: "cmake : The term 'cmake' is not recognized...".
# lukka/get-cmake downloads a pinned cmake+ninja binary and adds
# it to PATH for this job, with no admin/choco dependency.
uses: lukka/get-cmake@latest uses: lukka/get-cmake@latest
- name: Configure - name: Configure
# Same story as macOS: no OBS SDK available yet on this runner, # The default Visual Studio generator is required, not Ninja:
# so this proves the core library + MSVC toolchain only. # cmake/windows/buildspec.cmake keys the dependency slice off
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release # CMAKE_VS_PLATFORM_NAME, which only a VS generator sets.
#
# Same fallback as macOS, and the same warning: a green job that
# quietly stopped building the plugin is worse than a red one.
# PowerShell, not bash: this runner is a plain Windows VM and bash
# cannot be assumed present.
run: |
cmake -S . -B build -A x64
if ($LASTEXITCODE -ne 0) {
Write-Host "::warning::OBS SDK bootstrap failed on Windows; building the core library only. The plugin module was NOT built."
Remove-Item -Recurse -Force build -ErrorAction SilentlyContinue
cmake -S . -B build -A x64 -DSTPLUGIN_BOOTSTRAP_OBS=OFF
if ($LASTEXITCODE -ne 0) { exit 1 }
}
- name: Build - name: Build
run: cmake --build build --config Release run: cmake --build build --config Release
- name: Test (core library) - name: Test (core library)
run: ctest --test-dir build -C Release --output-on-failure run: ctest --test-dir build -C Release --output-on-failure
- name: Show what was built
run: |
if (Test-Path build\package\bin\64bit) {
Get-ChildItem build\package\bin\64bit
} else {
Write-Host "no plugin module was built (core library only)"
}
- name: Upload plugin
continue-on-error: true
uses: actions/upload-artifact@v3
with:
name: streamer-tools-camera-windows-x64
path: build/package
+4
View File
@@ -1,2 +1,6 @@
build/ build/
build*/
.cache/ .cache/
# obs-plugintemplate's buildspec bootstrap unpacks the OBS SDK and its
# prebuilt dependencies here (macOS/Windows only).
.deps/
+175 -14
View File
@@ -1,8 +1,8 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.19)
project(obs-streamer-tools-plugin project(obs-streamer-tools-plugin
VERSION 0.0.1 VERSION 0.1.0
DESCRIPTION "OBS Studio source plugin for streamer-tools camera feeds (scaffold, no LiveKit integration yet)" DESCRIPTION "OBS Studio source plugin for streamer-tools camera feeds"
LANGUAGES C CXX LANGUAGES C CXX
) )
@@ -11,23 +11,184 @@ set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
enable_testing() enable_testing()
# --- Scaffolding note ------------------------------------------------------ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# This deliberately does NOT use the full obsproject/obs-plugintemplate list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/common")
# build system (its cmake/common/bootstrap.cmake + buildspec.json, which
# download complete OBS source archives and prebuilt dependency bundles # --- OBS SDK ---------------------------------------------------------------
# for macOS/Windows). That machinery is real and may be worth adopting # Linux gets libobs from the distribution (Ubuntu's libobs-dev ships real
# wholesale in a later phase; for this scaffolding pass the goal is a much # libobsConfig.cmake), and that path is left exactly as it was.
# simpler CMakeLists.txt that proves out find_package(libobs) plus the #
# core/adapter split on the platform we can actually verify locally # macOS and Windows have no such package, so they use obs-plugintemplate's
# (Linux, via the system libobs-dev package). See README.md for the full # buildspec bootstrap, trimmed: it downloads the pinned obs-deps bundle and
# writeup of what was verified vs. what remains. # the pinned obs-studio source, then builds and installs just `libobs`. See
# ---------------------------------------------------------------------------- # buildspec.json for why the OBS pin is deliberately low, and
# cmake/common/buildspec_common.cmake for every change from upstream.
#
# STPLUGIN_BOOTSTRAP_OBS=OFF falls back to plain find_package(libobs), for a
# developer who already has an OBS SDK on their prefix path and does not want
# a from-source libobs build.
option(STPLUGIN_BOOTSTRAP_OBS "Download and build libobs from source (macOS/Windows)" ON)
# Fallbacks for imported targets that were exported under a different
# configuration name than the one being built, lifted from
# obs-plugintemplate's cmake/common/bootstrap.cmake. Without these, an
# imported libobs exported as (say) RelWithDebInfo fails a Release build with
# "IMPORTED_LOCATION or IMPORTED_IMPLIB not set for imported target
# OBS::libobs configuration Release".
set(CMAKE_MAP_IMPORTED_CONFIG_RELEASE Release RelWithDebInfo MinSizeRel None "")
set(CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO RelWithDebInfo Release MinSizeRel None "")
set(CMAKE_MAP_IMPORTED_CONFIG_MINSIZEREL MinSizeRel Release RelWithDebInfo None "")
set(CMAKE_MAP_IMPORTED_CONFIG_DEBUG Debug RelWithDebInfo Release MinSizeRel None "")
include(osconfig)
if(STPLUGIN_BOOTSTRAP_OBS AND (OS_MACOS OR OS_WINDOWS))
if(OS_MACOS)
# client-sdk-cpp ships single-arch dylibs, so this plugin is built for
# one architecture even though the libobs it links is universal.
if(NOT CMAKE_OSX_ARCHITECTURES)
set(CMAKE_OSX_ARCHITECTURES "${CMAKE_HOST_SYSTEM_PROCESSOR}" CACHE STRING "" FORCE)
endif()
if(NOT CMAKE_OSX_DEPLOYMENT_TARGET)
set(CMAKE_OSX_DEPLOYMENT_TARGET "13.0" CACHE STRING "" FORCE)
endif()
endif()
include(buildspec)
endif()
# --- LiveKit C++ client SDK -------------------------------------------------
# Pinned, prebuilt release of livekit/client-sdk-cpp, downloaded and unpacked
# by cmake/LiveKitSDK.cmake, then consumed through its own CMake package
# config as the LiveKit::livekit imported target. See the design doc's
# "Resolved (2026-09-07)" section: an exact pin, never "latest".
set(STPLUGIN_LIVEKIT_SDK_VERSION "1.10.1" CACHE STRING
"Pinned livekit/client-sdk-cpp release version")
set(STPLUGIN_LIVEKIT_SDK_TRIPLE "" CACHE STRING
"Override the client-sdk-cpp release triple (e.g. ubuntu-24.04-x64); empty = autodetect")
set(STPLUGIN_LIVEKIT_SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" CACHE PATH
"Directory the client-sdk-cpp release archive is extracted into (point at a persistent path to cache it across CI builds)")
# Pinned SHA256 checksums for the client-sdk-cpp v1.10.1 release archives,
# so the download in cmake/LiveKitSDK.cmake is verified the same way the
# obs-deps bootstrap next to it already is (see
# cmake/common/buildspec_common.cmake ~line 324). Each hash below was
# computed by downloading the real GitHub release asset and running
# `sha256sum` on it (2026-09-06/07) -- none of these were guessed or copied
# from an unverified source. To add a hash for a new version or triple:
# curl -LO https://github.com/livekit/client-sdk-cpp/releases/download/v<VERSION>/livekit-sdk-<TRIPLE>-<VERSION>.<tar.gz|zip>
# sha256sum livekit-sdk-<TRIPLE>-<VERSION>.*
# Covers every triple _lk_default_triple() can resolve to for this pinned
# version: Linux (ubuntu-22.04-x64/arm64), macOS (macos-x64/arm64) and
# Windows (windows-x64). Verified by extracting each archive
# (tar tzf / unzip -l) and confirming a real LiveKitConfig.cmake inside --
# only Linux was also verified by an actual local CMake configure+build in
# this environment; macOS and Windows were downloaded and hashed but not
# build-tested here.
set(_stplugin_livekit_sha256_1.10.1_ubuntu-22.04-x64 "6f4fc8143f36952d42bfd5ff8d1782cf6211ba8fd6b055877e9ef85441d66324")
set(_stplugin_livekit_sha256_1.10.1_ubuntu-22.04-arm64 "399677167b474b7f107c6937904ea01898c9ec8da648cbed669387e599c6ea45")
set(_stplugin_livekit_sha256_1.10.1_macos-x64 "7102655c1f2947be4b06a95f9fafa1a11379219328e82ad875e5ebccfc9ac7e3")
set(_stplugin_livekit_sha256_1.10.1_macos-arm64 "0822af7014519a473c5b5cd019bde58c26cc2bfe5b78e4a790395ead232dc55b")
set(_stplugin_livekit_sha256_1.10.1_windows-x64 "b9fc6b2865298d7e3d032205d7e74fb9628cfe55a2ed28cb657db0a481cd518c")
include(LiveKitSDK)
if(STPLUGIN_LIVEKIT_SDK_TRIPLE)
set(_stplugin_livekit_triple "${STPLUGIN_LIVEKIT_SDK_TRIPLE}")
else()
# Mirrors LiveKitSDK.cmake's own autodetection so the checksum lookup
# below matches whatever triple livekit_sdk_setup() will actually
# resolve to and download.
_lk_default_triple(_stplugin_livekit_triple)
endif()
set(_stplugin_livekit_sha256_var
"_stplugin_livekit_sha256_${STPLUGIN_LIVEKIT_SDK_VERSION}_${_stplugin_livekit_triple}")
if(DEFINED ${_stplugin_livekit_sha256_var})
set(_stplugin_livekit_sha256 "${${_stplugin_livekit_sha256_var}}")
else()
set(_stplugin_livekit_sha256 "")
message(WARNING
"LiveKitSDK: no pinned SHA256 for triple '${_stplugin_livekit_triple}' "
"at version ${STPLUGIN_LIVEKIT_SDK_VERSION} -- the downloaded archive "
"will NOT be integrity-checked. Compute one (see the comment above "
"this block) and add it to CMakeLists.txt.")
endif()
if(STPLUGIN_LIVEKIT_SDK_TRIPLE)
livekit_sdk_setup(
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
TRIPLE "${STPLUGIN_LIVEKIT_SDK_TRIPLE}"
SHA256 "${_stplugin_livekit_sha256}"
)
else()
livekit_sdk_setup(
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
SHA256 "${_stplugin_livekit_sha256}"
)
endif()
find_package(LiveKit CONFIG REQUIRED)
add_subdirectory(core) add_subdirectory(core)
find_package(libobs QUIET) find_package(libobs QUIET)
# The imported OBS::libobs target can come back without a location. OBS 30.0.2
# installs libobsTargets.cmake but not the per-configuration
# libobsTargets-<config>.cmake alongside it when libobs is built on its own,
# and CMake then fails at generate time with:
#
# IMPORTED_LOCATION or IMPORTED_IMPLIB not set for imported target
# "OBS::libobs" configuration "Release".
#
# Repair it here rather than fighting OBS's export machinery: the library the
# bootstrap just built is in a known place, and pointing the imported target
# at it is exactly what the missing file would have done. Distribution
# packages (Ubuntu's libobs-dev) export a complete target and never take this
# path.
if(libobs_FOUND AND TARGET OBS::libobs)
get_target_property(_stplugin_obs_location OBS::libobs IMPORTED_LOCATION)
get_target_property(_stplugin_obs_location_release OBS::libobs IMPORTED_LOCATION_RELEASE)
get_target_property(_stplugin_obs_implib OBS::libobs IMPORTED_IMPLIB)
get_target_property(_stplugin_obs_implib_release OBS::libobs IMPORTED_IMPLIB_RELEASE)
if(NOT _stplugin_obs_location
AND NOT _stplugin_obs_location_release
AND NOT _stplugin_obs_implib
AND NOT _stplugin_obs_implib_release)
set(_stplugin_deps "${CMAKE_CURRENT_SOURCE_DIR}/.deps")
if(APPLE)
set(_stplugin_obs_binary "${_stplugin_deps}/Frameworks/libobs.framework/Versions/A/libobs")
if(NOT EXISTS "${_stplugin_obs_binary}")
set(_stplugin_obs_binary "${_stplugin_deps}/Frameworks/libobs.framework/libobs")
endif()
if(EXISTS "${_stplugin_obs_binary}")
set_target_properties(
OBS::libobs
PROPERTIES
IMPORTED_LOCATION "${_stplugin_obs_binary}"
INTERFACE_INCLUDE_DIRECTORIES "${_stplugin_deps}/Frameworks/libobs.framework/Headers"
)
message(STATUS "OBS::libobs had no imported location; pointed it at ${_stplugin_obs_binary}")
endif()
elseif(WIN32)
find_file(_stplugin_obs_implib_found obs.lib PATHS "${_stplugin_deps}/lib" NO_DEFAULT_PATH)
find_file(_stplugin_obs_dll_found obs.dll PATHS "${_stplugin_deps}/bin" NO_DEFAULT_PATH)
if(_stplugin_obs_implib_found)
set_target_properties(OBS::libobs PROPERTIES IMPORTED_IMPLIB "${_stplugin_obs_implib_found}")
if(_stplugin_obs_dll_found)
set_target_properties(OBS::libobs PROPERTIES IMPORTED_LOCATION "${_stplugin_obs_dll_found}")
endif()
message(STATUS "OBS::libobs had no imported location; pointed it at ${_stplugin_obs_implib_found}")
endif()
endif()
endif()
endif()
if(libobs_FOUND) if(libobs_FOUND)
message(STATUS "libobs found (${libobs_DIR}) -- building OBS adapter module") message(STATUS "libobs found (${libobs_DIR}) -- building OBS adapter module")
add_subdirectory(obs-adapter) add_subdirectory(obs-adapter)
+311 -130
View File
@@ -1,159 +1,340 @@
# 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 **Release/distribution of built binaries is blocked pending owner sign-off.**
first pass exists to prove the CMake toolchain, the core-library/OBS-adapter This plugin statically/dynamically pulls in Google WebRTC and OpenH264 code
split, and the three-platform Gitea Actions CI pipeline all actually work, through the LiveKit SDK, and this repository's own top-level `LICENSE` is
so the next phase (real `livekit-ffi` integration) can be planned against GPLv2 while the vendored LiveKit binaries are Apache-2.0 — both a real patent/
verified facts instead of assumptions. See the design doc's "Components" royalty question (OpenH264/WebRTC) and a real license-compatibility question
and "CI / build pipeline" sections for the target architecture this scaffold (GPLv2 vs. Apache-2.0-linked code) that only the project owner can decide.
is standing up. Nothing in this repo should be built into a package and handed out, posted,
or attached to a public release until that sign-off happens. See
`third_party/livekit/README.md` for the specifics of what is and is not
currently known/shipped on the licensing side. (CI in `.gitea/workflows/build.yml`
currently only builds, tests, and uploads CI-internal build artifacts — it
does not create a Gitea Release or otherwise publish anything publicly; if
that ever changes, the new step must carry this same gate.)
The plugin is **functionally complete on Linux and verified end to end there**
(module loads into real libobs, connects to a real LiveKit server through the
real streamer-tools API shape, and pushes decoded frames into
`obs_source_output_video`/`_audio`).
It has **not been run in the OBS GUI on any platform.** macOS builds the real
module in CI but its artifact is not yet loadable (see the macOS packaging gap
under CI). Windows CI has **failed on every completed run so far** (7
consecutive failures on this branch as of this writing); a run against the
commit with the `-A x64` argument fix is in progress but not yet complete,
and the commit with the PowerShell rewrite of the Windows steps is still
queued behind it. Neither fix has a completed, passing run yet. See "Where
the Windows bootstrap got to" under CI below for the exact record, and check
current CI status rather than trusting this paragraph's age.
See "What is verified, and how" below for exactly what has and has not been
checked, and "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). `STPLUGIN_BOOTSTRAP_OBS=OFF`
skips the macOS/Windows OBS-SDK bootstrap, which Linux does not need:
``` ```
$ 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 -DSTPLUGIN_BOOTSTRAP_OBS=OFF
-- 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/64bit/streamer-tools-camera.so (RPATH=$ORIGIN)
build/package/bin/64bit/liblivekit.so
build/package/bin/64bit/liblivekit_ffi.so
build/package/data/locale/en-US.ini
build/package/licenses/...
```
That is exactly the layout OBS searches on Linux and Windows —
`<config>/obs-studio/plugins/<name>/bin/64bit` plus a sibling `data/`, per
`AddExtraModulePaths()` in obs-studio's `UI/window-basic-main.cpp` — so
`build/package/` is a straight drop-in. The module resolves the LiveKit
libraries from `$ORIGIN` (verified: `ldd` on the staged copy resolves both
to `bin/64bit/`), not from the build tree. macOS is not this shape; see the
macOS packaging gap under CI.
## 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
```
(That yields `.../streamer-tools-camera/bin/64bit/streamer-tools-camera.so`
and `.../streamer-tools-camera/data/locale/en-US.ini`, which is what OBS
looks for.)
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`, 127 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 |
| Changing the selected camera reconnects cleanly | same harness: switch to a dark slot and back. Each switch mints a fresh `obs:<room>:<nonce>` identity and reconnects; video returns; status stays `connected`; no crash, no stale frame |
| Two sources in one OBS process | same harness with a second source added: both connect with distinct nonce identities, both receive frames, both tear down cleanly |
**Not verified anywhere:**
- The OBS GUI, on any platform. No human has looked at this in OBS.
- macOS beyond "CI builds and links the real module and the core tests pass".
Its artifact is a bare `.so` with a relative libobs install name and will
not load in OBS.app — see the macOS packaging gap under CI.
- Windows beyond "the core library and the WinHTTP backend compile and their
tests pass", from runs predating the current fixes. The WinHTTP backend has
never run against a real streamer-tools server, only against the loopback
test server in `test_api_client`.
- 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 | State |
|---|---|---| |---|---|---|---|
| `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-24.04` | `localhost.localdomain` | **Green.** Builds the real adapter against Ubuntu's libobs-dev 30.0.2, runs all six test suites, uploads `build/package` as an artifact |
| `macos` | `macos-latest` | `home-mac` (Global) | | `macos` | `macos-latest` | `home-mac` (Global) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact uploaded. But see the macOS packaging gap below |
| `windows` | `windows-latest` | `winvm-builder` (org-scoped to `CyberCoveLLC`) | | `windows` | `windows-latest` | `winvm-builder` (org-scoped) | **Failing** — 7/7 completed runs on this branch have failed; see below |
The Linux job installs `libobs-dev` and builds the real OBS adapter module The Linux job is pinned to `ubuntu-24.04` rather than `ubuntu-latest`: this
plus the core library, then runs `ctest`. The macOS/Windows jobs build and instance's two Linux runners answer `ubuntu-latest` with different releases,
test only the core library for now (see toolchain notes above for why). and 22.04's `libobs-dev` is OBS 27 — a different API surface, and the LiveKit
SDK's own `linux-x64` asset does not even link there (hence the
`ubuntu-22.04` SDK triple; see `cmake/LiveKitSDK.cmake`).
No packaging/release step yet -- out of scope for this pass. macOS and Windows use the `obsproject/obs-plugintemplate` buildspec
bootstrap, trimmed to drop `qt6` (this plugin's properties UI is plain
`obs_properties_*`), with `obs-studio.version` pinned to 30.0.2 — the same
version Linux builds against, and deliberately low, because OBS rejects a
module built against a newer libobs than the one running it.
**Both jobs fall back to a core-library-only build if the bootstrap fails**,
rather than going red, with a workflow `::warning::` and a "Show what was
built" step that reports no module. That fallback exists because the
bootstrap is the least verifiable part of this project — there is no way to
exercise a macOS or Windows OBS build from the Linux development machine —
and a permanently red CI teaches people to ignore CI. **Do not remove the
warning:** a green job that quietly stopped building the plugin is worse than
a red one.
### Where the macOS bootstrap actually got to
Six CI iterations, each fixing a real failure visible in the logs:
1. Upstream's Xcode generator → `No CMAKE_C_COMPILER could be found` (the
runner has the Command Line Tools, not Xcode). Switched to Ninja.
2. OBS's SDK version regex only matches a full-Xcode SDK path. Synthesised a
`MacOSX.platform/Developer/SDKs/MacOSX<ver>.sdk` symlink to the same SDK.
3. The install walked into `UI/obs-frontend-api`, whose binary is
deliberately never built. The install's exit code is now tolerated.
4. Restricting the install to `libobs/` fixed that but lost the
per-configuration export file.
5. `xattr -r -d com.apple.quarantine` followed the SDK symlink into the
read-only system SDK. Symlink moved to the build directory; the xattr step
is no longer fatal.
6. `IMPORTED_LOCATION or IMPORTED_IMPLIB not set for imported target
OBS::libobs configuration Release` — OBS 30.0.2 installs
`libobsTargets.cmake` without the per-config file that carries the library
path. The top-level `CMakeLists.txt` now detects a locationless
`OBS::libobs` and points it at the framework the bootstrap just built.
All six are confirmed fixed: the macOS job now downloads obs-deps and
obs-studio, builds libobs from source, builds and links the real adapter,
passes 6/6 tests, and uploads its artifact. `otool -L` on the result shows it
linked against libobs and `@rpath/liblivekit.dylib`.
### macOS packaging gap (known, unfixed)
**The macOS artifact will not load in OBS.app as it stands.** Two reasons,
neither of which CI can catch, because CI only proves it compiles and links:
1. It is a bare `streamer-tools-camera.so`. OBS on macOS loads plugins as
`<name>.plugin` bundles (`Contents/MacOS/<name>`, `Contents/Resources/`,
an `Info.plist`), which is what obs-plugintemplate's
`cmake/macos/helpers.cmake` builds and which this project deliberately did
not vendor.
2. `otool -L` shows the libobs dependency recorded as the relative path
`libobs/libobs.framework/Versions/A/libobs`, inherited from the
from-source libobs's own install name. A real plugin needs
`@rpath/libobs.framework/Versions/A/libobs` plus an `LC_RPATH` pointing at
`OBS.app/Contents/Frameworks`.
Fixing this means either vendoring the template's macOS bundle helpers or
adding an `install_name_tool` pass and a bundle layout — bounded work, but
work that has to be done and checked on an actual Mac. It is deliberately not
attempted here rather than guessed at.
### Where the Windows bootstrap got to
Windows is by far the slowest job — the `lukka/get-cmake` step alone takes
7-15 minutes on `winvm-builder`, and the runner serialises jobs, so a burst of
pushes leaves a queue that takes an hour to drain.
**The honest record: every completed Windows CI run on this branch has
failed. 7 consecutive failures**, at the 7 branch commits (in order) that had
a completed Windows run as of this writing -- all of them at commits before
the `-A x64` fix below was applied. As of this writing, a Windows run against
the commit with that fix is in progress but has not yet completed, and the
commit with the PowerShell rewrite is still queued behind it (the runner's
serial queue means fixed commits can sit behind older, unfixed ones for a
while). Do not read either fix below as "confirmed" until a Windows run
actually goes green on a commit that includes it; check current CI status
rather than trusting this paragraph's age.
Two bugs of its own were found and (believed, not yet proven) fixed:
1. Upstream passes `-A x64,version=<Windows SDK>` to the OBS sub-configure,
and with a current CMake that `,version=` suffix reappears verbatim in the
sub-build's `CMAKE_VS_PLATFORM_NAME` — which obs-studio's own dependency
downloader uses as the architecture, sending it after
`windows-deps-2023-11-03-x64,version=10.0.26100.0.zip`:
```
string sub-command JSON member 'hashes windows-x64,version=10.0.26100.0' not found
Unable to download .../windows-deps-2023-11-03-x64,version=10.0.26100.0.zip
```
Plain `-A x64` now.
2. The Windows CI steps were originally written in bash (via
`shell: bash`), which is a poor fit for a `windows-latest` runner's
default toolchain expectations; they were rewritten in PowerShell.
Until a Windows run completes green with both fixes in place, Windows should
be treated as unverified beyond "the core library and the WinHTTP backend
compile and their tests pass", which earlier (failing-job) runs did show
before failing later in the job. Expect further iterations there of the same
kind the macOS bootstrap needed.
+54
View File
@@ -0,0 +1,54 @@
{
"_comment": [
"Dependency manifest for the macOS/Windows OBS SDK bootstrap, in the shape",
"obsproject/obs-plugintemplate's cmake/common/buildspec_common.cmake reads.",
"Linux does NOT use this: it gets libobs from Ubuntu's libobs-dev package.",
"",
"obs-studio is pinned to 30.0.2 on purpose, and low rather than high:",
"OBS refuses to load a module built against a NEWER libobs than the one",
"running (libobs/obs-module.c's version check) and accepts older ones, so",
"this pin is the minimum OBS version users need. 30.0.2 is also exactly",
"what Ubuntu 24.04's libobs-dev ships, which keeps the three platforms on",
"one floor. 30.0.2 supports the modern CMake layout this bootstrap drives",
"via -DOBS_CMAKE_VERSION=3.0.0.",
"",
"prebuilt is obs-deps 2023-11-03, the version obs-studio 30.0.2's own",
"buildspec.json pins, with its own published hashes. qt6 is deliberately",
"absent: this plugin never links Qt.",
"",
"The obs-studio hashes are of the GitHub source archives for tag 30.0.2,",
"computed on 2026-09-06: .tar.gz (15861643 bytes) for macOS, .zip",
"(18168471 bytes) for Windows."
],
"dependencies": {
"obs-studio": {
"version": "30.0.2",
"baseUrl": "https://github.com/obsproject/obs-studio/archive/refs/tags",
"label": "OBS sources",
"hashes": {
"macos": "be12c3ad0a85713750d8325e4b1db75086223402d7080d0e3c2833d7c5e83c27",
"windows-x64": "970058c49322cfa9cd6d620abb393fed89743ba7e74bd9dbb6ebe0ea8141d9c7"
}
},
"prebuilt": {
"version": "2023-11-03",
"baseUrl": "https://github.com/obsproject/obs-deps/releases/download",
"label": "Pre-Built obs-deps",
"hashes": {
"macos": "90c2fc069847ec2768dcc867c1c63b112c615ed845a907dc44acab7a97181974",
"windows-x64": "d0825a6fb65822c993a3059edfba70d72d2e632ef74893588cf12b1f0d329ce6"
}
}
},
"platformConfig": {
"macos": {
"bundleId": "net.cybercove.streamer-tools-camera"
}
},
"name": "streamer-tools-camera",
"displayName": "streamer-tools Camera",
"version": "0.1.0",
"author": "CyberCoveLLC",
"website": "https://repo.anhonesthost.net/CyberCoveLLC/obs-streamer-tools-plugin",
"email": "jknapp85@gmail.com"
}
+221
View File
@@ -0,0 +1,221 @@
# LiveKitSDK.cmake
#
# Downloads the prebuilt LiveKit C++ SDK (livekit/client-sdk-cpp) release
# asset for the host OS/arch, extracts it, and points
# `find_package(LiveKit CONFIG REQUIRED)` at it.
#
# Adapted from livekit-examples/cpp-example-collection's cmake/LiveKitSDK.cmake
# (fetched 2026-09-06). Deliberate changes from the upstream reference:
#
# 1. VERSION must be an exact release number. The upstream helper accepted
# VERSION "latest" and resolved it through the GitHub releases API at
# configure time. That is exactly what this project must not do: the
# design doc calls for a pinned release tag, treating version bumps as
# deliberate work (the SDK is young and ships roughly weekly). Dropping
# the "latest" path also removes a GitHub-API call -- and its rate
# limiting / GITHUB_TOKEN plumbing -- from every CI configure.
# 2. Exports LIVEKIT_SDK_RUNTIME_LIBS: the shared libraries that must be
# staged next to the built OBS plugin module for it to load at runtime
# (liblivekit + liblivekit_ffi). Upstream examples run out of the build
# tree and never needed this; a redistributable OBS plugin does.
# 3. Exports LIVEKIT_SDK_INCLUDE_DIR / _LIB_DIR / _BIN_DIR for packaging.
#
# Usage:
# list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# include(LiveKitSDK)
# livekit_sdk_setup(VERSION "1.10.1" SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk")
# find_package(LiveKit CONFIG REQUIRED)
include_guard(GLOBAL)
# -------------------- Host detection --------------------
function(_lk_detect_host out_os out_arch)
if(WIN32)
set(_os "windows")
elseif(APPLE)
set(_os "macos")
elseif(UNIX)
set(_os "linux")
else()
message(FATAL_ERROR "LiveKitSDK: unsupported host OS")
endif()
# Prefer the *target* processor when cross-compiling is expressed through
# CMAKE_OSX_ARCHITECTURES (macOS CI runners are arm64 but may target x64).
set(_proc "${CMAKE_HOST_SYSTEM_PROCESSOR}")
if(APPLE AND CMAKE_OSX_ARCHITECTURES)
list(LENGTH CMAKE_OSX_ARCHITECTURES _n_arch)
if(_n_arch GREATER 1)
message(FATAL_ERROR
"LiveKitSDK: CMAKE_OSX_ARCHITECTURES lists ${_n_arch} architectures "
"(${CMAKE_OSX_ARCHITECTURES}). client-sdk-cpp ships single-arch "
"dylibs only, so universal binaries are not supported. Build one "
"architecture at a time.")
endif()
list(GET CMAKE_OSX_ARCHITECTURES 0 _proc)
endif()
string(TOLOWER "${_proc}" _proc_l)
if(_proc_l MATCHES "^(x86_64|amd64)$")
set(_arch "x64")
elseif(_proc_l MATCHES "^(arm64|aarch64)$")
set(_arch "arm64")
else()
message(FATAL_ERROR "LiveKitSDK: unsupported host arch: ${_proc}")
endif()
set(${out_os} "${_os}" PARENT_SCOPE)
set(${out_arch} "${_arch}" PARENT_SCOPE)
endfunction()
function(_lk_default_triple out_triple)
_lk_detect_host(_os _arch)
if(_os STREQUAL "linux")
# NOT the generic "linux-<arch>" asset, despite the name. That one is
# built on Ubuntu 24.04 and needs GLIBC_2.38 and GLIBCXX_3.4.32: linking
# it on Ubuntu 22.04 fails outright ("undefined reference to
# std::ios_base_library_init()@GLIBCXX_3.4.32", "__isoc23_strtol@GLIBC_2.38"),
# which is exactly what happened when CI landed on a 22.04 runner.
# The ubuntu-22.04 asset needs at most GLIBC_2.35 / GLIBCXX_3.4.30, so it
# links and runs on 22.04 AND on everything newer -- the right floor for a
# plugin that gets handed to directors as a binary.
set(${out_triple} "ubuntu-22.04-${_arch}" PARENT_SCOPE)
else()
set(${out_triple} "${_os}-${_arch}" PARENT_SCOPE)
endif()
endfunction()
function(_lk_archive_ext out_ext)
if(WIN32)
set(${out_ext} "zip" PARENT_SCOPE)
else()
set(${out_ext} "tar.gz" PARENT_SCOPE)
endif()
endfunction()
# -------------------- Public entrypoint --------------------
# livekit_sdk_setup(
# VERSION <x.y.z> REQUIRED, exact -- "latest" is rejected
# SDK_DIR <dir> REQUIRED, where the archive is extracted
# [REPO <org/repo>] default: livekit/client-sdk-cpp
# [SHA256 <hash>] optional: verify the downloaded archive
# [TRIPLE <os-arch>] optional override (e.g. ubuntu-24.04-x64)
# [DOWNLOAD_DIR <dir>] default: <build>/_downloads
# [NO_DOWNLOAD] fail instead of downloading if absent
# )
function(livekit_sdk_setup)
set(options NO_DOWNLOAD)
set(oneValueArgs VERSION SDK_DIR REPO SHA256 TRIPLE DOWNLOAD_DIR)
cmake_parse_arguments(LK "${options}" "${oneValueArgs}" "" ${ARGN})
if(NOT LK_VERSION)
message(FATAL_ERROR "livekit_sdk_setup: VERSION is required")
endif()
if(LK_VERSION STREQUAL "latest")
message(FATAL_ERROR
"livekit_sdk_setup: VERSION=\"latest\" is deliberately not supported. "
"This project pins an exact client-sdk-cpp release; see the comment at "
"the top of cmake/LiveKitSDK.cmake.")
endif()
if(NOT LK_VERSION MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+$")
message(FATAL_ERROR "livekit_sdk_setup: VERSION must be x.y.z, got '${LK_VERSION}'")
endif()
if(NOT LK_SDK_DIR)
message(FATAL_ERROR "livekit_sdk_setup: SDK_DIR is required")
endif()
if(NOT LK_REPO)
set(LK_REPO "livekit/client-sdk-cpp")
endif()
if(NOT LK_DOWNLOAD_DIR)
set(LK_DOWNLOAD_DIR "${CMAKE_BINARY_DIR}/_downloads")
endif()
if(NOT LK_TRIPLE)
_lk_default_triple(LK_TRIPLE)
endif()
_lk_archive_ext(_ext)
set(_archive "livekit-sdk-${LK_TRIPLE}-${LK_VERSION}.${_ext}")
set(_url "https://github.com/${LK_REPO}/releases/download/v${LK_VERSION}/${_archive}")
set(_archive_path "${LK_DOWNLOAD_DIR}/${_archive}")
# The archive contains a single top-level folder named after the asset.
set(_extracted_root "${LK_SDK_DIR}/livekit-sdk-${LK_TRIPLE}-${LK_VERSION}")
file(MAKE_DIRECTORY "${LK_DOWNLOAD_DIR}")
file(MAKE_DIRECTORY "${LK_SDK_DIR}")
if(NOT EXISTS "${_extracted_root}/lib/cmake")
if(LK_NO_DOWNLOAD)
message(FATAL_ERROR
"LiveKitSDK: SDK not found at:\n ${_extracted_root}\nand NO_DOWNLOAD was set.")
endif()
message(STATUS "LiveKitSDK: downloading ${_url}")
if(LK_SHA256)
file(DOWNLOAD "${_url}" "${_archive_path}"
SHOW_PROGRESS TLS_VERIFY ON
EXPECTED_HASH "SHA256=${LK_SHA256}"
STATUS _st LOG _log)
else()
file(DOWNLOAD "${_url}" "${_archive_path}"
SHOW_PROGRESS TLS_VERIFY ON
STATUS _st LOG _log)
endif()
list(GET _st 0 _code)
list(GET _st 1 _msg)
if(NOT _code EQUAL 0)
file(REMOVE "${_archive_path}")
message(STATUS "LiveKitSDK: download log:\n${_log}")
message(FATAL_ERROR
"LiveKitSDK: download failed\nURL: ${_url}\nStatus: ${_code}\nMessage: ${_msg}\n"
"If this triple has no release asset, pass TRIPLE explicitly.")
endif()
# Remove any previous partial extraction.
file(REMOVE_RECURSE "${_extracted_root}")
message(STATUS "LiveKitSDK: extracting ${_archive_path}")
file(ARCHIVE_EXTRACT INPUT "${_archive_path}" DESTINATION "${LK_SDK_DIR}")
endif()
if(NOT EXISTS "${_extracted_root}/lib/cmake/LiveKit/LiveKitConfig.cmake")
message(FATAL_ERROR
"LiveKitSDK: extracted SDK does not look valid (missing "
"lib/cmake/LiveKit/LiveKitConfig.cmake)\nExpected under: ${_extracted_root}")
endif()
# Make find_package(LiveKit CONFIG REQUIRED) work in the caller's scope.
list(PREPEND CMAKE_PREFIX_PATH "${_extracted_root}")
set(CMAKE_PREFIX_PATH "${CMAKE_PREFIX_PATH}" PARENT_SCOPE)
set(LiveKit_DIR "${_extracted_root}/lib/cmake/LiveKit" PARENT_SCOPE)
# --- Runtime libraries, for staging next to the plugin module ------------
# Windows keeps the DLLs in bin/ and the import libs in lib/; the Unix
# platforms put the shared objects directly in lib/.
if(WIN32)
file(GLOB _runtime_libs "${_extracted_root}/bin/*.dll")
elseif(APPLE)
file(GLOB _runtime_libs "${_extracted_root}/lib/*.dylib")
else()
file(GLOB _runtime_libs "${_extracted_root}/lib/*.so" "${_extracted_root}/lib/*.so.*")
endif()
if(NOT _runtime_libs)
message(FATAL_ERROR
"LiveKitSDK: found no runtime shared libraries under ${_extracted_root}. "
"The release layout may have changed for version ${LK_VERSION}.")
endif()
set(LIVEKIT_SDK_EXTRACTED_ROOT "${_extracted_root}" CACHE PATH "LiveKit SDK extracted root" FORCE)
set(LIVEKIT_SDK_INCLUDE_DIR "${_extracted_root}/include" CACHE PATH "LiveKit SDK include dir" FORCE)
set(LIVEKIT_SDK_LIB_DIR "${_extracted_root}/lib" CACHE PATH "LiveKit SDK lib dir" FORCE)
set(LIVEKIT_SDK_BIN_DIR "${_extracted_root}/bin" CACHE PATH "LiveKit SDK bin dir" FORCE)
set(LIVEKIT_SDK_RUNTIME_LIBS "${_runtime_libs}" CACHE STRING "LiveKit SDK runtime shared libraries" FORCE)
set(LIVEKIT_SDK_URL_USED "${_url}" CACHE STRING "LiveKit SDK URL used" FORCE)
set(LIVEKIT_SDK_VERSION_RESOLVED "${LK_VERSION}" CACHE STRING "LiveKit SDK version" FORCE)
set(LIVEKIT_SDK_TRIPLE_USED "${LK_TRIPLE}" CACHE STRING "LiveKit SDK triple" FORCE)
message(STATUS "LiveKitSDK: using SDK ${LK_VERSION} (${LK_TRIPLE}) at ${_extracted_root}")
endfunction()
+370
View File
@@ -0,0 +1,370 @@
# Adapted from obsproject/obs-plugintemplate (cmake/common/buildspec_common.cmake,
# master as of 2026-09-06). Deliberate changes from upstream, all recorded here
# so a future re-sync knows what to keep:
#
# 1. _setup_obs_studio builds and installs the `libobs` target, not
# `obs-frontend-api`. This plugin's properties UI is plain
# obs_properties_* and it never touches the frontend API, so building it
# would only drag Qt back in -- which is the whole point of dropping qt6
# from dependencies_list.
# 2. It passes -DENABLE_UI:BOOL=OFF and -DENABLE_SCRIPTING:BOOL=OFF as well
# as upstream's -DENABLE_FRONTEND:BOOL=OFF. The pinned OBS (30.0.2)
# predates the ENABLE_FRONTEND option and gates its Qt-dependent UI on
# ENABLE_UI instead; without this the sub-build configures the whole
# OBS UI and demands Qt.
# 3. Only the Release configuration is built and installed. Upstream builds
# Debug as well; nothing here consumes a debug libobs, and it doubles the
# slowest step in CI.
# 4. macOS uses the Ninja generator and a single architecture, not upstream's
# Xcode generator and forced universal build. See the comment at that
# branch: a runner with only the Command Line Tools has no xcodebuild.
# 5. Generator flags are built as CMake lists so each becomes its own argv
# entry, rather than upstream's space-separated strings passed unquoted.
# 6. The install step targets the libobs subdirectory rather than the whole
# build tree, so it does not trip over the install rules of targets that
# were deliberately never built.
# 7. _resolve_versioned_macos_sdk exists at all -- see its own comment.
#
include_guard(GLOBAL)
# _check_deps_version: Checks for obs-deps VERSION file in prefix paths
function(_check_deps_version version)
set(found FALSE)
foreach(path IN LISTS CMAKE_PREFIX_PATH)
if(EXISTS "${path}/share/obs-deps/VERSION")
if(dependency STREQUAL qt6 AND NOT EXISTS "${path}/lib/cmake/Qt6/Qt6Config.cmake")
set(found FALSE)
continue()
endif()
file(READ "${path}/share/obs-deps/VERSION" _check_version)
string(REPLACE "\n" "" _check_version "${_check_version}")
string(REPLACE "-" "." _check_version "${_check_version}")
string(REPLACE "-" "." version "${version}")
if(_check_version VERSION_EQUAL version)
set(found TRUE)
break()
elseif(_check_version VERSION_LESS version)
message(
AUTHOR_WARNING
"Older ${label} version detected in ${path}: \n"
"Found ${_check_version}, require ${version}"
)
list(REMOVE_ITEM CMAKE_PREFIX_PATH "${path}")
list(APPEND CMAKE_PREFIX_PATH "${path}")
set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH})
continue()
else()
message(
AUTHOR_WARNING
"Newer ${label} version detected in ${path}: \n"
"Found ${_check_version}, require ${version}"
)
set(found TRUE)
break()
endif()
endif()
endforeach()
return(PROPAGATE found CMAKE_PREFIX_PATH)
endfunction()
# _resolve_versioned_macos_sdk: return an SDK path that satisfies OBS's own
# macOS SDK version check.
#
# Not upstream. OBS 30.0.2's cmake/macos/compilerconfig.cmake reads the SDK
# version straight out of CMAKE_OSX_SYSROOT with this regex:
#
# ".+/MacOSX.platform/Developer/SDKs/MacOSX([0-9]+\\.[0-9])+\\.sdk$"
#
# and hard-fails if it does not match ("Your macOS SDK version () is too low",
# with an empty version, which is the tell). That pattern only ever matches a
# full-Xcode SDK path; a Command-Line-Tools-only install has its SDK at
# /Library/Developer/CommandLineTools/SDKs/MacOSX<ver>.sdk, with no
# MacOSX.platform/Developer/SDKs segment at all, and can never match.
#
# Upstream never hits this because it uses the Xcode generator, where
# CMAKE_OSX_SYSROOT stays the literal string "macosx" and Xcode resolves it
# late. This project uses Ninja (the CI runner has no xcodebuild), so CMake
# resolves the sysroot eagerly and the regex runs against a real path.
#
# So: if the toolchain's own SDK path already matches, use it untouched.
# Otherwise build a symlink tree under the build directory whose shape matches
# the regex and which points at exactly the same SDK. Nothing about the
# compilation changes -- only the spelling of the path, which is all the check
# reads.
function(_resolve_versioned_macos_sdk out_path)
set(${out_path} "" PARENT_SCOPE)
execute_process(
COMMAND xcrun --show-sdk-path
OUTPUT_VARIABLE _sdk
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE _rc
ERROR_QUIET
)
if(NOT _rc EQUAL 0 OR NOT _sdk)
message(WARNING "Could not determine the macOS SDK path via xcrun; leaving CMAKE_OSX_SYSROOT alone.")
return()
endif()
if(_sdk MATCHES "/MacOSX\\.platform/Developer/SDKs/MacOSX[0-9]+\\.[0-9]+\\.sdk$")
set(${out_path} "${_sdk}" PARENT_SCOPE)
return()
endif()
execute_process(
COMMAND xcrun --show-sdk-version
OUTPUT_VARIABLE _sdk_version
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE _rc
ERROR_QUIET
)
if(NOT _rc EQUAL 0 OR NOT _sdk_version MATCHES "^([0-9]+)\\.([0-9]+)")
message(WARNING "Could not determine the macOS SDK version via xcrun (got '${_sdk_version}').")
return()
endif()
set(_short "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}")
# Deliberately under the BUILD directory, not .deps/: cmake/macos/
# buildspec.cmake runs `xattr -r -d com.apple.quarantine` over the whole
# dependency directory, which would follow this symlink into the read-only
# system SDK and fail with "Permission denied".
set(_link_dir "${CMAKE_BINARY_DIR}/macos-sdk/MacOSX.platform/Developer/SDKs")
set(_link "${_link_dir}/MacOSX${_short}.sdk")
file(MAKE_DIRECTORY "${_link_dir}")
if(NOT EXISTS "${_link}")
file(CREATE_LINK "${_sdk}" "${_link}" SYMBOLIC)
endif()
if(EXISTS "${_link}")
message(STATUS "macOS SDK ${_short} at ${_sdk}; presenting it to OBS as ${_link}")
set(${out_path} "${_link}" PARENT_SCOPE)
else()
message(WARNING "Could not create the versioned macOS SDK symlink at ${_link}.")
endif()
endfunction()
# _setup_obs_studio: Create obs-studio build project, then build libobs and obs-frontend-api
function(_setup_obs_studio)
if(NOT libobs_DIR)
set(_is_fresh --fresh)
endif()
# Every generator-specific flag is built as a proper CMake list, so each
# element becomes its own argv entry. Upstream packs several flags into one
# space-separated string and passes it unquoted, which execute_process hands
# to cmake as a single argument -- it happens not to matter there because
# those flags are optional, but -DCMAKE_BUILD_TYPE is not.
set(_cmake_arch "")
set(_cmake_extra "")
if(OS_WINDOWS)
set(_cmake_generator "${CMAKE_GENERATOR}")
# Plain "-A x64", NOT upstream's "-A x64,version=<Windows SDK>". With a
# current CMake the ",version=" suffix comes back out verbatim in the
# sub-build's CMAKE_VS_PLATFORM_NAME, and obs-studio's OWN dependency
# downloader keys its release assets off that value -- so the sub-configure
# goes looking for a file that cannot exist:
#
# string sub-command JSON member 'hashes windows-x64,version=10.0.26100.0'
# not found
# Unable to download .../windows-deps-2023-11-03-x64,version=10.0.26100.0.zip
#
# The Windows SDK is selected automatically anyway ("Selecting Windows SDK
# version 10.0.26100.0"), and CMAKE_SYSTEM_VERSION is passed below.
list(APPEND _cmake_arch -A "${arch}")
list(APPEND _cmake_extra "-DCMAKE_SYSTEM_VERSION=${CMAKE_SYSTEM_VERSION}")
elseif(OS_MACOS)
# Ninja, not upstream's Xcode generator. A runner with only the Command
# Line Tools installed has no xcodebuild, and the Xcode generator then
# fails the OBS sub-configure outright with "No CMAKE_C_COMPILER could be
# found" -- observed on the `home-mac` CI runner. Ninja is single-config,
# hence the explicit CMAKE_BUILD_TYPE below.
set(_cmake_generator "Ninja")
# Single-architecture, not upstream's forced universal build: this plugin
# is built for one architecture anyway (client-sdk-cpp ships single-arch
# dylibs), so building libobs universal would double the slowest step in
# CI for a slice nothing links against.
if(CMAKE_OSX_ARCHITECTURES)
list(APPEND _cmake_arch "-DCMAKE_OSX_ARCHITECTURES:STRING=${CMAKE_OSX_ARCHITECTURES}")
endif()
list(APPEND _cmake_extra "-DCMAKE_BUILD_TYPE=Release")
if(CMAKE_OSX_DEPLOYMENT_TARGET)
list(APPEND _cmake_extra "-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}")
endif()
_resolve_versioned_macos_sdk(_sdk_path)
if(_sdk_path)
list(APPEND _cmake_extra "-DCMAKE_OSX_SYSROOT=${_sdk_path}")
endif()
endif()
message(STATUS "Configure ${label} (${arch})")
execute_process(
COMMAND
"${CMAKE_COMMAND}" -S "${dependencies_dir}/${_obs_destination}" -B
"${dependencies_dir}/${_obs_destination}/build_${arch}" -G ${_cmake_generator} ${_cmake_arch}
-DOBS_CMAKE_VERSION:STRING=3.0.0 -DENABLE_PLUGINS:BOOL=OFF -DENABLE_FRONTEND:BOOL=OFF
-DENABLE_UI:BOOL=OFF -DENABLE_SCRIPTING:BOOL=OFF -DENABLE_BROWSER:BOOL=OFF
-DOBS_VERSION_OVERRIDE:STRING=${_obs_version} "-DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}" ${_is_fresh}
${_cmake_extra}
RESULT_VARIABLE _process_result
COMMAND_ERROR_IS_FATAL ANY
)
message(STATUS "Configure ${label} (${arch}) - done")
message(STATUS "Build ${label} (Release - ${arch})")
execute_process(
COMMAND "${CMAKE_COMMAND}" --build build_${arch} --target libobs --config Release --parallel
WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}"
RESULT_VARIABLE _process_result
COMMAND_ERROR_IS_FATAL ANY
)
message(STATUS "Build ${label} (Release - ${arch}) - done")
# Install the whole build tree, and tolerate a non-zero exit.
#
# Installing from the top-level build directory walks every subproject's
# cmake_install.cmake, including UI/obs-frontend-api's, which fails on a
# binary this build deliberately never produced (that is the whole point of
# dropping Qt):
#
# file INSTALL cannot find ".../obs-frontend-api.dylib": No such file
#
# Restricting --install to the libobs subdirectory avoids that error, but
# then the per-configuration export file (libobsTargets-release.cmake) never
# lands, and every consumer fails with "IMPORTED_LOCATION or IMPORTED_IMPLIB
# not set for imported target OBS::libobs configuration Release". So run the
# full install exactly as upstream does, let it get through libobs, and
# ignore the error it hits afterwards. If libobs really did not install, the
# find_package(libobs) in the top-level CMakeLists is where that surfaces --
# loudly, and with a far more useful message than a half-installed tree.
message(STATUS "Install ${label} (${arch})")
execute_process(
COMMAND
"${CMAKE_COMMAND}" --install build_${arch} --component Development --config Release --prefix "${dependencies_dir}"
WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}"
RESULT_VARIABLE _install_result
OUTPUT_QUIET
)
if(NOT _install_result EQUAL 0)
message(
STATUS
"Install ${label} (${arch}) reported errors; expected, because install rules exist for "
"targets this build skips. Continuing -- find_package(libobs) is the real check."
)
endif()
message(STATUS "Install ${label} (${arch}) - done")
endfunction()
# _check_dependencies: Fetch and extract pre-built OBS build dependencies
function(_check_dependencies)
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec)
string(JSON dependency_data GET ${buildspec} dependencies)
foreach(dependency IN LISTS dependencies_list)
string(JSON data GET ${dependency_data} ${dependency})
string(JSON version GET ${data} version)
string(JSON hash GET ${data} hashes ${platform})
string(JSON url GET ${data} baseUrl)
string(JSON label GET ${data} label)
string(JSON revision ERROR_VARIABLE error GET ${data} revision ${platform})
message(STATUS "Setting up ${label} (${arch})")
set(file "${${dependency}_filename}")
set(destination "${${dependency}_destination}")
string(REPLACE "VERSION" "${version}" file "${file}")
string(REPLACE "VERSION" "${version}" destination "${destination}")
string(REPLACE "ARCH" "${arch}" file "${file}")
string(REPLACE "ARCH" "${arch}" destination "${destination}")
if(revision)
string(REPLACE "_REVISION" "_v${revision}" file "${file}")
string(REPLACE "-REVISION" "-v${revision}" file "${file}")
else()
string(REPLACE "_REVISION" "" file "${file}")
string(REPLACE "-REVISION" "" file "${file}")
endif()
if(EXISTS "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256")
file(
READ
"${dependencies_dir}/.dependency_${dependency}_${arch}.sha256"
OBS_DEPENDENCY_${dependency}_${arch}_HASH
)
endif()
set(skip FALSE)
if(dependency STREQUAL prebuilt OR dependency STREQUAL qt6)
if(OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash})
_check_deps_version(${version})
if(found)
set(skip TRUE)
endif()
endif()
endif()
if(skip)
message(STATUS "Setting up ${label} (${arch}) - skipped")
continue()
endif()
if(dependency STREQUAL obs-studio)
set(url ${url}/${file})
else()
set(url ${url}/${version}/${file})
endif()
if(NOT EXISTS "${dependencies_dir}/${file}")
message(STATUS "Downloading ${url}")
file(DOWNLOAD "${url}" "${dependencies_dir}/${file}" STATUS download_status EXPECTED_HASH SHA256=${hash})
list(GET download_status 0 error_code)
list(GET download_status 1 error_message)
if(error_code GREATER 0)
message(STATUS "Downloading ${url} - Failure")
message(FATAL_ERROR "Unable to download ${url}, failed with error: ${error_message}")
file(REMOVE "${dependencies_dir}/${file}")
else()
message(STATUS "Downloading ${url} - done")
endif()
endif()
if(NOT OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash})
file(REMOVE_RECURSE "${dependencies_dir}/${destination}")
endif()
if(NOT EXISTS "${dependencies_dir}/${destination}")
file(MAKE_DIRECTORY "${dependencies_dir}/${destination}")
if(dependency STREQUAL obs-studio)
file(ARCHIVE_EXTRACT INPUT "${dependencies_dir}/${file}" DESTINATION "${dependencies_dir}")
else()
file(ARCHIVE_EXTRACT INPUT "${dependencies_dir}/${file}" DESTINATION "${dependencies_dir}/${destination}")
endif()
endif()
file(WRITE "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256" "${hash}")
if(dependency STREQUAL prebuilt)
list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}")
elseif(dependency STREQUAL qt6)
list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}")
elseif(dependency STREQUAL obs-studio)
set(_obs_version ${version})
set(_obs_destination "${destination}")
list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}")
endif()
message(STATUS "Setting up ${label} (${arch}) - done")
endforeach()
list(REMOVE_DUPLICATES CMAKE_PREFIX_PATH)
set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH} CACHE PATH "CMake prefix search path" FORCE)
_setup_obs_studio()
endfunction()
+20
View File
@@ -0,0 +1,20 @@
# CMake operating system bootstrap module
include_guard(GLOBAL)
if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows")
set(CMAKE_C_EXTENSIONS FALSE)
set(CMAKE_CXX_EXTENSIONS FALSE)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/windows")
set(OS_WINDOWS TRUE)
elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin")
set(CMAKE_C_EXTENSIONS FALSE)
set(CMAKE_CXX_EXTENSIONS FALSE)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos")
set(OS_MACOS TRUE)
elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD|OpenBSD")
set(CMAKE_CXX_EXTENSIONS FALSE)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/linux")
string(TOUPPER "${CMAKE_HOST_SYSTEM_NAME}" _SYSTEM_NAME_U)
set(OS_${_SYSTEM_NAME_U} TRUE)
endif()
+46
View File
@@ -0,0 +1,46 @@
# CMake macOS build dependencies module
#
# Adapted from obsproject/obs-plugintemplate. Only change from upstream: qt6
# is dropped from dependencies_list. This plugin's properties UI is plain
# obs_properties_*, it never links Qt, and the OBS sub-build is configured
# with ENABLE_UI=OFF -- so downloading a ~100 MB Qt bundle on every CI run
# would buy nothing.
include_guard(GLOBAL)
include(buildspec_common)
# _check_dependencies_macos: Set up macOS slice for _check_dependencies
function(_check_dependencies_macos)
set(arch universal)
set(platform macos)
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec)
set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps")
set(prebuilt_filename "macos-deps-VERSION-ARCH_REVISION.tar.xz")
set(prebuilt_destination "obs-deps-VERSION-ARCH")
set(obs-studio_filename "VERSION.tar.gz")
set(obs-studio_destination "obs-studio-VERSION")
set(dependencies_list prebuilt obs-studio)
_check_dependencies()
# Clearing the quarantine flag on the downloaded dependencies is a
# convenience, not a correctness requirement, so a failure here must not
# take the whole configure down with it. Upstream makes it fatal.
execute_process(
COMMAND "xattr" -r -d com.apple.quarantine "${dependencies_dir}"
RESULT_VARIABLE result
OUTPUT_QUIET
ERROR_QUIET
)
if(NOT result EQUAL 0)
message(STATUS "Could not clear the quarantine attribute under ${dependencies_dir} (continuing)")
endif()
list(APPEND CMAKE_FRAMEWORK_PATH "${dependencies_dir}/Frameworks")
set(CMAKE_FRAMEWORK_PATH ${CMAKE_FRAMEWORK_PATH} PARENT_SCOPE)
endfunction()
_check_dependencies_macos()
+28
View File
@@ -0,0 +1,28 @@
# CMake Windows build dependencies module
#
# Adapted from obsproject/obs-plugintemplate. Only change from upstream: qt6
# is dropped from dependencies_list. This plugin's properties UI is plain
# obs_properties_*, it never links Qt, and the OBS sub-build is configured
# with ENABLE_UI=OFF -- so downloading a ~100 MB Qt bundle on every CI run
# would buy nothing.
include_guard(GLOBAL)
include(buildspec_common)
# _check_dependencies_windows: Set up Windows slice for _check_dependencies
function(_check_dependencies_windows)
set(arch ${CMAKE_VS_PLATFORM_NAME})
set(platform windows-${arch})
set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps")
set(prebuilt_filename "windows-deps-VERSION-ARCH-REVISION.zip")
set(prebuilt_destination "obs-deps-VERSION-ARCH")
set(obs-studio_filename "VERSION.zip")
set(obs-studio_destination "obs-studio-VERSION")
set(dependencies_list prebuilt obs-studio)
_check_dependencies()
endfunction()
_check_dependencies_windows()
+36 -1
View File
@@ -4,15 +4,50 @@
# 2026-09-06-obs-camera-plugin-design.md in the streamer-tools repo) -- # 2026-09-06-obs-camera-plugin-design.md in the streamer-tools repo) --
# this must build and test headlessly on every platform. # this must build and test headlessly on every platform.
add_library(stplugin_core STATIC set(STPLUGIN_CORE_SOURCES
src/core.cpp src/core.cpp
src/json.cpp
src/http_common.cpp
src/api_client.cpp
src/session_types.cpp
src/session.cpp
) )
# HTTP backend, one per platform. See core/include/stplugin/http.h for why
# this is split rather than using libcurl everywhere.
if(WIN32)
list(APPEND STPLUGIN_CORE_SOURCES src/http_winhttp.cpp)
else()
list(APPEND STPLUGIN_CORE_SOURCES src/http_curl.cpp)
endif()
add_library(stplugin_core STATIC ${STPLUGIN_CORE_SOURCES})
target_include_directories(stplugin_core target_include_directories(stplugin_core
PUBLIC PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include
) )
target_link_libraries(stplugin_core
PUBLIC
LiveKit::livekit
)
if(WIN32)
target_link_libraries(stplugin_core PRIVATE winhttp)
else()
find_package(CURL REQUIRED)
target_link_libraries(stplugin_core PRIVATE CURL::libcurl)
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 set_target_properties(stplugin_core PROPERTIES
POSITION_INDEPENDENT_CODE ON POSITION_INDEPENDENT_CODE ON
) )
+133
View File
@@ -0,0 +1,133 @@
/*
streamer-tools OBS Camera Plugin - streamer-tools API client
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/>
*/
#pragma once
// Client for the two read-key-scoped endpoints in
// apps/server/src/obs/plugin.routes.ts (streamer-tools repo):
//
// GET /api/obs/:slug/slots?key=<readKey>
// 200 { slots: [ { identity, displayName, live } ] }
// 404 { error: 'not found' } wrong key OR unknown room
// 503 { error: 'livekit not configured' }
//
// POST /api/obs/:slug/token?key=<readKey>
// 200 { lkToken, wsUrl, identity }
// 404 / 503 as above
//
// The server deliberately answers a wrong key and an unknown slug identically
// (404), so this client must not claim to know which it was.
#include <memory>
#include <string>
#include <vector>
#include "stplugin/core.h"
#include "stplugin/http.h"
namespace stplugin {
enum class ApiStatus {
Ok,
/// server URL / slug / key were not all filled in
InvalidConfig,
/// request never completed (DNS, TLS, timeout, ...)
NetworkError,
/// HTTP 404: unknown room slug or wrong read key -- indistinguishable
NotFound,
/// HTTP 503: the server has no LiveKit credentials configured
Unavailable,
/// any other non-2xx status
HttpError,
/// 2xx but the body was not the JSON shape this client expects
MalformedResponse,
};
/// A short, operator-facing description. Never includes the read key.
const char *describeApiStatus(ApiStatus status);
struct SlotInfo {
/// LiveKit participant identity -- this is what the session wrapper
/// subscribes to, and what gets persisted in the OBS source settings.
std::string identity;
/// Human label for the dropdown; the server falls back to identity.
std::string display_name;
/// Currently publishing camera video.
bool live = false;
};
struct SlotsResult {
ApiStatus status = ApiStatus::InvalidConfig;
/// Detail for logs/UI. Never contains the read key.
std::string message;
std::vector<SlotInfo> slots;
bool ok() const { return status == ApiStatus::Ok; }
};
struct TokenResult {
ApiStatus status = ApiStatus::InvalidConfig;
std::string message;
/// LiveKit JWT for a hidden, subscribe-only participant.
std::string lk_token;
/// LiveKit websocket URL to connect to.
std::string ws_url;
/// The obs:<slug>:<nonce> identity the server minted for us.
std::string identity;
bool ok() const { return status == ApiStatus::Ok; }
};
class ApiClient {
public:
/// Takes ownership of the HTTP client, so tests can inject a fake.
explicit ApiClient(std::shared_ptr<HttpClient> http);
/// @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
/// nothing usable is left. Defaults to https:// when no scheme is given,
/// because the read key must never be sent in the clear by accident.
static std::string normalizeServerUrl(const std::string &raw);
/// Exposed for tests and for logging: the exact URL a call will hit,
/// with the read key replaced by "***".
static std::string redactedUrl(const std::string &url);
/// Scrubs "access_token=<value>" and "key=<value>" out of arbitrary
/// text -- not necessarily a bare URL/query string -- replacing each
/// value with "<redacted>". Unlike redactedUrl (which only has to
/// handle "&"-delimited query parameters), a value here can be followed
/// by a quote or whitespace, because the text this scrubs is a free-form
/// log line that may merely *contain* a URL. Used by the OBS adapter's
/// LiveKit SDK log bridge: LiveKit's signaling URL carries the access
/// token as a query parameter, and the SDK's own log lines could
/// include it.
static std::string redactSensitiveParams(const std::string &text);
private:
std::shared_ptr<HttpClient> http_;
};
} // namespace stplugin
+12 -21
View File
@@ -20,32 +20,23 @@ with this program. If not, see <https://www.gnu.org/licenses/>
#include <string> #include <string>
// C++ API for the core library. Per the design doc // The small shared pieces of the core library: its version string, and the
// (docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md in the // streamer-tools connection settings that both the API client and the OBS
// streamer-tools repo), this library will eventually own: streamer-tools // adapter pass around. Everything substantial lives in its own header --
// API auth, LiveKit FFI session management (connect, subscribe, decode, // api_client.h, session.h, http.h, json.h -- and none of it depends on OBS,
// reconnect), and frame callbacks -- all with zero OBS dependency, so it // so the whole library builds and tests headlessly on all three platforms.
// can be built and tested headlessly.
//
// THIS IS SCAFFOLDING. Nothing below talks to a real server or to
// livekit-ffi yet. It exists to prove the core-library/OBS-adapter split
// builds, links, and is unit-testable, ahead of a later phase that
// implements the real logic.
namespace stplugin { namespace stplugin {
// Returns the core library's version string. Placeholder for a real // The core library's version string, injected by CMake from the top-level
// version scheme once the library does something. // project() version, so what OBS logs on load is the actual build.
const char *core_version(); const char *core_version();
// Minimal connection configuration the future core library will use to // What an operator types into the source's properties, and what ApiClient
// authenticate against the streamer-tools API // needs to reach the two read-key-scoped endpoints in
// (see apps/server/src/rooms/join.routes.ts and // apps/server/src/obs/plugin.routes.ts (streamer-tools repo). The read key is
// apps/server/src/livekit/tokens.ts in the streamer-tools repo for the // a credential: it is masked in the properties UI and never logged (see
// existing read-key-authed token pattern this will follow) and mint a // ApiClient::redactedUrl).
// scoped LiveKit subscriber token. Validation here is intentionally
// trivial -- it exists to prove the core library is unit-testable
// headlessly, not to implement the real API client.
struct ConnectionConfig { struct ConnectionConfig {
std::string server_url; std::string server_url;
std::string room_slug; std::string room_slug;
+6 -8
View File
@@ -18,15 +18,13 @@ with this program. If not, see <https://www.gnu.org/licenses/>
#pragma once #pragma once
// Minimal C ABI surface of the core library, for the OBS adapter (plain // A minimal C ABI over the core library's version string.
// C, per the obs-plugintemplate convention) to call into the core
// library (C++) without needing a C++ compiler in that translation unit.
// //
// This mirrors the boundary the real integration will cross in the // The OBS adapter is C++ and calls stplugin::core_version() directly, so
// other direction: livekit-ffi is a Rust library exposing a C ABI that // nothing in this repository needs this header today. It is kept because it
// the C++ core library will link against. Proving a small, deliberate // is the seam a plain-C consumer would use, and because the unit tests assert
// C ABI seam works cleanly here is part of what this scaffold is for. // the two entry points agree -- which is a cheap check that the C++ library
// really is linkable from a C translation unit.
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
#endif #endif
+78
View File
@@ -0,0 +1,78 @@
/*
streamer-tools OBS Camera Plugin - HTTP client interface
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/>
*/
#pragma once
// A two-method HTTP interface, injectable so ApiClient can be unit-tested
// without a network (mirroring the streamer-tools repo's injectable-deps
// convention, per the design doc's Testing section).
//
// Backends, chosen per platform so no third-party HTTP dependency has to be
// built on any of the three CI runners:
// - Linux/macOS: libcurl. Already present on both (client-sdk-cpp's own
// liblivekit links libcurl on Linux, and macOS ships libcurl in the SDK).
// - Windows: WinHTTP, which ships with the OS and handles TLS through
// SChannel -- avoiding an OpenSSL or curl build on the Windows runner.
#include <map>
#include <string>
namespace stplugin {
struct HttpResponse {
/// HTTP status code, or 0 when the request never completed (DNS failure,
/// TLS failure, timeout, ...). Callers must check `network_error` first.
long status = 0;
/// Response body. May be empty, may be arbitrary bytes: never assume it
/// parses as JSON.
std::string body;
/// Empty on success. Non-empty means the request did not complete and
/// `status`/`body` are meaningless.
std::string network_error;
bool ok() const { return network_error.empty(); }
};
struct HttpRequest {
std::string method = "GET";
std::string url;
std::string body;
std::string content_type;
/// Whole-request timeout. Kept short: this runs on OBS's UI thread when
/// the properties dropdown is refreshed, and on the source's own worker
/// thread when a token is minted.
int timeout_ms = 10000;
};
class HttpClient {
public:
virtual ~HttpClient() = default;
virtual HttpResponse send(const HttpRequest &request) = 0;
};
/// Percent-encode a string for use in a URL query value.
std::string urlEncode(const std::string &value);
/// Construct the platform's real HTTP client. Returns nullptr if no backend
/// was compiled in.
HttpClient *createPlatformHttpClient();
} // namespace stplugin
+106
View File
@@ -0,0 +1,106 @@
/*
streamer-tools OBS Camera Plugin - minimal JSON reader
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/>
*/
#pragma once
// A deliberately small, strict, allocation-bounded JSON reader.
//
// Why hand-rolled rather than vendoring nlohmann/json: the only JSON this
// plugin ever parses is two small, fixed-shape responses from its own
// server (apps/server/src/obs/plugin.routes.ts in the streamer-tools repo),
// and the parser has to build unmodified on three platforms with no package
// manager step in CI. The scope is small enough to test exhaustively --
// including the malformed inputs a compromised or misconfigured endpoint
// could return, which is the case that must not crash or hang OBS.
//
// Properties this parser guarantees, all covered by core/tests/test_json.cpp:
// - never throws; every failure is reported as Value::invalid()
// - bounded recursion (kMaxDepth) so nesting cannot blow the stack
// - trailing garbage after the top-level value is an error
// - accessors on a wrong-typed value return the caller's default rather
// than aborting, so callers can be written without type interrogation
#include <cstdint>
#include <map>
#include <string>
#include <vector>
namespace stplugin {
namespace json {
/// Maximum nesting depth accepted by parse(). Any deeper input is rejected
/// as invalid rather than recursed into.
constexpr int kMaxDepth = 32;
class Value {
public:
enum class Type { Invalid, Null, Bool, Number, String, Array, Object };
Value() = default;
static Value invalid() { return Value(); }
static Value makeNull();
static Value makeBool(bool v);
static Value makeNumber(double v);
static Value makeString(std::string v);
static Value makeArray(std::vector<Value> v);
static Value makeObject(std::map<std::string, Value> v);
Type type() const { return type_; }
bool valid() const { return type_ != Type::Invalid; }
bool isNull() const { return type_ == Type::Null; }
bool isBool() const { return type_ == Type::Bool; }
bool isNumber() const { return type_ == Type::Number; }
bool isString() const { return type_ == Type::String; }
bool isArray() const { return type_ == Type::Array; }
bool isObject() const { return type_ == Type::Object; }
/// Object member lookup. Returns invalid() for a missing key or when this
/// value is not an object.
const Value &operator[](const std::string &key) const;
/// Array element access. Returns invalid() when out of range or when this
/// value is not an array.
const Value &at(std::size_t index) const;
std::size_t size() const;
/// Typed accessors. Each returns `fallback` when this value is missing or
/// of the wrong type, so callers never have to check first.
std::string asString(const std::string &fallback = std::string()) const;
bool asBool(bool fallback = false) const;
double asNumber(double fallback = 0.0) const;
const std::vector<Value> &elements() const { return array_; }
private:
Type type_ = Type::Invalid;
bool bool_ = false;
double number_ = 0.0;
std::string string_;
std::vector<Value> array_;
std::map<std::string, Value> object_;
};
/// Parse a complete JSON document. Returns Value::invalid() on any syntax
/// error, on trailing non-whitespace content, or on excessive nesting.
/// Never throws.
Value parse(const std::string &text);
} // namespace json
} // namespace stplugin
+118
View File
@@ -0,0 +1,118 @@
/*
streamer-tools OBS Camera Plugin - LiveKit session wrapper
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/>
*/
#pragma once
#include <memory>
#include <string>
#include "stplugin/session_types.h"
namespace stplugin {
struct SessionConfig {
/// LiveKit websocket URL, from POST /api/obs/:slug/token.
std::string ws_url;
/// LiveKit JWT, from the same call.
std::string token;
/// The slot's participant identity to subscribe to.
std::string participant_identity;
/// Pixel format requested from the SDK. I420 costs no conversion on
/// either side.
PixelFormat video_format = PixelFormat::I420;
/// Ring-buffer depth for decoded video. Non-zero means the SDK drops the
/// OLDEST frame when the queue is full, which is the structural answer to
/// the stale-frame-after-publisher-swap bug that motivated this plugin
/// (see the design doc's Approach section): a stalled consumer can only
/// ever fall this far behind, and what it then sees is the newest frame,
/// not a backlog.
std::size_t video_queue_capacity = 3;
/// Same for audio. A little deeper because audio frames are 10ms each.
std::size_t audio_queue_capacity = 20;
bool subscribe_audio = true;
/// How long connect() waits for the room to come up before giving up.
int connect_timeout_ms = 15000;
};
/// Wraps livekit::Room for exactly one subscribed slot.
///
/// Threading contract, which the OBS adapter depends on:
/// - connect() and disconnect() are blocking and must be called from an
/// ordinary thread. They must NOT be called from inside a handler this
/// class invokes: the SDK documents that Room::disconnect() deadlocks if
/// called from a room event callback, and Room's own callback registration
/// is not re-entrant either.
/// - The video and audio handlers are invoked on dedicated reader threads,
/// one per track. Frame pointers are valid only for the duration of the
/// call.
/// - The state handler is invoked from whichever thread observed the
/// change. It must not block and must not call back into this object.
/// - All handlers must be installed before connect(); they are not
/// synchronised against a running session.
class LiveKitSession {
public:
LiveKitSession();
~LiveKitSession();
LiveKitSession(const LiveKitSession &) = delete;
LiveKitSession &operator=(const LiveKitSession &) = delete;
void setVideoHandler(VideoFrameHandler handler);
void setAudioHandler(AudioFrameHandler handler);
void setStateHandler(SessionStateHandler handler);
/// Connect and start subscribing. Returns true once the room is up; the
/// selected slot's tracks may still arrive later (or not at all, if the
/// camera is dark), which is reported through hasVideo()/the state
/// handler rather than as a connect failure.
bool connect(const SessionConfig &config);
/// Tear everything down. Safe to call when never connected, and safe to
/// call twice.
void disconnect();
SessionState state() const;
std::string stateDetail() const;
bool hasVideo() const;
bool hasAudio() const;
/// True when connected but the slot is not publishing: the source should
/// show its placeholder, not an error.
bool waitingForCamera() const;
/// Monotonic counters, for logging and for the adapter to tell "connected
/// but silent" from "never started".
std::uint64_t videoFrameCount() const;
std::uint64_t audioFrameCount() const;
/// Process-wide SDK init/teardown. Reference-counted, so several sources
/// can each hold one. The OBS adapter calls these from obs_module_load /
/// obs_module_unload.
static void globalInitialize();
static void globalShutdown();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace stplugin
+187
View File
@@ -0,0 +1,187 @@
/*
streamer-tools OBS Camera Plugin - session types and pure session logic
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/>
*/
#pragma once
// Everything in this header is deliberately free of both LiveKit and OBS
// types. That is what makes the session's decision-making unit-testable
// headlessly: the parts of LiveKitSession that can go wrong without a server
// -- which state a sequence of room events leaves us in, whether a given
// published track is the one we want, and whether a frame's geometry is
// self-consistent -- all live here, and LiveKitSession is the (much thinner)
// piece that wires real SDK callbacks into them.
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
namespace stplugin {
// ---------------------------------------------------------------------------
// Media description (mirrors of the LiveKit enums, converted at the boundary)
// ---------------------------------------------------------------------------
enum class MediaKind { Unknown, Audio, Video };
enum class MediaSource { Unknown, Camera, Microphone, Screenshare, ScreenshareAudio, Other };
/// Pixel formats this plugin is willing to receive. I420 is the default
/// because it is what a WebRTC decoder produces and what OBS accepts
/// natively, so neither side pays for a conversion.
enum class PixelFormat { I420, NV12, BGRA };
const char *describePixelFormat(PixelFormat format);
/// Number of planes a format uses (3 for I420, 2 for NV12, 1 for BGRA).
int planeCount(PixelFormat format);
/// Total bytes a tightly-packed frame of this format and geometry occupies.
/// Returns 0 for non-positive dimensions. Used to reject a frame whose
/// buffer does not match its claimed size before its pointers reach OBS.
std::size_t expectedFrameBytes(PixelFormat format, int width, int height);
// ---------------------------------------------------------------------------
// Track selection
// ---------------------------------------------------------------------------
/// Does this published track belong to the slot we were asked to show, and is
/// it the camera (never the screenshare)?
///
/// streamer-tools publishes cameras as Track.Source.Camera and screenshares
/// as Track.Source.ScreenShare (apps/web/src/avatar/publish.ts), so the
/// source is the reliable discriminator. A track that reports no source at
/// all is accepted on kind alone rather than dropped, since an unknown source
/// on a video track from the right participant is far more likely to be a
/// camera than anything else.
bool isWantedVideoTrack(const std::string &wanted_identity, const std::string &track_identity, MediaKind kind,
MediaSource source);
/// Same, for the slot's microphone.
bool isWantedAudioTrack(const std::string &wanted_identity, const std::string &track_identity, MediaKind kind,
MediaSource source);
// ---------------------------------------------------------------------------
// Session state
// ---------------------------------------------------------------------------
enum class SessionState {
/// Never asked to connect, or fully torn down.
Idle,
/// connect() is in flight.
Connecting,
/// Signalling is up. Says nothing about whether video is arriving --
/// that is what hasVideo() is for.
Connected,
/// The SDK is re-establishing the connection on its own.
Reconnecting,
/// The room ended: either we disconnected, or the server did.
Disconnected,
/// connect() failed, or the session died in a way retrying will not fix
/// (a rejected token, a duplicate identity).
Failed,
};
const char *describeSessionState(SessionState state);
/// The pure state machine behind LiveKitSession. Not thread-safe on its own;
/// LiveKitSession owns the lock.
///
/// It exists separately so the transitions that matter operationally -- a
/// publisher swap must not read as a failure, a reconnect must not read as a
/// fresh connection, a failure detail must survive until the next connect --
/// can be tested without a LiveKit server.
class SessionStateMachine {
public:
SessionState state() const { return state_; }
/// Human-readable reason for the current state. Empty when there is
/// nothing to say. Never contains a token or read key.
const std::string &detail() const { return detail_; }
/// Whether a video track is currently attached and delivering.
bool hasVideo() const { return has_video_; }
bool hasAudio() const { return has_audio_; }
/// True when the source should be showing its "waiting for camera"
/// placeholder rather than an error: we are up, the slot just isn't live.
bool waitingForCamera() const;
void onConnectRequested();
void onConnectSucceeded();
void onConnectFailed(const std::string &reason);
void onReconnecting();
void onReconnected();
/// The server (or the SDK) ended the room. `fatal` distinguishes a reason
/// that retrying cannot fix from an ordinary drop.
void onRoomEnded(const std::string &reason, bool fatal);
void onLocalDisconnect();
void onVideoAttached();
void onVideoDetached();
void onAudioAttached();
void onAudioDetached();
private:
SessionState state_ = SessionState::Idle;
std::string detail_;
bool has_video_ = false;
bool has_audio_ = false;
};
// ---------------------------------------------------------------------------
// Frames handed to the OBS adapter
// ---------------------------------------------------------------------------
struct VideoPlane {
const std::uint8_t *data = nullptr;
std::uint32_t stride = 0;
std::uint32_t size = 0;
};
/// A decoded video frame. All pointers are owned by the SDK and are valid
/// only for the duration of the callback -- copy or consume synchronously.
struct VideoFrameData {
int width = 0;
int height = 0;
PixelFormat format = PixelFormat::I420;
const std::uint8_t *data = nullptr;
std::size_t size = 0;
VideoPlane planes[4];
int plane_count = 0;
/// WebRTC capture-time timestamp, microseconds.
std::int64_t timestamp_us = 0;
};
/// Interleaved int16 PCM. client-sdk-cpp's AudioFrameCallback carries no
/// timestamp, so the adapter stamps arrival time itself -- see the design
/// doc's "Audio/video sync verification" note, which flags that as an
/// assumption to check on real hardware rather than a guarantee.
struct AudioFrameData {
const std::int16_t *samples = nullptr;
std::size_t sample_count = 0;
int sample_rate = 0;
int channels = 0;
int samples_per_channel = 0;
};
using VideoFrameHandler = std::function<void(const VideoFrameData &)>;
using AudioFrameHandler = std::function<void(const AudioFrameData &)>;
using SessionStateHandler = std::function<void(SessionState state, const std::string &detail)>;
} // namespace stplugin
+252
View File
@@ -0,0 +1,252 @@
/*
streamer-tools OBS Camera Plugin - streamer-tools API client
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/>
*/
#include "stplugin/api_client.h"
#include "stplugin/json.h"
#include <utility>
namespace stplugin {
namespace {
std::string trim(const std::string &s)
{
std::size_t begin = 0;
std::size_t end = s.size();
auto is_space = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
while (begin < end && is_space(s[begin]))
++begin;
while (end > begin && is_space(s[end - 1]))
--end;
return s.substr(begin, end - begin);
}
/// Map a completed HTTP response onto the shared status codes. Returns
/// ApiStatus::Ok when the caller should go on to parse the body.
ApiStatus classify(const HttpResponse &response, std::string &message)
{
if (!response.ok()) {
message = response.network_error;
return ApiStatus::NetworkError;
}
if (response.status >= 200 && response.status < 300)
return ApiStatus::Ok;
if (response.status == 404) {
message = "unknown room slug, or the read key is wrong or has been rotated";
return ApiStatus::NotFound;
}
if (response.status == 503) {
message = "the streamer-tools server has no LiveKit credentials configured";
return ApiStatus::Unavailable;
}
message = "HTTP " + std::to_string(response.status);
return ApiStatus::HttpError;
}
std::string buildUrl(const ConnectionConfig &config, const char *suffix)
{
return ApiClient::normalizeServerUrl(config.server_url) + "/api/obs/" +
urlEncode(trim(config.room_slug)) + suffix + "?key=" + urlEncode(trim(config.read_key));
}
} // namespace
const char *describeApiStatus(ApiStatus status)
{
switch (status) {
case ApiStatus::Ok: return "ok";
case ApiStatus::InvalidConfig: return "server URL, room slug and read key are all required";
case ApiStatus::NetworkError: return "could not reach the streamer-tools server";
case ApiStatus::NotFound: return "room not found, or the read key is wrong";
case ApiStatus::Unavailable: return "server has no LiveKit configured";
case ApiStatus::HttpError: return "unexpected response from the streamer-tools server";
case ApiStatus::MalformedResponse: return "unreadable response from the streamer-tools server";
}
return "unknown error";
}
ApiClient::ApiClient(std::shared_ptr<HttpClient> http) : http_(std::move(http)) {}
std::string ApiClient::normalizeServerUrl(const std::string &raw)
{
std::string url = trim(raw);
if (url.empty())
return url;
// A bare "streamers.example.com" is what an operator will paste half the
// time. Defaulting to https (never http) keeps the read key off the wire
// in the clear.
const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0;
if (!has_scheme)
url = "https://" + url;
// An explicit "http://..." is left as-is on purpose: an operator who
// typed the scheme out has made a deliberate (if inadvisable) choice,
// and this function's job is only to supply a sane default, not to
// second-guess an explicit one.
while (!url.empty() && url.back() == '/')
url.pop_back();
// "https://" with nothing after it is not a server.
if (url == "https:/" || url == "https:" || url == "http:/" || url == "http:" ||
url == "https://" || url == "http://")
return std::string();
return url;
}
// No current call site logs a request URL (the OBS adapter only logs
// ws_url/status text, never the streamer-tools API request URL itself) --
// this exists as a deliberate guard rail for whenever request-URL logging
// is added later, so the read key can never be pasted into an OBS log by
// accident. Not dead code to be deleted.
std::string ApiClient::redactedUrl(const std::string &url)
{
const std::size_t at = url.find("key=");
if (at == std::string::npos)
return url;
const std::size_t value = at + 4;
std::size_t end = url.find('&', value);
if (end == std::string::npos)
end = url.size();
return url.substr(0, value) + "***" + url.substr(end);
}
std::string ApiClient::redactSensitiveParams(const std::string &text)
{
static const char *const kParams[] = {"access_token=", "key="};
std::string out = text;
for (const char *param : kParams) {
const std::size_t param_len = std::string(param).size();
std::size_t pos = 0;
while ((pos = out.find(param, pos)) != std::string::npos) {
const std::size_t value_start = pos + param_len;
std::size_t value_end = value_start;
// A value ends at the next query-string delimiter, a quote (the
// URL is often embedded in a quoted/bracketed log line), or
// whitespace -- whichever comes first -- or at the end of the
// string.
while (value_end < out.size()) {
const char c = out[value_end];
if (c == '&' || c == '"' || c == '\'' || c == ' ' || c == '\t' || c == '\n' ||
c == '\r' || c == ')' || c == ']')
break;
++value_end;
}
const std::string replacement = "<redacted>";
out.replace(value_start, value_end - value_start, replacement);
pos = value_start + replacement.size();
}
}
return out;
}
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const
{
SlotsResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
result.status = ApiStatus::InvalidConfig;
result.message = describeApiStatus(ApiStatus::InvalidConfig);
return result;
}
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);
if (status != ApiStatus::Ok) {
result.status = status;
return result;
}
const json::Value root = json::parse(response.body);
const json::Value &slots = root["slots"];
if (!root.isObject() || !slots.isArray()) {
result.status = ApiStatus::MalformedResponse;
result.message = "expected a JSON object with a \"slots\" array";
return result;
}
for (const json::Value &entry : slots.elements()) {
// A slot without an identity is unusable -- it is what the session
// wrapper subscribes by -- so skip it rather than surfacing a
// dropdown row that can never connect. Anything else is best-effort:
// a missing displayName falls back to the identity exactly as the
// server itself does.
const std::string identity = entry["identity"].asString();
if (identity.empty())
continue;
SlotInfo slot;
slot.identity = identity;
slot.display_name = entry["displayName"].asString(identity);
if (slot.display_name.empty())
slot.display_name = identity;
slot.live = entry["live"].asBool(false);
result.slots.push_back(std::move(slot));
}
result.status = ApiStatus::Ok;
return result;
}
TokenResult ApiClient::requestToken(const ConnectionConfig &config, int timeout_ms) const
{
TokenResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
result.status = ApiStatus::InvalidConfig;
result.message = describeApiStatus(ApiStatus::InvalidConfig);
return result;
}
HttpRequest request;
request.method = "POST";
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);
if (status != ApiStatus::Ok) {
result.status = status;
return result;
}
const json::Value root = json::parse(response.body);
const std::string token = root["lkToken"].asString();
const std::string ws_url = root["wsUrl"].asString();
if (!root.isObject() || token.empty() || ws_url.empty()) {
result.status = ApiStatus::MalformedResponse;
result.message = "expected a JSON object with non-empty \"lkToken\" and \"wsUrl\"";
return result;
}
result.lk_token = token;
result.ws_url = ws_url;
result.identity = root["identity"].asString();
result.status = ApiStatus::Ok;
return result;
}
} // namespace stplugin
+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 {
+46
View File
@@ -0,0 +1,46 @@
/*
streamer-tools OBS Camera Plugin - HTTP helpers shared by all backends
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/>
*/
#include "stplugin/http.h"
namespace stplugin {
// Hand-rolled rather than curl_easy_escape so the WinHTTP backend gets the
// same behaviour, and so this is testable without a live HTTP client.
// Unreserved set per RFC 3986 section 2.3.
std::string urlEncode(const std::string &value)
{
static const char *kHex = "0123456789ABCDEF";
std::string out;
out.reserve(value.size());
for (const char raw : value) {
const unsigned char c = static_cast<unsigned char>(raw);
const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~';
if (unreserved) {
out.push_back(static_cast<char>(c));
} else {
out.push_back('%');
out.push_back(kHex[c >> 4]);
out.push_back(kHex[c & 0x0F]);
}
}
return out;
}
} // namespace stplugin
+146
View File
@@ -0,0 +1,146 @@
/*
streamer-tools OBS Camera Plugin - libcurl HTTP backend (Linux/macOS)
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/>
*/
#include "stplugin/http.h"
#include <curl/curl.h>
#include <cstddef>
#include <mutex>
namespace stplugin {
namespace {
/// Hard cap on a response body. The two endpoints this client talks to return
/// a few hundred bytes; anything larger is a misconfigured proxy or a wrong
/// URL, and must not be allowed to grow OBS's heap without bound.
constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u;
struct WriteContext {
std::string body;
bool overflowed = false;
};
std::size_t writeCallback(char *ptr, std::size_t size, std::size_t nmemb, void *userdata)
{
auto *ctx = static_cast<WriteContext *>(userdata);
const std::size_t bytes = size * nmemb;
if (ctx->body.size() + bytes > kMaxResponseBytes) {
ctx->overflowed = true;
return 0; // aborts the transfer with CURLE_WRITE_ERROR
}
ctx->body.append(ptr, bytes);
return bytes;
}
/// curl_global_init is not thread-safe and must run once per process before
/// any easy handle is created. OBS may create several sources concurrently.
void ensureCurlGlobalInit()
{
static std::once_flag once;
std::call_once(once, [] { curl_global_init(CURL_GLOBAL_DEFAULT); });
}
class CurlHttpClient : public HttpClient {
public:
HttpResponse send(const HttpRequest &request) override
{
ensureCurlGlobalInit();
HttpResponse response;
CURL *curl = curl_easy_init();
if (!curl) {
response.network_error = "curl_easy_init failed";
return response;
}
WriteContext ctx;
struct curl_slist *headers = nullptr;
curl_easy_setopt(curl, CURLOPT_URL, request.url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(request.timeout_ms));
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(request.timeout_ms));
// Redirects are never legitimate here: this client only ever talks to
// two fixed, first-party streamer-tools API endpoints, and the read
// key travels as a URL query parameter (see api_client.cpp). Blindly
// following a redirect -- including an HTTPS->HTTP downgrade, which
// curl does not refuse by default -- would hand that key to whatever
// host the redirect points at. A redirect from our own server is a
// configuration error, so treat it as a failed request instead of
// silently following it. This also brings this backend in line with
// http_winhttp.cpp, which already refuses HTTPS->HTTP downgrades by
// default.
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0");
// NOSIGNAL is required whenever curl is used off the main thread:
// without it curl installs a SIGALRM handler for DNS timeouts, which
// is process-global and would be a rude thing to do inside OBS.
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
// TLS verification stays on. The read key is a credential; sending it
// to an unverified host is exactly the failure this must not have.
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
if (request.method == "POST") {
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, request.body.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(request.body.size()));
} else if (request.method != "GET") {
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, request.method.c_str());
}
if (!request.content_type.empty()) {
const std::string header = "Content-Type: " + request.content_type;
headers = curl_slist_append(headers, header.c_str());
}
// Fastify answers a bare POST with no body fine, but some proxies
// insert an Expect: 100-continue round trip; suppress it.
headers = curl_slist_append(headers, "Expect:");
if (headers)
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
const CURLcode rc = curl_easy_perform(curl);
if (rc == CURLE_OK) {
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
response.status = status;
response.body = std::move(ctx.body);
} else if (ctx.overflowed) {
response.network_error = "response body exceeded 4 MiB";
} else {
response.network_error = curl_easy_strerror(rc);
}
if (headers)
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
return response;
}
};
} // namespace
HttpClient *createPlatformHttpClient()
{
return new CurlHttpClient();
}
} // namespace stplugin
+208
View File
@@ -0,0 +1,208 @@
/*
streamer-tools OBS Camera Plugin - WinHTTP backend (Windows)
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/>
*/
// WinHTTP rather than libcurl on Windows: it ships with the OS, does TLS
// through SChannel (so no OpenSSL to build or ship), and needs no package
// manager on the self-hosted `winvm-builder` runner -- which, per the
// scaffold README, is a bare VM without even cmake preinstalled.
#include "stplugin/http.h"
#include <windows.h>
#include <winhttp.h>
#include <cstddef>
#include <string>
#include <vector>
namespace stplugin {
namespace {
constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u;
std::wstring widen(const std::string &s)
{
if (s.empty())
return std::wstring();
const int needed = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
if (needed <= 0)
return std::wstring();
std::wstring out(static_cast<std::size_t>(needed), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), &out[0], needed);
return out;
}
std::string lastErrorMessage(const char *what)
{
return std::string(what) + " failed (GetLastError=" + std::to_string(GetLastError()) + ")";
}
/// RAII for the three WinHTTP handle kinds, which all close the same way.
class Handle {
public:
Handle() = default;
explicit Handle(HINTERNET h) : h_(h) {}
~Handle()
{
if (h_)
WinHttpCloseHandle(h_);
}
Handle(const Handle &) = delete;
Handle &operator=(const Handle &) = delete;
void reset(HINTERNET h)
{
if (h_)
WinHttpCloseHandle(h_);
h_ = h;
}
HINTERNET get() const { return h_; }
explicit operator bool() const { return h_ != nullptr; }
private:
HINTERNET h_ = nullptr;
};
class WinHttpClient : public HttpClient {
public:
HttpResponse send(const HttpRequest &request) override
{
HttpResponse response;
const std::wstring url = widen(request.url);
if (url.empty()) {
response.network_error = "empty or non-UTF-8 URL";
return response;
}
URL_COMPONENTS parts{};
parts.dwStructSize = sizeof(parts);
wchar_t host[256] = {0};
wchar_t path[4096] = {0};
wchar_t extra[4096] = {0};
parts.lpszHostName = host;
parts.dwHostNameLength = static_cast<DWORD>(sizeof(host) / sizeof(host[0]));
parts.lpszUrlPath = path;
parts.dwUrlPathLength = static_cast<DWORD>(sizeof(path) / sizeof(path[0]));
parts.lpszExtraInfo = extra;
parts.dwExtraInfoLength = static_cast<DWORD>(sizeof(extra) / sizeof(extra[0]));
if (!WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(url.size()), 0, &parts)) {
response.network_error = lastErrorMessage("WinHttpCrackUrl");
return response;
}
if (parts.nScheme != INTERNET_SCHEME_HTTP && parts.nScheme != INTERNET_SCHEME_HTTPS) {
response.network_error = "unsupported URL scheme";
return response;
}
Handle session(WinHttpOpen(L"streamer-tools-obs-plugin/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0));
if (!session) {
response.network_error = lastErrorMessage("WinHttpOpen");
return response;
}
const DWORD timeout = static_cast<DWORD>(request.timeout_ms);
WinHttpSetTimeouts(session.get(), static_cast<int>(timeout), static_cast<int>(timeout),
static_cast<int>(timeout), static_cast<int>(timeout));
Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0));
if (!connect) {
response.network_error = lastErrorMessage("WinHttpConnect");
return response;
}
std::wstring target(path);
target += extra;
const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u;
Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr,
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags));
if (!req) {
response.network_error = lastErrorMessage("WinHttpOpenRequest");
return response;
}
std::wstring headers;
if (!request.content_type.empty())
headers = L"Content-Type: " + widen(request.content_type) + L"\r\n";
const LPCWSTR header_ptr = headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str();
const DWORD header_len = headers.empty() ? 0u : static_cast<DWORD>(headers.size());
void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA
: const_cast<char *>(request.body.data());
const DWORD body_len = static_cast<DWORD>(request.body.size());
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0)) {
response.network_error = lastErrorMessage("WinHttpSendRequest");
return response;
}
if (!WinHttpReceiveResponse(req.get(), nullptr)) {
response.network_error = lastErrorMessage("WinHttpReceiveResponse");
return response;
}
DWORD status = 0;
DWORD status_size = sizeof(status);
if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) {
response.network_error = lastErrorMessage("WinHttpQueryHeaders");
return response;
}
response.status = static_cast<long>(status);
std::string body;
for (;;) {
DWORD available = 0;
if (!WinHttpQueryDataAvailable(req.get(), &available)) {
response.network_error = lastErrorMessage("WinHttpQueryDataAvailable");
return response;
}
if (available == 0)
break;
if (body.size() + available > kMaxResponseBytes) {
response.network_error = "response body exceeded 4 MiB";
return response;
}
std::vector<char> chunk(available);
DWORD read = 0;
if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) {
response.network_error = lastErrorMessage("WinHttpReadData");
return response;
}
if (read == 0)
break;
body.append(chunk.data(), read);
}
response.body = std::move(body);
return response;
}
};
} // namespace
HttpClient *createPlatformHttpClient()
{
return new WinHttpClient();
}
} // namespace stplugin
+442
View File
@@ -0,0 +1,442 @@
/*
streamer-tools OBS Camera Plugin - minimal JSON reader
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/>
*/
#include "stplugin/json.h"
#include <cstdlib>
#include <cstring>
namespace stplugin {
namespace json {
namespace {
const Value &invalidSingleton()
{
static const Value v;
return v;
}
} // namespace
Value Value::makeNull()
{
Value v;
v.type_ = Type::Null;
return v;
}
Value Value::makeBool(bool b)
{
Value v;
v.type_ = Type::Bool;
v.bool_ = b;
return v;
}
Value Value::makeNumber(double n)
{
Value v;
v.type_ = Type::Number;
v.number_ = n;
return v;
}
Value Value::makeString(std::string s)
{
Value v;
v.type_ = Type::String;
v.string_ = std::move(s);
return v;
}
Value Value::makeArray(std::vector<Value> a)
{
Value v;
v.type_ = Type::Array;
v.array_ = std::move(a);
return v;
}
Value Value::makeObject(std::map<std::string, Value> o)
{
Value v;
v.type_ = Type::Object;
v.object_ = std::move(o);
return v;
}
const Value &Value::operator[](const std::string &key) const
{
if (type_ != Type::Object)
return invalidSingleton();
auto it = object_.find(key);
if (it == object_.end())
return invalidSingleton();
return it->second;
}
const Value &Value::at(std::size_t index) const
{
if (type_ != Type::Array || index >= array_.size())
return invalidSingleton();
return array_[index];
}
std::size_t Value::size() const
{
if (type_ == Type::Array)
return array_.size();
if (type_ == Type::Object)
return object_.size();
if (type_ == Type::String)
return string_.size();
return 0;
}
std::string Value::asString(const std::string &fallback) const
{
return type_ == Type::String ? string_ : fallback;
}
bool Value::asBool(bool fallback) const
{
return type_ == Type::Bool ? bool_ : fallback;
}
double Value::asNumber(double fallback) const
{
return type_ == Type::Number ? number_ : fallback;
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
namespace {
class Parser {
public:
explicit Parser(const std::string &text) : s_(text) {}
bool parseDocument(Value &out)
{
skipWs();
if (!parseValue(out, 0))
return false;
skipWs();
// Trailing content is an error: "{}garbage" must not silently parse
// as an empty object.
return pos_ == s_.size();
}
private:
const std::string &s_;
std::size_t pos_ = 0;
bool eof() const { return pos_ >= s_.size(); }
char peek() const { return s_[pos_]; }
void skipWs()
{
while (!eof()) {
const char c = s_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
++pos_;
else
break;
}
}
bool literal(const char *lit)
{
const std::size_t n = std::strlen(lit);
if (s_.compare(pos_, n, lit) != 0)
return false;
pos_ += n;
return true;
}
bool parseValue(Value &out, int depth)
{
if (depth > kMaxDepth)
return false;
if (eof())
return false;
switch (peek()) {
case '{':
return parseObject(out, depth);
case '[':
return parseArray(out, depth);
case '"': {
std::string str;
if (!parseString(str))
return false;
out = Value::makeString(std::move(str));
return true;
}
case 't':
if (!literal("true"))
return false;
out = Value::makeBool(true);
return true;
case 'f':
if (!literal("false"))
return false;
out = Value::makeBool(false);
return true;
case 'n':
if (!literal("null"))
return false;
out = Value::makeNull();
return true;
default:
return parseNumber(out);
}
}
bool parseObject(Value &out, int depth)
{
++pos_; // '{'
std::map<std::string, Value> members;
skipWs();
if (!eof() && peek() == '}') {
++pos_;
out = Value::makeObject(std::move(members));
return true;
}
for (;;) {
skipWs();
std::string key;
if (!parseString(key))
return false;
skipWs();
if (eof() || peek() != ':')
return false;
++pos_;
skipWs();
Value v;
if (!parseValue(v, depth + 1))
return false;
members[key] = std::move(v);
skipWs();
if (eof())
return false;
if (peek() == ',') {
++pos_;
continue;
}
if (peek() == '}') {
++pos_;
out = Value::makeObject(std::move(members));
return true;
}
return false;
}
}
bool parseArray(Value &out, int depth)
{
++pos_; // '['
std::vector<Value> items;
skipWs();
if (!eof() && peek() == ']') {
++pos_;
out = Value::makeArray(std::move(items));
return true;
}
for (;;) {
skipWs();
Value v;
if (!parseValue(v, depth + 1))
return false;
items.push_back(std::move(v));
skipWs();
if (eof())
return false;
if (peek() == ',') {
++pos_;
continue;
}
if (peek() == ']') {
++pos_;
out = Value::makeArray(std::move(items));
return true;
}
return false;
}
}
bool parseHex4(unsigned &out)
{
if (pos_ + 4 > s_.size())
return false;
unsigned value = 0;
for (int i = 0; i < 4; ++i) {
const char c = s_[pos_ + static_cast<std::size_t>(i)];
unsigned digit;
if (c >= '0' && c <= '9')
digit = static_cast<unsigned>(c - '0');
else if (c >= 'a' && c <= 'f')
digit = static_cast<unsigned>(c - 'a') + 10u;
else if (c >= 'A' && c <= 'F')
digit = static_cast<unsigned>(c - 'A') + 10u;
else
return false;
value = (value << 4) | digit;
}
pos_ += 4;
out = value;
return true;
}
static void appendUtf8(std::string &out, unsigned cp)
{
if (cp < 0x80) {
out.push_back(static_cast<char>(cp));
} else if (cp < 0x800) {
out.push_back(static_cast<char>(0xC0u | (cp >> 6)));
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
} else if (cp < 0x10000) {
out.push_back(static_cast<char>(0xE0u | (cp >> 12)));
out.push_back(static_cast<char>(0x80u | ((cp >> 6) & 0x3Fu)));
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
} else {
out.push_back(static_cast<char>(0xF0u | (cp >> 18)));
out.push_back(static_cast<char>(0x80u | ((cp >> 12) & 0x3Fu)));
out.push_back(static_cast<char>(0x80u | ((cp >> 6) & 0x3Fu)));
out.push_back(static_cast<char>(0x80u | (cp & 0x3Fu)));
}
}
bool parseString(std::string &out)
{
if (eof() || peek() != '"')
return false;
++pos_;
out.clear();
for (;;) {
if (eof())
return false; // unterminated string
const unsigned char c = static_cast<unsigned char>(s_[pos_]);
if (c == '"') {
++pos_;
return true;
}
if (c == '\\') {
++pos_;
if (eof())
return false;
const char esc = s_[pos_++];
switch (esc) {
case '"': out.push_back('"'); break;
case '\\': out.push_back('\\'); break;
case '/': out.push_back('/'); break;
case 'b': out.push_back('\b'); break;
case 'f': out.push_back('\f'); break;
case 'n': out.push_back('\n'); break;
case 'r': out.push_back('\r'); break;
case 't': out.push_back('\t'); break;
case 'u': {
unsigned cp = 0;
if (!parseHex4(cp))
return false;
if (cp >= 0xD800 && cp <= 0xDBFF) {
// High surrogate: a low surrogate must follow.
if (pos_ + 1 < s_.size() && s_[pos_] == '\\' && s_[pos_ + 1] == 'u') {
pos_ += 2;
unsigned lo = 0;
if (!parseHex4(lo))
return false;
if (lo < 0xDC00 || lo > 0xDFFF)
return false;
cp = 0x10000u + ((cp - 0xD800u) << 10) + (lo - 0xDC00u);
} else {
return false;
}
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
return false; // lone low surrogate
}
appendUtf8(out, cp);
break;
}
default:
return false;
}
continue;
}
if (c < 0x20)
return false; // raw control character
out.push_back(static_cast<char>(c));
++pos_;
}
}
bool parseNumber(Value &out)
{
const std::size_t start = pos_;
if (!eof() && peek() == '-')
++pos_;
if (eof())
return false;
if (peek() == '0') {
++pos_;
} else if (peek() >= '1' && peek() <= '9') {
while (!eof() && peek() >= '0' && peek() <= '9')
++pos_;
} else {
return false;
}
if (!eof() && peek() == '.') {
++pos_;
if (eof() || peek() < '0' || peek() > '9')
return false;
while (!eof() && peek() >= '0' && peek() <= '9')
++pos_;
}
if (!eof() && (peek() == 'e' || peek() == 'E')) {
++pos_;
if (!eof() && (peek() == '+' || peek() == '-'))
++pos_;
if (eof() || peek() < '0' || peek() > '9')
return false;
while (!eof() && peek() >= '0' && peek() <= '9')
++pos_;
}
const std::string token = s_.substr(start, pos_ - start);
// strtod is locale-sensitive for the decimal separator, but the
// grammar above only ever hands it ASCII digits with a '.', and OBS
// does not switch the C locale away from "C". Using strtod rather
// than std::stod keeps this noexcept.
out = Value::makeNumber(std::strtod(token.c_str(), nullptr));
return true;
}
};
} // namespace
Value parse(const std::string &text)
{
Parser p(text);
Value v;
if (!p.parseDocument(v))
return Value::invalid();
return v;
}
} // namespace json
} // namespace stplugin
+742
View File
@@ -0,0 +1,742 @@
/*
streamer-tools OBS Camera Plugin - LiveKit session wrapper
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/>
*/
#include "stplugin/session.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <exception>
#include <mutex>
#include <thread>
#include <utility>
#include <vector>
#include <livekit/audio_frame.h>
#include <livekit/audio_stream.h>
#include <livekit/livekit.h>
#include <livekit/remote_participant.h>
#include <livekit/remote_track_publication.h>
#include <livekit/room.h>
#include <livekit/room_delegate.h>
#include <livekit/room_event_types.h>
#include <livekit/track.h>
#include <livekit/video_frame.h>
#include <livekit/video_stream.h>
namespace stplugin {
namespace {
// --- LiveKit <-> plugin type conversion ------------------------------------
MediaKind toMediaKind(livekit::TrackKind kind)
{
switch (kind) {
case livekit::TrackKind::KIND_AUDIO: return MediaKind::Audio;
case livekit::TrackKind::KIND_VIDEO: return MediaKind::Video;
case livekit::TrackKind::KIND_UNKNOWN: break;
}
return MediaKind::Unknown;
}
MediaSource toMediaSource(livekit::TrackSource source)
{
switch (source) {
case livekit::TrackSource::SOURCE_CAMERA: return MediaSource::Camera;
case livekit::TrackSource::SOURCE_MICROPHONE: return MediaSource::Microphone;
case livekit::TrackSource::SOURCE_SCREENSHARE: return MediaSource::Screenshare;
case livekit::TrackSource::SOURCE_SCREENSHARE_AUDIO: return MediaSource::ScreenshareAudio;
case livekit::TrackSource::SOURCE_UNKNOWN: break;
}
return MediaSource::Unknown;
}
livekit::VideoBufferType toLiveKitBufferType(PixelFormat format)
{
switch (format) {
case PixelFormat::I420: return livekit::VideoBufferType::I420;
case PixelFormat::NV12: return livekit::VideoBufferType::NV12;
case PixelFormat::BGRA: return livekit::VideoBufferType::BGRA;
}
return livekit::VideoBufferType::I420;
}
/// Returns false when the SDK handed us a format the OBS adapter cannot
/// consume, in which case the caller converts.
bool fromLiveKitBufferType(livekit::VideoBufferType type, PixelFormat &out)
{
switch (type) {
case livekit::VideoBufferType::I420: out = PixelFormat::I420; return true;
case livekit::VideoBufferType::NV12: out = PixelFormat::NV12; return true;
case livekit::VideoBufferType::BGRA: out = PixelFormat::BGRA; return true;
default: return false;
}
}
/// Which disconnect reasons are worth telling the operator "this will not fix
/// itself" about. Everything else is reported as an ordinary disconnect,
/// because the SDK's own reconnect logic covers it.
bool isFatalDisconnect(livekit::DisconnectReason reason)
{
switch (reason) {
case livekit::DisconnectReason::DuplicateIdentity:
case livekit::DisconnectReason::ParticipantRemoved:
case livekit::DisconnectReason::RoomDeleted:
case livekit::DisconnectReason::JoinFailure:
case livekit::DisconnectReason::UserRejected:
return true;
default:
return false;
}
}
const char *describeDisconnectReason(livekit::DisconnectReason reason)
{
switch (reason) {
case livekit::DisconnectReason::Unknown: return "connection lost";
case livekit::DisconnectReason::ClientInitiated: return "disconnected";
case livekit::DisconnectReason::DuplicateIdentity: return "another client joined with the same identity";
case livekit::DisconnectReason::ServerShutdown: return "the LiveKit server is shutting down";
case livekit::DisconnectReason::ParticipantRemoved: return "removed from the room";
case livekit::DisconnectReason::RoomDeleted: return "the room was deleted";
case livekit::DisconnectReason::StateMismatch: return "session could not be resumed";
case livekit::DisconnectReason::JoinFailure: return "could not join the room (token rejected or expired?)";
case livekit::DisconnectReason::Migration: return "migrating to another server";
case livekit::DisconnectReason::SignalClose: return "the signalling connection closed";
case livekit::DisconnectReason::RoomClosed: return "the room closed";
case livekit::DisconnectReason::UserUnavailable: return "user unavailable";
case livekit::DisconnectReason::UserRejected: return "connection rejected";
case livekit::DisconnectReason::SipTrunkFailure: return "SIP trunk failure";
case livekit::DisconnectReason::ConnectionTimeout: return "connection timed out";
case livekit::DisconnectReason::MediaFailure: return "media connection failed";
case livekit::DisconnectReason::AgentError: return "agent error";
}
return "disconnected";
}
// --- Process-wide SDK lifetime ---------------------------------------------
std::mutex &globalMutex()
{
static std::mutex m;
return m;
}
int &globalRefCount()
{
static int n = 0;
return n;
}
} // namespace
// ---------------------------------------------------------------------------
// Impl
// ---------------------------------------------------------------------------
struct LiveKitSession::Impl : public livekit::RoomDelegate {
enum class CommandType { AttachVideo, DetachVideo, AttachAudio, DetachAudio, Stop };
struct Command {
CommandType type;
std::shared_ptr<livekit::Track> track;
};
livekit::Room room;
SessionConfig config;
mutable std::mutex state_mutex;
SessionStateMachine machine;
VideoFrameHandler on_video;
AudioFrameHandler on_audio;
SessionStateHandler on_state;
std::atomic<std::uint64_t> video_frames{0};
std::atomic<std::uint64_t> audio_frames{0};
std::atomic<std::uint64_t> dropped_frames{0};
// Command queue. Every interaction with livekit::VideoStream /
// livekit::AudioStream happens on `worker`, never on a room event thread:
// the SDK's room callbacks run on its own event thread and blocking or
// re-entering there stalls every other event (and Room::disconnect() from
// inside one is documented to deadlock outright).
std::mutex queue_mutex;
std::condition_variable queue_cv;
std::deque<Command> queue;
std::thread worker;
bool worker_running = false;
// Owned exclusively by the worker thread.
std::shared_ptr<livekit::VideoStream> video_stream;
std::thread video_thread;
std::shared_ptr<livekit::AudioStream> audio_stream;
std::thread audio_thread;
bool connected = false;
~Impl() override = default;
// --- state helpers -----------------------------------------------------
template<typename Fn> void mutateState(Fn &&fn)
{
SessionState state;
std::string detail;
SessionStateHandler handler;
{
std::lock_guard<std::mutex> guard(state_mutex);
fn(machine);
state = machine.state();
detail = machine.detail();
handler = on_state;
}
// Notified outside the lock: the handler is OBS adapter code and must
// never be able to deadlock against a concurrent state query.
if (handler)
handler(state, detail);
}
void post(CommandType type, std::shared_ptr<livekit::Track> track = nullptr)
{
{
std::lock_guard<std::mutex> guard(queue_mutex);
if (!worker_running)
return;
queue.push_back(Command{type, std::move(track)});
}
queue_cv.notify_one();
}
// --- RoomDelegate ------------------------------------------------------
void onTrackSubscribed(livekit::Room &, const livekit::TrackSubscribedEvent &event) override
{
if (!event.participant || !event.track)
return;
const std::string identity = event.participant->identity();
const MediaKind kind = toMediaKind(event.track->kind());
const MediaSource source =
event.publication ? toMediaSource(event.publication->source()) : MediaSource::Unknown;
if (isWantedVideoTrack(config.participant_identity, identity, kind, source))
post(CommandType::AttachVideo, event.track);
else if (config.subscribe_audio && isWantedAudioTrack(config.participant_identity, identity, kind, source))
post(CommandType::AttachAudio, event.track);
}
void onTrackUnsubscribed(livekit::Room &, const livekit::TrackUnsubscribedEvent &event) override
{
if (!event.participant || !event.track)
return;
if (event.participant->identity() != config.participant_identity)
return;
const MediaKind kind = toMediaKind(event.track->kind());
if (kind == MediaKind::Video)
post(CommandType::DetachVideo);
else if (kind == MediaKind::Audio)
post(CommandType::DetachAudio);
}
void onParticipantDisconnected(livekit::Room &, const livekit::ParticipantDisconnectedEvent &event) override
{
if (!event.participant || event.participant->identity() != config.participant_identity)
return;
// The slot went away entirely. This is the placeholder state, not an
// error: the operator's room is fine, the camera just left.
post(CommandType::DetachVideo);
post(CommandType::DetachAudio);
}
void onReconnecting(livekit::Room &, const livekit::ReconnectingEvent &) override
{
mutateState([](SessionStateMachine &m) { m.onReconnecting(); });
}
void onReconnected(livekit::Room &, const livekit::ReconnectedEvent &) override
{
mutateState([](SessionStateMachine &m) { m.onReconnected(); });
}
void onDisconnected(livekit::Room &, const livekit::DisconnectedEvent &event) override
{
const std::string reason = describeDisconnectReason(event.reason);
const bool fatal = isFatalDisconnect(event.reason);
post(CommandType::DetachVideo);
post(CommandType::DetachAudio);
mutateState([&](SessionStateMachine &m) { m.onRoomEnded(reason, fatal); });
}
void onRoomEos(livekit::Room &, const livekit::RoomEosEvent &) override
{
post(CommandType::DetachVideo);
post(CommandType::DetachAudio);
mutateState([](SessionStateMachine &m) { m.onRoomEnded("the room session ended", false); });
}
// --- worker ------------------------------------------------------------
void startWorker()
{
{
std::lock_guard<std::mutex> guard(queue_mutex);
queue.clear();
worker_running = true;
}
worker = std::thread([this] { workerLoop(); });
}
void stopWorker()
{
{
std::lock_guard<std::mutex> guard(queue_mutex);
if (!worker_running)
return;
queue.push_back(Command{CommandType::Stop, nullptr});
worker_running = false;
}
queue_cv.notify_one();
if (worker.joinable())
worker.join();
}
void workerLoop()
{
for (;;) {
Command command{CommandType::Stop, nullptr};
{
std::unique_lock<std::mutex> lock(queue_mutex);
queue_cv.wait(lock, [this] { return !queue.empty(); });
command = std::move(queue.front());
queue.pop_front();
}
switch (command.type) {
case CommandType::AttachVideo:
attachVideo(command.track);
break;
case CommandType::DetachVideo:
detachVideo();
break;
case CommandType::AttachAudio:
attachAudio(command.track);
break;
case CommandType::DetachAudio:
detachAudio();
break;
case CommandType::Stop:
detachVideo();
detachAudio();
return;
}
}
}
void attachVideo(const std::shared_ptr<livekit::Track> &track)
{
if (!track)
return;
// Replacing an existing stream is the publisher-swap path: tear the
// old reader all the way down first so no frame from the previous
// publisher can arrive after the new one starts.
detachVideo();
livekit::VideoStream::Options options;
options.capacity = config.video_queue_capacity;
options.format = toLiveKitBufferType(config.video_format);
std::shared_ptr<livekit::VideoStream> stream;
try {
stream = livekit::VideoStream::fromTrack(track, options);
} catch (const std::exception &e) {
mutateState([&](SessionStateMachine &m) {
m.onRoomEnded(std::string("could not open the video stream: ") + e.what(), true);
});
return;
}
if (!stream)
return;
video_stream = stream;
video_thread = std::thread([this, stream] { videoReaderLoop(stream); });
mutateState([](SessionStateMachine &m) { m.onVideoAttached(); });
}
void detachVideo()
{
if (video_stream)
video_stream->close(); // wakes the blocking read()
if (video_thread.joinable())
video_thread.join();
const bool had = static_cast<bool>(video_stream);
video_stream.reset();
if (had)
mutateState([](SessionStateMachine &m) { m.onVideoDetached(); });
}
void attachAudio(const std::shared_ptr<livekit::Track> &track)
{
if (!track)
return;
detachAudio();
livekit::AudioStream::Options options;
options.capacity = config.audio_queue_capacity;
std::shared_ptr<livekit::AudioStream> stream;
try {
stream = livekit::AudioStream::fromTrack(track, options);
} catch (const std::exception &) {
// Audio is not worth failing the whole source over: a camera with
// no usable audio track is still a usable camera.
return;
}
if (!stream)
return;
audio_stream = stream;
audio_thread = std::thread([this, stream] { audioReaderLoop(stream); });
mutateState([](SessionStateMachine &m) { m.onAudioAttached(); });
}
void detachAudio()
{
if (audio_stream)
audio_stream->close();
if (audio_thread.joinable())
audio_thread.join();
const bool had = static_cast<bool>(audio_stream);
audio_stream.reset();
if (had)
mutateState([](SessionStateMachine &m) { m.onAudioDetached(); });
}
void videoReaderLoop(std::shared_ptr<livekit::VideoStream> stream)
{
VideoFrameHandler handler;
{
std::lock_guard<std::mutex> guard(state_mutex);
handler = on_video;
}
livekit::VideoFrameEvent event;
while (stream->read(event)) {
if (!handler)
continue;
deliverVideoFrame(event, handler);
}
}
void deliverVideoFrame(livekit::VideoFrameEvent &event, const VideoFrameHandler &handler)
{
PixelFormat format;
const livekit::VideoFrame *frame = &event.frame;
livekit::VideoFrame converted;
if (!fromLiveKitBufferType(frame->type(), format)) {
// The SDK gave us something the adapter cannot hand to OBS.
// convert() is a full CPU repack, so this is a fallback, not the
// normal path -- the normal path is the format we asked for.
try {
converted = frame->convert(toLiveKitBufferType(config.video_format));
} catch (const std::exception &) {
dropped_frames.fetch_add(1);
return;
}
frame = &converted;
format = config.video_format;
}
const int width = frame->width();
const int height = frame->height();
const std::size_t expected = expectedFrameBytes(format, width, height);
if (expected == 0 || frame->dataSize() < expected) {
// Geometry that does not match the buffer would make OBS read off
// the end of it. Drop rather than trust.
dropped_frames.fetch_add(1);
return;
}
VideoFrameData out;
out.width = width;
out.height = height;
out.format = format;
out.data = frame->data();
out.size = frame->dataSize();
out.timestamp_us = event.timestamp_us;
const std::vector<livekit::VideoPlaneInfo> planes = frame->planeInfos();
const int wanted_planes = planeCount(format);
int count = 0;
for (const livekit::VideoPlaneInfo &plane : planes) {
if (count >= 4)
break;
out.planes[count].data = reinterpret_cast<const std::uint8_t *>(plane.data_ptr);
out.planes[count].stride = plane.stride;
out.planes[count].size = plane.size;
++count;
}
if (count == 0 && wanted_planes == 1) {
// planeInfos() documents that packed formats may return an empty
// list rather than one plane. Synthesise it from the frame buffer
// instead of dropping a perfectly good BGRA frame.
out.planes[0].data = frame->data();
out.planes[0].stride = static_cast<std::uint32_t>(width) * 4u;
out.planes[0].size = static_cast<std::uint32_t>(frame->dataSize());
count = 1;
}
if (count != wanted_planes) {
dropped_frames.fetch_add(1);
return;
}
out.plane_count = count;
video_frames.fetch_add(1);
handler(out);
}
void audioReaderLoop(std::shared_ptr<livekit::AudioStream> stream)
{
AudioFrameHandler handler;
{
std::lock_guard<std::mutex> guard(state_mutex);
handler = on_audio;
}
livekit::AudioFrameEvent event;
while (stream->read(event)) {
if (!handler)
continue;
const livekit::AudioFrame &frame = event.frame;
if (frame.numChannels() <= 0 || frame.samplesPerChannel() <= 0 || frame.sampleRate() <= 0)
continue;
AudioFrameData out;
out.samples = frame.data().data();
out.sample_count = frame.totalSamples();
out.sample_rate = frame.sampleRate();
out.channels = frame.numChannels();
out.samples_per_channel = frame.samplesPerChannel();
audio_frames.fetch_add(1);
handler(out);
}
}
/// After connect(), the target slot may already be in the room with its
/// tracks subscribed, in which case no onTrackSubscribed event is coming.
/// Sweep what is already there so a source added mid-show shows video
/// immediately instead of waiting for the publisher to republish.
void attachExistingTracks()
{
auto participant = room.remoteParticipant(config.participant_identity).lock();
if (!participant)
return;
const std::string identity = participant->identity();
for (const auto &entry : participant->trackPublications()) {
const std::shared_ptr<livekit::RemoteTrackPublication> &publication = entry.second;
if (!publication)
continue;
const std::shared_ptr<livekit::Track> track = publication->track();
if (!track)
continue; // published but not subscribed yet
const MediaKind kind = toMediaKind(track->kind());
const MediaSource source = toMediaSource(publication->source());
if (isWantedVideoTrack(config.participant_identity, identity, kind, source))
post(CommandType::AttachVideo, track);
else if (config.subscribe_audio && isWantedAudioTrack(config.participant_identity, identity, kind, source))
post(CommandType::AttachAudio, track);
}
}
};
// ---------------------------------------------------------------------------
// LiveKitSession
// ---------------------------------------------------------------------------
LiveKitSession::LiveKitSession() : impl_(new Impl()) {}
LiveKitSession::~LiveKitSession()
{
disconnect();
}
void LiveKitSession::setVideoHandler(VideoFrameHandler handler)
{
std::lock_guard<std::mutex> guard(impl_->state_mutex);
impl_->on_video = std::move(handler);
}
void LiveKitSession::setAudioHandler(AudioFrameHandler handler)
{
std::lock_guard<std::mutex> guard(impl_->state_mutex);
impl_->on_audio = std::move(handler);
}
void LiveKitSession::setStateHandler(SessionStateHandler handler)
{
std::lock_guard<std::mutex> guard(impl_->state_mutex);
impl_->on_state = std::move(handler);
}
bool LiveKitSession::connect(const SessionConfig &config)
{
if (impl_->connected)
disconnect();
impl_->config = config;
impl_->video_frames.store(0);
impl_->audio_frames.store(0);
impl_->dropped_frames.store(0);
impl_->mutateState([](SessionStateMachine &m) { m.onConnectRequested(); });
if (config.ws_url.empty() || config.token.empty() || config.participant_identity.empty()) {
impl_->mutateState(
[](SessionStateMachine &m) { m.onConnectFailed("missing LiveKit URL, token or camera selection"); });
return false;
}
impl_->startWorker();
livekit::RoomOptions options;
// auto_subscribe is what makes track_subscribed events (and therefore any
// media at all) happen; the SDK is emphatic about this.
//
// Known, measured-but-unaddressed cost: auto_subscribe pulls every
// participant's published track, not just the one camera this session
// actually wants, and this client discards the unwanted ones
// client-side. In a multi-camera room that is real, wasted bandwidth
// and decode CPU that scales with room size, not with what this source
// displays. Selectively unsubscribing from unwanted publications (the
// SDK exposes per-publication subscribe/unsubscribe) is a real
// follow-up optimization, deliberately out of scope here.
options.auto_subscribe = true;
options.dynacast = false;
// This client never publishes, so a single peer connection is all it
// needs.
options.single_peer_connection = true;
options.connect_timeout = std::chrono::milliseconds(config.connect_timeout_ms);
impl_->room.setDelegate(impl_.get());
bool ok = false;
try {
ok = impl_->room.connect(config.ws_url, config.token, options);
} catch (const std::exception &e) {
ok = false;
impl_->mutateState([&](SessionStateMachine &m) { m.onConnectFailed(e.what()); });
impl_->stopWorker();
impl_->room.setDelegate(nullptr);
return false;
}
if (!ok) {
impl_->mutateState([](SessionStateMachine &m) {
m.onConnectFailed("could not connect to LiveKit (check the server URL, or the token may have expired)");
});
impl_->stopWorker();
impl_->room.setDelegate(nullptr);
return false;
}
impl_->connected = true;
impl_->mutateState([](SessionStateMachine &m) { m.onConnectSucceeded(); });
impl_->attachExistingTracks();
return true;
}
void LiveKitSession::disconnect()
{
if (!impl_)
return;
// Order matters: stop the readers first so nothing is mid-read on a
// stream the room is about to tear down, then disconnect the room, then
// drop the delegate so no event can arrive at a half-destroyed object.
impl_->stopWorker();
if (impl_->connected) {
impl_->connected = false;
try {
impl_->room.disconnect(livekit::DisconnectReason::ClientInitiated);
} catch (const std::exception &) {
// Best effort: a failed graceful disconnect must not stop the
// OBS source from being destroyed.
}
impl_->mutateState([](SessionStateMachine &m) { m.onLocalDisconnect(); });
}
impl_->room.setDelegate(nullptr);
}
SessionState LiveKitSession::state() const
{
std::lock_guard<std::mutex> guard(impl_->state_mutex);
return impl_->machine.state();
}
std::string LiveKitSession::stateDetail() const
{
std::lock_guard<std::mutex> guard(impl_->state_mutex);
return impl_->machine.detail();
}
bool LiveKitSession::hasVideo() const
{
std::lock_guard<std::mutex> guard(impl_->state_mutex);
return impl_->machine.hasVideo();
}
bool LiveKitSession::hasAudio() const
{
std::lock_guard<std::mutex> guard(impl_->state_mutex);
return impl_->machine.hasAudio();
}
bool LiveKitSession::waitingForCamera() const
{
std::lock_guard<std::mutex> guard(impl_->state_mutex);
return impl_->machine.waitingForCamera();
}
std::uint64_t LiveKitSession::videoFrameCount() const
{
return impl_->video_frames.load();
}
std::uint64_t LiveKitSession::audioFrameCount() const
{
return impl_->audio_frames.load();
}
void LiveKitSession::globalInitialize()
{
std::lock_guard<std::mutex> guard(globalMutex());
if (globalRefCount()++ == 0)
livekit::initialize(livekit::LogLevel::Warn);
}
void LiveKitSession::globalShutdown()
{
std::lock_guard<std::mutex> guard(globalMutex());
if (globalRefCount() > 0 && --globalRefCount() == 0)
livekit::shutdown();
}
} // namespace stplugin
+185
View File
@@ -0,0 +1,185 @@
/*
streamer-tools OBS Camera Plugin - session types and pure session logic
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/>
*/
#include "stplugin/session_types.h"
namespace stplugin {
const char *describePixelFormat(PixelFormat format)
{
switch (format) {
case PixelFormat::I420: return "I420";
case PixelFormat::NV12: return "NV12";
case PixelFormat::BGRA: return "BGRA";
}
return "unknown";
}
int planeCount(PixelFormat format)
{
switch (format) {
case PixelFormat::I420: return 3;
case PixelFormat::NV12: return 2;
case PixelFormat::BGRA: return 1;
}
return 0;
}
std::size_t expectedFrameBytes(PixelFormat format, int width, int height)
{
if (width <= 0 || height <= 0)
return 0;
const std::size_t w = static_cast<std::size_t>(width);
const std::size_t h = static_cast<std::size_t>(height);
// Chroma planes round up, which is what libyuv/WebRTC do for odd sizes.
const std::size_t cw = (w + 1) / 2;
const std::size_t ch = (h + 1) / 2;
switch (format) {
case PixelFormat::I420: return w * h + 2 * cw * ch;
case PixelFormat::NV12: return w * h + 2 * cw * ch;
case PixelFormat::BGRA: return w * h * 4;
}
return 0;
}
bool isWantedVideoTrack(const std::string &wanted_identity, const std::string &track_identity, MediaKind kind,
MediaSource source)
{
if (wanted_identity.empty() || track_identity != wanted_identity)
return false;
if (kind != MediaKind::Video)
return false;
return source == MediaSource::Camera || source == MediaSource::Unknown;
}
bool isWantedAudioTrack(const std::string &wanted_identity, const std::string &track_identity, MediaKind kind,
MediaSource source)
{
if (wanted_identity.empty() || track_identity != wanted_identity)
return false;
if (kind != MediaKind::Audio)
return false;
return source == MediaSource::Microphone || source == MediaSource::Unknown;
}
const char *describeSessionState(SessionState state)
{
switch (state) {
case SessionState::Idle: return "idle";
case SessionState::Connecting: return "connecting";
case SessionState::Connected: return "connected";
case SessionState::Reconnecting: return "reconnecting";
case SessionState::Disconnected: return "disconnected";
case SessionState::Failed: return "failed";
}
return "unknown";
}
bool SessionStateMachine::waitingForCamera() const
{
return (state_ == SessionState::Connected || state_ == SessionState::Reconnecting) && !has_video_;
}
void SessionStateMachine::onConnectRequested()
{
state_ = SessionState::Connecting;
// A new attempt clears the previous failure reason, so a stale message
// can never be shown alongside a fresh, healthy connection.
detail_.clear();
has_video_ = false;
has_audio_ = false;
}
void SessionStateMachine::onConnectSucceeded()
{
state_ = SessionState::Connected;
detail_.clear();
}
void SessionStateMachine::onConnectFailed(const std::string &reason)
{
state_ = SessionState::Failed;
detail_ = reason;
has_video_ = false;
has_audio_ = false;
}
void SessionStateMachine::onReconnecting()
{
// Only meaningful from a live session; a reconnect notification after we
// already gave up must not resurrect the session.
if (state_ != SessionState::Connected && state_ != SessionState::Reconnecting)
return;
state_ = SessionState::Reconnecting;
detail_ = "reconnecting";
// Tracks are re-subscribed on the other side of a reconnect; the SDK will
// tell us when they are back.
has_video_ = false;
has_audio_ = false;
}
void SessionStateMachine::onReconnected()
{
if (state_ != SessionState::Reconnecting)
return;
state_ = SessionState::Connected;
detail_.clear();
}
void SessionStateMachine::onRoomEnded(const std::string &reason, bool fatal)
{
if (state_ == SessionState::Idle || state_ == SessionState::Disconnected)
return;
state_ = fatal ? SessionState::Failed : SessionState::Disconnected;
detail_ = reason;
has_video_ = false;
has_audio_ = false;
}
void SessionStateMachine::onLocalDisconnect()
{
state_ = SessionState::Disconnected;
detail_.clear();
has_video_ = false;
has_audio_ = false;
}
void SessionStateMachine::onVideoAttached()
{
has_video_ = true;
}
void SessionStateMachine::onVideoDetached()
{
// A publisher swap (the motivating bug) shows up here: the old track goes
// away and a new one arrives moments later. That is a placeholder state,
// never an error state -- the connection itself is untouched.
has_video_ = false;
}
void SessionStateMachine::onAudioAttached()
{
has_audio_ = true;
}
void SessionStateMachine::onAudioDetached()
{
has_audio_ = false;
}
} // namespace stplugin
+36 -4
View File
@@ -1,7 +1,39 @@
add_executable(stplugin_core_tests # Dependency-free CTest targets (see test_util.h for why there is no gtest).
test_core.cpp
function(stplugin_add_test name)
add_executable(${name} ${name}.cpp)
target_link_libraries(${name} PRIVATE stplugin_core)
target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
if(WIN32)
# loopback_server.h needs Winsock for the real-backend tests.
target_link_libraries(${name} PRIVATE ws2_32)
endif()
add_test(NAME ${name} COMMAND ${name})
# Nothing here should ever take a minute; a hang is a failure, not a
# reason for CI to sit for its default 1500s.
set_tests_properties(${name} PROPERTIES TIMEOUT 120)
endfunction()
stplugin_add_test(test_core)
target_compile_definitions(test_core PRIVATE
STPLUGIN_EXPECTED_CORE_VERSION="${PROJECT_VERSION}"
) )
stplugin_add_test(test_json)
stplugin_add_test(test_api_client)
stplugin_add_test(test_session)
target_link_libraries(stplugin_core_tests PRIVATE stplugin_core) # End-to-end against a REAL LiveKit room: publishes a synthetic camera with
# the same SDK and subscribes to it through LiveKitSession. Skips (exit 0)
# unless STPLUGIN_IT_* is set, so the three build runners -- which have no
# LiveKit server -- stay green. See scripts/livekit-dev-room.py.
stplugin_add_test(test_integration_livekit)
set_tests_properties(test_integration_livekit PROPERTIES TIMEOUT 300)
add_test(NAME stplugin_core_tests COMMAND stplugin_core_tests) # Smoke test for the LiveKit SDK link: initialize()/shutdown() must succeed
# in-process. This is the cheapest possible proof that LiveKit::livekit is
# not just linked but loadable and callable (it dlopen-chains into
# liblivekit_ffi, which is where a broken RPATH would show up).
stplugin_add_test(test_livekit_smoke)
target_compile_definitions(test_livekit_smoke PRIVATE
STPLUGIN_EXPECTED_LIVEKIT_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}"
)
+246
View File
@@ -0,0 +1,246 @@
/*
streamer-tools OBS Camera Plugin - minimal loopback HTTP server for tests
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/>
*/
#pragma once
// A single-threaded, one-request-at-a-time HTTP/1.1 server on 127.0.0.1, used
// to exercise the *real* platform HTTP backend (libcurl on Linux/macOS,
// WinHTTP on Windows) rather than only a fake. The handler returns raw bytes,
// so tests can serve deliberately malformed responses and half-closed
// connections -- the cases that must not hang or crash OBS.
//
// Plain HTTP only: a TLS listener would need a certificate and would test
// libcurl/SChannel rather than this plugin.
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
using st_socket_t = SOCKET;
#define ST_INVALID_SOCKET INVALID_SOCKET
#define ST_CLOSE_SOCKET closesocket
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
using st_socket_t = int;
#define ST_INVALID_SOCKET (-1)
#define ST_CLOSE_SOCKET ::close
#endif
namespace sttest {
/// Returns raw response bytes for a received raw request. Returning an empty
/// string means "close the connection without replying".
using LoopbackHandler = std::function<std::string(const std::string &request)>;
class LoopbackServer {
public:
explicit LoopbackServer(LoopbackHandler handler) : handler_(std::move(handler))
{
#ifdef _WIN32
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
#endif
listen_ = ::socket(AF_INET, SOCK_STREAM, 0);
if (listen_ == ST_INVALID_SOCKET)
return;
int reuse = 1;
::setsockopt(listen_, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char *>(&reuse), sizeof(reuse));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0; // let the OS pick a free port
if (::bind(listen_, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) != 0) {
ST_CLOSE_SOCKET(listen_);
listen_ = ST_INVALID_SOCKET;
return;
}
if (::listen(listen_, 4) != 0) {
ST_CLOSE_SOCKET(listen_);
listen_ = ST_INVALID_SOCKET;
return;
}
sockaddr_in bound{};
#ifdef _WIN32
int len = sizeof(bound);
#else
socklen_t len = sizeof(bound);
#endif
if (::getsockname(listen_, reinterpret_cast<sockaddr *>(&bound), &len) != 0) {
ST_CLOSE_SOCKET(listen_);
listen_ = ST_INVALID_SOCKET;
return;
}
port_ = ntohs(bound.sin_port);
thread_ = std::thread([this] { run(); });
}
~LoopbackServer()
{
stop_.store(true);
if (thread_.joinable())
thread_.join();
if (listen_ != ST_INVALID_SOCKET)
ST_CLOSE_SOCKET(listen_);
#ifdef _WIN32
WSACleanup();
#endif
}
LoopbackServer(const LoopbackServer &) = delete;
LoopbackServer &operator=(const LoopbackServer &) = delete;
bool valid() const { return listen_ != ST_INVALID_SOCKET; }
int port() const { return port_; }
std::string baseUrl() const { return "http://127.0.0.1:" + std::to_string(port_); }
int requestCount() const { return requests_.load(); }
/// The most recent raw request, for asserting on method/path/body.
std::string lastRequest() const
{
std::lock_guard<std::mutex> guard(mutex_);
return last_request_;
}
private:
void run()
{
while (!stop_.load()) {
// select() with a short timeout rather than a blocking accept(),
// so the destructor's stop flag is honoured promptly on every
// platform (closing a socket another thread is blocked in
// accept() on is not portable).
fd_set readable;
FD_ZERO(&readable);
FD_SET(listen_, &readable);
timeval tv{};
tv.tv_sec = 0;
tv.tv_usec = 50000; // 50ms
const int ready = ::select(static_cast<int>(listen_) + 1, &readable, nullptr, nullptr, &tv);
if (ready <= 0)
continue;
st_socket_t client = ::accept(listen_, nullptr, nullptr);
if (client == ST_INVALID_SOCKET)
continue;
const std::string request = readRequest(client);
{
std::lock_guard<std::mutex> guard(mutex_);
last_request_ = request;
}
requests_.fetch_add(1);
const std::string response = handler_ ? handler_(request) : std::string();
if (!response.empty())
sendAll(client, response);
ST_CLOSE_SOCKET(client);
}
}
static std::string readRequest(st_socket_t client)
{
std::string data;
char buffer[4096];
std::size_t header_end = std::string::npos;
long content_length = 0;
for (;;) {
#ifdef _WIN32
const int n = ::recv(client, buffer, static_cast<int>(sizeof(buffer)), 0);
#else
const ssize_t n = ::recv(client, buffer, sizeof(buffer), 0);
#endif
if (n <= 0)
break;
data.append(buffer, static_cast<std::size_t>(n));
if (header_end == std::string::npos) {
header_end = data.find("\r\n\r\n");
if (header_end != std::string::npos)
content_length = parseContentLength(data.substr(0, header_end));
}
if (header_end != std::string::npos &&
data.size() >= header_end + 4 + static_cast<std::size_t>(content_length))
break;
}
return data;
}
static long parseContentLength(const std::string &headers)
{
std::string lower;
lower.reserve(headers.size());
for (char c : headers)
lower.push_back(static_cast<char>(c >= 'A' && c <= 'Z' ? c + 32 : c));
const std::size_t at = lower.find("content-length:");
if (at == std::string::npos)
return 0;
return std::strtol(headers.c_str() + at + 15, nullptr, 10);
}
static void sendAll(st_socket_t client, const std::string &data)
{
std::size_t sent = 0;
while (sent < data.size()) {
#ifdef _WIN32
const int n = ::send(client, data.data() + sent, static_cast<int>(data.size() - sent), 0);
#else
const ssize_t n = ::send(client, data.data() + sent, data.size() - sent, 0);
#endif
if (n <= 0)
return;
sent += static_cast<std::size_t>(n);
}
}
LoopbackHandler handler_;
st_socket_t listen_ = ST_INVALID_SOCKET;
int port_ = 0;
std::thread thread_;
std::atomic<bool> stop_{false};
std::atomic<int> requests_{0};
mutable std::mutex mutex_;
std::string last_request_;
};
/// Build a well-formed HTTP/1.1 response with an explicit Content-Length and
/// Connection: close, so the client never waits for keep-alive reuse.
inline std::string httpResponse(int status, const std::string &reason, const std::string &body,
const std::string &content_type = "application/json")
{
return "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n" + "Content-Type: " + content_type +
"\r\n" + "Content-Length: " + std::to_string(body.size()) + "\r\n" + "Connection: close\r\n\r\n" +
body;
}
} // namespace sttest
+467
View File
@@ -0,0 +1,467 @@
/*
streamer-tools OBS Camera Plugin - API client tests
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/>
*/
// Two layers of coverage:
// 1. a fake HttpClient, for response parsing and every error branch;
// 2. a real loopback HTTP server driven through the *platform* backend
// (libcurl or WinHTTP), so the backend itself is exercised in CI on all
// three runners rather than assumed to work.
#include <chrono>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "stplugin/api_client.h"
#include "stplugin/http.h"
#include "loopback_server.h"
#include "test_util.h"
using namespace stplugin;
namespace {
class FakeHttpClient : public HttpClient {
public:
HttpResponse next;
HttpRequest last;
int calls = 0;
HttpResponse send(const HttpRequest &request) override
{
last = request;
++calls;
return next;
}
};
ConnectionConfig testConfig()
{
return ConnectionConfig{"https://streamers.example.com", "main-room", "readkey123"};
}
std::shared_ptr<FakeHttpClient> makeFake(long status, const std::string &body)
{
auto fake = std::make_shared<FakeHttpClient>();
fake->next.status = status;
fake->next.body = body;
return fake;
}
// ---------------------------------------------------------------------------
// URL handling
// ---------------------------------------------------------------------------
void testNormalizeServerUrl()
{
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com"), std::string("https://a.example.com"));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com/"), std::string("https://a.example.com"));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com///"), std::string("https://a.example.com"));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" https://a.example.com "), std::string("https://a.example.com"));
// No scheme defaults to https, never http: the read key is a credential.
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("a.example.com"), std::string("https://a.example.com"));
// An explicit http:// is honoured -- the test LXC is reachable that way.
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("http://192.168.1.175:3000"), std::string("http://192.168.1.175:3000"));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(""), std::string(""));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" "), std::string(""));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://"), std::string(""));
ST_ASSERT_EQ(ApiClient::normalizeServerUrl("/"), std::string(""));
}
void testUrlEncodeAndRedaction()
{
ST_ASSERT_EQ(urlEncode("plain-slug_1.0~"), std::string("plain-slug_1.0~"));
ST_ASSERT_EQ(urlEncode("a b"), std::string("a%20b"));
ST_ASSERT_EQ(urlEncode("a/b?c=d&e"), std::string("a%2Fb%3Fc%3Dd%26e"));
ST_ASSERT_EQ(urlEncode("k\xc3\xa9y"), std::string("k%C3%A9y"));
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret"),
std::string("https://h/api/obs/r/slots?key=***"));
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret&x=1"),
std::string("https://h/api/obs/r/slots?key=***&x=1"));
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing"));
}
void testRedactSensitiveParams()
{
// The LiveKit log bridge's actual use case: a signaling URL embedded in
// a free-form SDK log line, not a bare query string.
ST_ASSERT_EQ(ApiClient::redactSensitiveParams(
"connecting to wss://lk.example.com/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.abc.def&x=1"),
std::string("connecting to wss://lk.example.com/rtc?access_token=<redacted>&x=1"));
// A value can be terminated by a quote or whitespace, not just '&', since
// this scrubs arbitrary text rather than a URL/query string.
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("url=\"wss://h/rtc?access_token=secret\" state=connecting"),
std::string("url=\"wss://h/rtc?access_token=<redacted>\" state=connecting"));
// "key=" is also scrubbed, matching redactedUrl's convention.
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("GET https://h/api/obs/r/slots?key=secret"),
std::string("GET https://h/api/obs/r/slots?key=<redacted>"));
// Both params can appear in the same message, and each is independently
// redacted.
ST_ASSERT_EQ(
ApiClient::redactSensitiveParams("a access_token=tok1 b key=tok2 c"),
std::string("a access_token=<redacted> b key=<redacted> c"));
// Text with neither parameter passes through unchanged.
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("livekit: participant joined"),
std::string("livekit: participant joined"));
// A value at the very end of the string is still bounded correctly.
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("token was access_token=trailing"),
std::string("token was access_token=<redacted>"));
}
void testRequestShape()
{
auto fake = makeFake(200, R"({"slots":[]})");
ApiClient client(fake);
ConnectionConfig config = testConfig();
// Values that need encoding, and stray whitespace an operator would paste.
config.room_slug = " main room ";
config.read_key = " a+b/c ";
(void)client.fetchSlots(config);
ST_ASSERT_EQ(fake->last.method, std::string("GET"));
ST_ASSERT_EQ(fake->last.url,
std::string("https://streamers.example.com/api/obs/main%20room/slots?key=a%2Bb%2Fc"));
auto fake2 = makeFake(200, R"({"lkToken":"t","wsUrl":"wss://x","identity":"obs:r:1"})");
ApiClient client2(fake2);
(void)client2.requestToken(testConfig());
ST_ASSERT_EQ(fake2->last.method, std::string("POST"));
ST_ASSERT_EQ(fake2->last.url,
std::string("https://streamers.example.com/api/obs/main-room/token?key=readkey123"));
ST_ASSERT_EQ(fake2->last.content_type, std::string("application/json"));
}
// ---------------------------------------------------------------------------
// Response parsing
// ---------------------------------------------------------------------------
void testSlotsHappyPath()
{
auto fake = makeFake(200,
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)"
R"({"identity":"cam2","displayName":"Bob","live":false}]})");
ApiClient client(fake);
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(2));
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
ST_ASSERT_EQ(result.slots[0].display_name, std::string("Alice"));
ST_ASSERT_EQ(result.slots[0].live, true);
ST_ASSERT_EQ(result.slots[1].live, false);
}
void testSlotsEdgeCases()
{
// Empty room: a valid answer, not an error.
{
ApiClient client(makeFake(200, R"({"slots":[]})"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(0));
}
// A missing/blank displayName falls back to the identity, matching what
// the server itself does for a slot with no display_name.
{
ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1"},{"identity":"cam2","displayName":""}]})"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(2));
ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1"));
ST_ASSERT_EQ(result.slots[1].display_name, std::string("cam2"));
ST_ASSERT_EQ(result.slots[0].live, false); // missing `live` is not live
}
// An entry with no identity is unusable and is dropped, not surfaced as a
// dropdown row that could never connect.
{
ApiClient client(makeFake(200, R"({"slots":[{"displayName":"ghost"},{"identity":"cam1"}]})"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
}
// Wrong types where the shape is otherwise right: don't crash, don't
// invent values.
{
ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1","displayName":42,"live":"yes"}]})"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1"));
ST_ASSERT_EQ(result.slots[0].live, false);
}
}
void testTokenHappyPath()
{
ApiClient client(makeFake(200,
R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def",)"
R"("wsUrl":"wss://streamers.example.com","identity":"obs:main-room:Ab_1"})"));
const TokenResult result = client.requestToken(testConfig());
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.lk_token, std::string("eyJhbGciOiJIUzI1NiJ9.abc.def"));
ST_ASSERT_EQ(result.ws_url, std::string("wss://streamers.example.com"));
ST_ASSERT_EQ(result.identity, std::string("obs:main-room:Ab_1"));
}
void testHttpErrorStatuses()
{
// 404 -- a wrong read key and an unknown slug are deliberately
// indistinguishable server-side, so the message must not claim to know.
{
ApiClient client(makeFake(404, R"({"error":"not found"})"));
const SlotsResult slots = client.fetchSlots(testConfig());
ST_ASSERT(!slots.ok());
ST_ASSERT(slots.status == ApiStatus::NotFound);
ST_ASSERT_EQ(slots.slots.size(), std::size_t(0));
const TokenResult token = client.requestToken(testConfig());
ST_ASSERT(token.status == ApiStatus::NotFound);
ST_ASSERT_EQ(token.lk_token, std::string(""));
}
// 503 -- server reachable, LiveKit not configured.
{
ApiClient client(makeFake(503, R"({"error":"livekit not configured"})"));
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::Unavailable);
ST_ASSERT(client.requestToken(testConfig()).status == ApiStatus::Unavailable);
}
// Anything else, e.g. a reverse proxy answering before the app does.
{
ApiClient client(makeFake(502, "<html>502 Bad Gateway</html>"));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.status == ApiStatus::HttpError);
ST_ASSERT_EQ(result.message, std::string("HTTP 502"));
}
{
ApiClient client(makeFake(401, ""));
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::HttpError);
}
}
void testMalformedSuccessBodies()
{
// 200 with a body that is not the expected shape must be reported, not
// silently treated as "no slots".
const char *bad_slots[] = {
"",
"not json at all",
"{}",
R"({"slots":null})",
R"({"slots":{}})",
R"({"slots":"cam1"})",
"[]",
R"({"slots":[)",
"<!DOCTYPE html><html>login page</html>",
};
for (const char *body : bad_slots) {
ApiClient client(makeFake(200, body));
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.status == ApiStatus::MalformedResponse);
ST_ASSERT_EQ(result.slots.size(), std::size_t(0));
}
const char *bad_token[] = {
"",
"{}",
R"({"lkToken":""})",
R"({"lkToken":"t"})", // no wsUrl
R"({"wsUrl":"wss://x"})", // no token
R"({"lkToken":123,"wsUrl":"wss://x"})", // wrong type
R"({"lkToken":"t","wsUrl":""})",
"[]",
"\xff\xfe binary",
};
for (const char *body : bad_token) {
ApiClient client(makeFake(200, body));
const TokenResult result = client.requestToken(testConfig());
ST_ASSERT(result.status == ApiStatus::MalformedResponse);
ST_ASSERT_EQ(result.lk_token, std::string(""));
}
}
void testNetworkErrorAndInvalidConfig()
{
{
auto fake = std::make_shared<FakeHttpClient>();
fake->next.network_error = "Could not resolve host";
ApiClient client(fake);
const SlotsResult result = client.fetchSlots(testConfig());
ST_ASSERT(result.status == ApiStatus::NetworkError);
ST_ASSERT_EQ(result.message, std::string("Could not resolve host"));
}
// An incomplete config must never reach the HTTP layer at all.
{
auto fake = makeFake(200, R"({"slots":[]})");
ApiClient client(fake);
ST_ASSERT(client.fetchSlots(ConnectionConfig{"", "r", "k"}).status == ApiStatus::InvalidConfig);
ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "", "k"}).status == ApiStatus::InvalidConfig);
ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "r", ""}).status == ApiStatus::InvalidConfig);
ST_ASSERT(client.requestToken(ConnectionConfig{"https://", "r", "k"}).status == ApiStatus::InvalidConfig);
ST_ASSERT_EQ(fake->calls, 0);
}
// A null HTTP client is a programming error, not a crash.
{
ApiClient client(nullptr);
ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::InvalidConfig);
}
}
// ---------------------------------------------------------------------------
// Real platform HTTP backend, against a real loopback socket
// ---------------------------------------------------------------------------
ConnectionConfig loopbackConfig(const sttest::LoopbackServer &server)
{
return ConnectionConfig{server.baseUrl(), "main-room", "readkey123"};
}
void testPlatformBackendAgainstLoopback()
{
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
ST_ASSERT(http != nullptr);
if (!http)
return;
ApiClient client(http);
// 200 with real slots, and the request line/headers the server sees.
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(200, "OK",
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true}]})");
});
ST_ASSERT(server.valid());
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.slots.size(), std::size_t(1));
ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1"));
ST_ASSERT(server.lastRequest().find("GET /api/obs/main-room/slots?key=readkey123") == 0);
}
// POST /token: verify the method and that a body is actually sent.
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(200, "OK", R"({"lkToken":"tok","wsUrl":"wss://lk.example","identity":"obs:r:1"})");
});
ST_ASSERT(server.valid());
const TokenResult result = client.requestToken(loopbackConfig(server));
ST_ASSERT(result.ok());
ST_ASSERT_EQ(result.lk_token, std::string("tok"));
ST_ASSERT_EQ(result.ws_url, std::string("wss://lk.example"));
ST_ASSERT(server.lastRequest().find("POST /api/obs/main-room/token?key=readkey123") == 0);
}
// 404 and 503 over a real socket.
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(404, "Not Found", R"({"error":"not found"})");
});
ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::NotFound);
}
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(503, "Service Unavailable", R"({"error":"livekit not configured"})");
});
ST_ASSERT(client.requestToken(loopbackConfig(server)).status == ApiStatus::Unavailable);
}
// 200 with a truncated JSON body: must be MalformedResponse, not a hang
// and not a crash.
{
sttest::LoopbackServer server([](const std::string &) {
return sttest::httpResponse(200, "OK", R"({"slots":[{"identity":)");
});
ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::MalformedResponse);
}
// A server that accepts the connection and closes without replying at
// all. This is a network error, and it must come back promptly.
{
sttest::LoopbackServer server([](const std::string &) { return std::string(); });
const auto start = std::chrono::steady_clock::now();
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
const auto elapsed = std::chrono::steady_clock::now() - start;
ST_ASSERT(result.status == ApiStatus::NetworkError);
ST_ASSERT(std::chrono::duration_cast<std::chrono::seconds>(elapsed).count() < 15);
}
// Garbage that is not HTTP at all.
{
sttest::LoopbackServer server([](const std::string &) { return std::string("\x01\x02not http\r\n\r\n"); });
const SlotsResult result = client.fetchSlots(loopbackConfig(server));
ST_ASSERT(result.status == ApiStatus::NetworkError || result.status == ApiStatus::MalformedResponse ||
result.status == ApiStatus::HttpError);
}
// Nothing listening on the port at all: a clean NetworkError.
{
int dead_port = 0;
{
sttest::LoopbackServer server([](const std::string &) { return std::string(); });
dead_port = server.port();
} // server destroyed, port closed
ConnectionConfig config{"http://127.0.0.1:" + std::to_string(dead_port), "main-room", "readkey123"};
ST_ASSERT(client.fetchSlots(config).status == ApiStatus::NetworkError);
}
}
void testPlatformBackendTimeout()
{
// A server that accepts and then stalls. The plugin must give up on its
// own timeout rather than blocking an OBS thread indefinitely.
sttest::LoopbackServer server([](const std::string &) {
std::this_thread::sleep_for(std::chrono::seconds(5));
return sttest::httpResponse(200, "OK", R"({"slots":[]})");
});
ST_ASSERT(server.valid());
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
HttpRequest request;
request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k";
request.timeout_ms = 700;
const auto start = std::chrono::steady_clock::now();
const HttpResponse response = http->send(request);
const auto elapsed = std::chrono::steady_clock::now() - start;
ST_ASSERT(!response.ok());
ST_ASSERT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() < 4000);
}
} // namespace
int main()
{
testNormalizeServerUrl();
testUrlEncodeAndRedaction();
testRedactSensitiveParams();
testRequestShape();
testSlotsHappyPath();
testSlotsEdgeCases();
testTokenHappyPath();
testHttpErrorStatuses();
testMalformedSuccessBodies();
testNetworkErrorAndInvalidConfig();
testPlatformBackendAgainstLoopback();
testPlatformBackendTimeout();
return st_test_report("api_client");
}
+31 -27
View File
@@ -16,44 +16,48 @@ You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/> with this program. If not, see <https://www.gnu.org/licenses/>
*/ */
// Deliberately dependency-free (no gtest/catch2 etc.) so this test target // NOTE on why this file uses ST_ASSERT and not assert(): CI builds Release,
// has no network fetch or package-manager step in CI -- proving the // which defines NDEBUG, which compiles every bare assert() out entirely. This
// "core library builds and tests headlessly, no OBS required" claim // suite previously passed unconditionally for exactly that reason. The
// without adding another moving part to this scaffolding pass. // ST_ASSERT macros in test_util.h are always live and report a pass/fail
// count.
#include <cassert>
#include <cstdio>
#include <cstring> #include <cstring>
#include <string>
#include "stplugin/core.h" #include "stplugin/core.h"
#include "stplugin/core_c.h" #include "stplugin/core_c.h"
#include "test_util.h"
int main() { int main()
// core_version() should return a non-empty string, via both the {
// C++ API and the C ABI wrapper the OBS adapter will actually call. // The version string comes through both the C++ API and the C ABI
// wrapper, and the two must agree.
const char *cpp_version = stplugin::core_version(); const char *cpp_version = stplugin::core_version();
assert(cpp_version != nullptr); ST_ASSERT(cpp_version != nullptr);
assert(std::strlen(cpp_version) > 0); ST_ASSERT(cpp_version != nullptr && std::strlen(cpp_version) > 0);
const char *c_version = stplugin_core_version(); const char *c_version = stplugin_core_version();
assert(c_version != nullptr); ST_ASSERT(c_version != nullptr);
assert(std::strcmp(cpp_version, c_version) == 0); ST_ASSERT_EQ(std::string(cpp_version ? cpp_version : ""), std::string(c_version ? c_version : ""));
// ConnectionConfig::is_valid() -- trivial non-empty checks, but // It is the version CMake injected, not a hand-maintained literal.
// exercised through all four combinations to prove the core library ST_ASSERT_EQ(std::string(cpp_version ? cpp_version : ""), std::string(STPLUGIN_EXPECTED_CORE_VERSION));
// is genuinely testable in isolation.
stplugin::ConnectionConfig valid{"https://streamers.example.com", "main-room", "readkey123"};
assert(valid.is_valid());
stplugin::ConnectionConfig missing_url{"", "main-room", "readkey123"}; // ConnectionConfig::is_valid(): all three fields are required. Named
assert(!missing_url.is_valid()); // locals rather than braced temporaries inline, because the commas inside
// a braced initialiser would be read as macro argument separators.
const stplugin::ConnectionConfig complete{"https://streamers.example.com", "main-room", "readkey123"};
const stplugin::ConnectionConfig no_url{"", "main-room", "readkey123"};
const stplugin::ConnectionConfig no_slug{"https://streamers.example.com", "", "readkey123"};
const stplugin::ConnectionConfig no_key{"https://streamers.example.com", "main-room", ""};
const stplugin::ConnectionConfig empty;
stplugin::ConnectionConfig missing_slug{"https://streamers.example.com", "", "readkey123"}; ST_ASSERT(complete.is_valid());
assert(!missing_slug.is_valid()); ST_ASSERT(!no_url.is_valid());
ST_ASSERT(!no_slug.is_valid());
ST_ASSERT(!no_key.is_valid());
ST_ASSERT(!empty.is_valid());
stplugin::ConnectionConfig missing_key{"https://streamers.example.com", "main-room", ""}; return st_test_report("core");
assert(!missing_key.is_valid());
std::printf("core: all tests passed\n");
return 0;
} }
+370
View File
@@ -0,0 +1,370 @@
/*
streamer-tools OBS Camera Plugin - LiveKit end-to-end integration test
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 one test that proves the session wrapper actually receives media.
//
// It publishes a synthetic camera into a real LiveKit room using the same
// SDK, subscribes to it through stplugin::LiveKitSession, and asserts that
// decoded frames arrive with the geometry the OBS adapter is going to hand
// to obs_source_output_video. It also drives the publisher-swap sequence
// (unpublish, republish) that motivated this whole plugin, and asserts the
// wrapper recovers instead of going stale or erroring out.
//
// It needs a reachable LiveKit server, so it SKIPS (exit 0) unless these are
// set -- CI on the three build runners has no server, and this must not turn
// into a red build there:
//
// STPLUGIN_IT_URL ws://127.0.0.1:7880
// STPLUGIN_IT_PUBLISH_TOKEN JWT: roomJoin + canPublish for the room
// STPLUGIN_IT_SUBSCRIBE_TOKEN JWT: roomJoin + canSubscribe for the room
// STPLUGIN_IT_PUBLISHER_IDENTITY the identity in the publish token
//
// scripts/livekit-dev-room.py mints all four against a `livekit-server --dev`.
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include <livekit/audio_frame.h>
#include <livekit/audio_source.h>
#include <livekit/livekit.h>
#include <livekit/local_audio_track.h>
#include <livekit/local_participant.h>
#include <livekit/local_track_publication.h>
#include <livekit/local_video_track.h>
#include <livekit/room.h>
#include <livekit/video_frame.h>
#include <livekit/video_source.h>
#include "stplugin/session.h"
#include "test_util.h"
using namespace stplugin;
namespace {
constexpr int kWidth = 320;
constexpr int kHeight = 240;
std::string envOrEmpty(const char *name)
{
const char *value = std::getenv(name);
return value ? std::string(value) : std::string();
}
/// A moving horizontal band, so a frozen or stale frame is distinguishable
/// from a live one by luma alone.
livekit::VideoFrame makeFrame(int tick)
{
livekit::VideoFrame frame = livekit::VideoFrame::create(kWidth, kHeight, livekit::VideoBufferType::I420);
std::uint8_t *data = frame.data();
const std::size_t luma = static_cast<std::size_t>(kWidth) * kHeight;
std::memset(data, 16, luma);
const int band = (tick * 7) % kHeight;
std::memset(data + static_cast<std::size_t>(band) * kWidth, 235, kWidth);
std::memset(data + luma, 128, frame.dataSize() - luma);
return frame;
}
void step(const char *what)
{
std::printf(" step: %s\n", what);
std::fflush(stdout);
}
bool waitFor(const std::function<bool()> &predicate, int timeout_ms)
{
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
while (std::chrono::steady_clock::now() < deadline) {
if (predicate())
return true;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
return predicate();
}
struct Publisher {
livekit::Room room;
std::shared_ptr<livekit::VideoSource> video_source;
std::shared_ptr<livekit::LocalVideoTrack> video_track;
std::shared_ptr<livekit::AudioSource> audio_source;
std::shared_ptr<livekit::LocalAudioTrack> audio_track;
std::thread pump;
std::atomic<bool> stop{false};
std::string video_sid;
bool connect(const std::string &url, const std::string &token)
{
livekit::RoomOptions options;
options.auto_subscribe = false;
options.connect_timeout = std::chrono::milliseconds(10000);
return room.connect(url, token, options);
}
bool publishVideo()
{
auto local = room.localParticipant().lock();
if (!local)
return false;
video_source = std::make_shared<livekit::VideoSource>(kWidth, kHeight);
video_track = livekit::LocalVideoTrack::createLocalVideoTrack("camera", video_source);
livekit::TrackPublishOptions options;
options.source = livekit::TrackSource::SOURCE_CAMERA;
options.simulcast = false;
local->publishTrack(video_track, options);
// publishTrack is async server-side; the SID appears on the track once
// the publication lands.
for (int i = 0; i < 100 && video_track->sid().empty(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
video_sid = video_track->sid();
return !video_sid.empty();
}
bool publishAudio()
{
auto local = room.localParticipant().lock();
if (!local)
return false;
audio_source = std::make_shared<livekit::AudioSource>(48000, 1);
audio_track = livekit::LocalAudioTrack::createLocalAudioTrack("microphone", audio_source);
livekit::TrackPublishOptions options;
options.source = livekit::TrackSource::SOURCE_MICROPHONE;
local->publishTrack(audio_track, options);
for (int i = 0; i < 100 && audio_track->sid().empty(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
return !audio_track->sid().empty();
}
bool unpublishVideo()
{
auto local = room.localParticipant().lock();
if (!local)
return false;
// The FFI keys local publications by the *publication* SID, which is
// not necessarily the track SID -- unpublishing by track SID throws
// "track not found".
std::string sid = video_sid;
if (video_track && video_track->publication())
sid = video_track->publication()->sid();
if (sid.empty())
return false;
try {
local->unpublishTrack(sid);
} catch (const std::exception &e) {
std::printf(" unpublishTrack(%s) threw: %s\n", sid.c_str(), e.what());
std::fflush(stdout);
return false;
}
video_sid.clear();
video_track.reset();
video_source.reset();
return true;
}
void startPump()
{
stop.store(false);
pump = std::thread([this] {
int tick = 0;
std::vector<std::int16_t> pcm(480, 0); // 10ms of 48kHz mono
while (!stop.load()) {
if (video_source) {
livekit::VideoFrame frame = makeFrame(tick);
video_source->captureFrame(frame, static_cast<std::int64_t>(tick) * 33333);
}
if (audio_source) {
for (std::size_t i = 0; i < pcm.size(); ++i)
pcm[i] = static_cast<std::int16_t>(((tick * 480 + static_cast<int>(i)) % 100) * 100);
livekit::AudioFrame audio(pcm, 48000, 1, static_cast<int>(pcm.size()));
audio_source->captureFrame(audio);
}
++tick;
std::this_thread::sleep_for(std::chrono::milliseconds(33));
}
});
}
void stopPump()
{
stop.store(true);
if (pump.joinable())
pump.join();
}
~Publisher()
{
stopPump();
room.disconnect(livekit::DisconnectReason::ClientInitiated);
}
};
} // namespace
int main()
{
const std::string url = envOrEmpty("STPLUGIN_IT_URL");
const std::string publish_token = envOrEmpty("STPLUGIN_IT_PUBLISH_TOKEN");
const std::string subscribe_token = envOrEmpty("STPLUGIN_IT_SUBSCRIBE_TOKEN");
const std::string publisher_identity = envOrEmpty("STPLUGIN_IT_PUBLISHER_IDENTITY");
if (url.empty() || publish_token.empty() || subscribe_token.empty() || publisher_identity.empty()) {
std::printf("integration_livekit: SKIPPED (STPLUGIN_IT_* not set)\n");
return 0;
}
LiveKitSession::globalInitialize();
auto publisher_holder = std::make_unique<Publisher>();
Publisher &publisher = *publisher_holder;
step("publisher connect");
ST_ASSERT(publisher.connect(url, publish_token));
step("publish video");
ST_ASSERT(publisher.publishVideo());
step("publish audio");
ST_ASSERT(publisher.publishAudio());
publisher.startPump();
// --- subscribe through the wrapper under test --------------------------
std::atomic<int> video_frames{0};
std::atomic<int> audio_frames{0};
std::atomic<int> bad_frames{0};
std::atomic<int> full_size_frames{0};
std::atomic<int> last_width{0};
std::atomic<int> last_height{0};
std::atomic<int> last_planes{0};
std::atomic<long long> last_timestamp{0};
std::atomic<int> last_sample_rate{0};
std::atomic<int> last_channels{0};
LiveKitSession session;
session.setVideoHandler([&](const VideoFrameData &frame) {
// Everything the OBS adapter is about to dereference must be sane --
// checked against the frame's OWN geometry, not the publisher's.
// WebRTC ramps a new subscription up from a downscaled spatial layer,
// so the first frames after (re)subscribing legitimately arrive
// smaller than what is being published; the adapter has to cope with
// a mid-stream resolution change, and so does this assertion.
const std::uint32_t chroma_stride = static_cast<std::uint32_t>((frame.width + 1) / 2);
const bool sane = frame.width > 0 && frame.height > 0 && frame.format == PixelFormat::I420 &&
frame.plane_count == 3 && frame.data != nullptr &&
frame.size >= expectedFrameBytes(frame.format, frame.width, frame.height) &&
frame.planes[0].data != nullptr && frame.planes[1].data != nullptr &&
frame.planes[2].data != nullptr &&
frame.planes[0].stride >= static_cast<std::uint32_t>(frame.width) &&
frame.planes[1].stride >= chroma_stride && frame.planes[2].stride >= chroma_stride;
if (!sane)
bad_frames.fetch_add(1);
if (frame.width == kWidth && frame.height == kHeight)
full_size_frames.fetch_add(1);
last_width.store(frame.width);
last_height.store(frame.height);
last_planes.store(frame.plane_count);
last_timestamp.store(static_cast<long long>(frame.timestamp_us));
video_frames.fetch_add(1);
});
session.setAudioHandler([&](const AudioFrameData &frame) {
last_sample_rate.store(frame.sample_rate);
last_channels.store(frame.channels);
audio_frames.fetch_add(1);
});
std::atomic<int> state_changes{0};
session.setStateHandler([&](SessionState, const std::string &) { state_changes.fetch_add(1); });
SessionConfig config;
config.ws_url = url;
config.token = subscribe_token;
config.participant_identity = publisher_identity;
config.connect_timeout_ms = 10000;
step("subscriber connect");
ST_ASSERT(session.connect(config));
ST_ASSERT(session.state() == SessionState::Connected);
ST_ASSERT(waitFor([&] { return video_frames.load() >= 15; }, 25000));
ST_ASSERT(session.hasVideo());
ST_ASSERT(!session.waitingForCamera());
ST_ASSERT_EQ(bad_frames.load(), 0);
ST_ASSERT_EQ(last_planes.load(), 3);
// The stream must actually reach the published resolution, not just
// deliver ramp-up frames forever.
ST_ASSERT(waitFor([&] { return full_size_frames.load() > 0; }, 20000));
ST_ASSERT_EQ(last_width.load(), kWidth);
ST_ASSERT_EQ(last_height.load(), kHeight);
ST_ASSERT(last_timestamp.load() > 0);
ST_ASSERT(session.videoFrameCount() >= 15);
ST_ASSERT(waitFor([&] { return audio_frames.load() >= 10; }, 20000));
ST_ASSERT(session.hasAudio());
ST_ASSERT_EQ(last_sample_rate.load(), 48000);
ST_ASSERT(last_channels.load() >= 1);
// --- publisher swap: the bug this plugin exists to make impossible -----
const int before_swap = video_frames.load();
publisher.stopPump();
step("unpublish video");
ST_ASSERT(publisher.unpublishVideo());
ST_ASSERT(waitFor([&] { return !session.hasVideo(); }, 15000));
// An unpublished camera is the placeholder state, never a failure: the
// room connection itself is untouched.
ST_ASSERT(session.state() == SessionState::Connected);
ST_ASSERT(session.waitingForCamera());
// No frames may keep arriving from the dead publisher.
const int after_unpublish = video_frames.load();
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
ST_ASSERT_EQ(video_frames.load(), after_unpublish);
ST_ASSERT(after_unpublish >= before_swap);
// Republish, exactly as a reconnecting browser would.
step("republish video");
ST_ASSERT(publisher.publishVideo());
publisher.startPump();
ST_ASSERT(waitFor([&] { return video_frames.load() >= after_unpublish + 15; }, 25000));
ST_ASSERT(session.hasVideo());
ST_ASSERT(session.state() == SessionState::Connected);
ST_ASSERT_EQ(bad_frames.load(), 0);
// --- teardown ----------------------------------------------------------
step("teardown");
publisher.stopPump();
session.disconnect();
ST_ASSERT(session.state() == SessionState::Disconnected);
ST_ASSERT(!session.hasVideo());
// The publisher's Room must be torn down while the SDK is still
// initialized, or its FFI disconnect fails on the way out.
publisher_holder.reset();
LiveKitSession::globalShutdown();
std::printf("integration_livekit: %d video frames, %d audio frames, %d state changes\n", video_frames.load(),
audio_frames.load(), state_changes.load());
return st_test_report("integration_livekit");
}
+188
View File
@@ -0,0 +1,188 @@
/*
streamer-tools OBS Camera Plugin - JSON reader tests
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/>
*/
#include <string>
#include <vector>
#include "stplugin/json.h"
#include "test_util.h"
using stplugin::json::Value;
using stplugin::json::parse;
static void testRealResponses()
{
// The exact shape apps/server/src/obs/plugin.routes.ts returns.
const Value slots = parse(
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)"
R"({"identity":"cam2","displayName":"Bob","live":false}]})");
ST_ASSERT(slots.valid());
ST_ASSERT(slots.isObject());
ST_ASSERT(slots["slots"].isArray());
ST_ASSERT_EQ(slots["slots"].size(), std::size_t(2));
ST_ASSERT_EQ(slots["slots"].at(0)["identity"].asString(), std::string("cam1"));
ST_ASSERT_EQ(slots["slots"].at(0)["displayName"].asString(), std::string("Alice"));
ST_ASSERT_EQ(slots["slots"].at(0)["live"].asBool(), true);
ST_ASSERT_EQ(slots["slots"].at(1)["live"].asBool(true), false);
const Value token = parse(
R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def","wsUrl":"wss://streamers.example.com",)"
R"("identity":"obs:main-room:Ab_1-cd2"})");
ST_ASSERT_EQ(token["lkToken"].asString(), std::string("eyJhbGciOiJIUzI1NiJ9.abc.def"));
ST_ASSERT_EQ(token["wsUrl"].asString(), std::string("wss://streamers.example.com"));
ST_ASSERT_EQ(token["identity"].asString(), std::string("obs:main-room:Ab_1-cd2"));
const Value error = parse(R"({"error":"not found"})");
ST_ASSERT_EQ(error["error"].asString(), std::string("not found"));
}
// One backslash, as it appears in the JSON *text* being parsed.
//
// Every JSON input below that contains a backslash is built by concatenation
// rather than written as a literal. Two separate portability problems make
// the obvious spellings unsafe, both observed on the Windows CI runner:
// - MSVC still forms escape sequences and universal-character-names inside
// RAW string literals, which it must not: R"( ... backslash-u-d-8-3-d ... )"
// is a hard compile error ("a universal-character-name specifies an
// invalid character"), and a raw string containing backslash-slash is an
// "illegal escape sequence".
// - A doubled backslash immediately followed by 'u' inside an ordinary
// literal sits on a genuinely murky corner of translation phase 1, where
// compilers have historically disagreed about whether a
// universal-character-name is formed.
// Concatenation sidesteps both: no backslash is ever adjacent to a 'u' in
// the source text at all.
static const std::string kBS = "\\";
static void testScalarsAndEscapes()
{
ST_ASSERT(parse("null").isNull());
ST_ASSERT_EQ(parse("true").asBool(), true);
ST_ASSERT_EQ(parse("false").asBool(true), false);
ST_ASSERT_EQ(parse("0").asNumber(), 0.0);
ST_ASSERT_EQ(parse("-12").asNumber(), -12.0);
ST_ASSERT_EQ(parse("1.5e2").asNumber(), 150.0);
ST_ASSERT_EQ(parse("\"\"").asString("x"), std::string(""));
// "a\"b\\c\/d" -> a"b\c/d
ST_ASSERT_EQ(parse("\"a" + kBS + "\"b" + kBS + kBS + "c" + kBS + "/d\"").asString(),
std::string("a\"b\\c/d"));
// "\n\t\r\b\f"
ST_ASSERT_EQ(parse("\"" + kBS + "n" + kBS + "t" + kBS + "r" + kBS + "b" + kBS + "f\"").asString(),
std::string("\n\t\r\b\f"));
// \uXXXX escapes, including a surrogate pair (an emoji in a display name
// is entirely plausible and must not corrupt the dropdown).
ST_ASSERT_EQ(parse("\"" + kBS + "u0041\"").asString(), std::string("A"));
ST_ASSERT_EQ(parse("\"caf" + kBS + "u00e9\"").asString(), std::string("caf\xc3\xa9"));
ST_ASSERT_EQ(parse("\"" + kBS + "ud83d" + kBS + "ude00\"").asString(),
std::string("\xf0\x9f\x98\x80"));
// Whitespace everywhere legal.
ST_ASSERT_EQ(parse(" {\n \"a\" :\t[ 1 , 2 ]\r\n} ")["a"].size(), std::size_t(2));
}
static void testMalformedIsRejectedNotCrashed()
{
const std::vector<std::string> bad = {
"",
" ",
"{",
"}",
"[",
"[1,",
"[1,]",
"{\"a\"}",
"{\"a\":}",
"{\"a\":1,}",
"{a:1}",
"{'a':1}",
"\"unterminated",
"\"bad" + kBS + "escape\"", // not a JSON escape character
"\"" + kBS + "u00\"", // truncated code point
"\"" + kBS + "uZZZZ\"", // non-hex code point
"\"" + kBS + "ud83d\"", // lone high surrogate
"\"" + kBS + "ude00\"", // lone low surrogate
"01", // leading zero
"+1",
".5",
"1.",
"1e",
"1e+",
"tru",
"nulll",
"{}garbage", // trailing content
"[1,2] [3]",
"\"raw\ncontrol\"", // literal control char inside a string
"\xff\xfe", // binary garbage
"<!DOCTYPE html><html><body>502 Bad Gateway</body></html>",
};
for (const std::string &text : bad) {
const Value v = parse(text);
ST_ASSERT(!v.valid());
// Accessors on an invalid value must still be safe and return the
// caller's fallback.
ST_ASSERT_EQ(v["anything"].asString("fallback"), std::string("fallback"));
ST_ASSERT_EQ(v.at(0).asNumber(-1.0), -1.0);
ST_ASSERT_EQ(v.size(), std::size_t(0));
}
}
static void testDepthLimit()
{
// Deep-but-legal nesting is rejected rather than recursed into, so a
// hostile response cannot overflow the stack inside OBS.
std::string deep;
const int depth = stplugin::json::kMaxDepth + 50;
for (int i = 0; i < depth; ++i)
deep += "[";
for (int i = 0; i < depth; ++i)
deep += "]";
ST_ASSERT(!parse(deep).valid());
// Just inside the limit still parses.
std::string shallow;
for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i)
shallow += "[";
shallow += "1";
for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i)
shallow += "]";
ST_ASSERT(parse(shallow).valid());
}
static void testWrongTypesFallBack()
{
const Value v = parse(R"({"n":5,"s":"x","b":true,"arr":[1],"obj":{}})");
ST_ASSERT_EQ(v["n"].asString("fallback"), std::string("fallback"));
ST_ASSERT_EQ(v["s"].asNumber(-1.0), -1.0);
ST_ASSERT_EQ(v["s"].asBool(true), true);
ST_ASSERT_EQ(v["missing"].asString("fallback"), std::string("fallback"));
ST_ASSERT_EQ(v["arr"].at(5).asNumber(-1.0), -1.0);
ST_ASSERT_EQ(v["obj"].at(0).asNumber(-1.0), -1.0);
ST_ASSERT_EQ(v["n"]["deeper"].asString("fallback"), std::string("fallback"));
}
int main()
{
testRealResponses();
testScalarsAndEscapes();
testMalformedIsRejectedNotCrashed();
testDepthLimit();
testWrongTypesFallBack();
return st_test_report("json");
}
+64
View File
@@ -0,0 +1,64 @@
/*
streamer-tools OBS Camera Plugin - LiveKit SDK link smoke test
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/>
*/
// Proves the pinned client-sdk-cpp release is genuinely linked and callable:
// the SDK's own global init/shutdown runs in-process without crashing, and
// the header-reported build version matches the version CMake pinned.
//
// This does NOT touch the network -- livekit::initialize() only sets up
// global SDK state and log routing.
#include <cstdio>
#include <cstring>
#include <livekit/build.h>
#include <livekit/livekit.h>
#include <livekit/logging.h>
#include "test_util.h"
int main()
{
// The pin CMake resolved is passed in as a define; the SDK's own
// generated build.h reports what was actually unpacked. A mismatch means
// a stale extracted SDK directory is being reused.
ST_ASSERT_EQ(std::string(LIVEKIT_BUILD_VERSION), std::string(STPLUGIN_EXPECTED_LIVEKIT_VERSION));
// initialize() returns true when this call performed the init, false if
// the SDK was already initialized. Either way it must not crash, and a
// second call must report "already initialized".
bool first = livekit::initialize(livekit::LogLevel::Error);
ST_ASSERT(first);
bool second = livekit::initialize(livekit::LogLevel::Error);
ST_ASSERT(!second);
// Log level round-trips through the SDK's global state.
livekit::setLogLevel(livekit::LogLevel::Warn);
ST_ASSERT(livekit::getLogLevel() == livekit::LogLevel::Warn);
livekit::shutdown();
// The SDK documents that initialize() may be called again after
// shutdown(); exercise that so a half-torn-down global state would show
// up here rather than in OBS.
ST_ASSERT(livekit::initialize(livekit::LogLevel::Error));
livekit::shutdown();
return st_test_report("livekit_smoke");
}
+355
View File
@@ -0,0 +1,355 @@
/*
streamer-tools OBS Camera Plugin - session wrapper tests
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/>
*/
// What is and is not covered here, stated plainly because it matters:
//
// COVERED headlessly -- the session's own decision-making: the state
// machine's transitions (including the publisher-swap and reconnect paths
// that motivated this plugin), track selection, frame geometry validation,
// and the real connect() failure paths against the real SDK (bad URL,
// unreachable host, garbage token).
//
// NOT COVERED here -- anything that needs a LiveKit server to answer:
// a successful connect, actual subscription, and actual decoded frames
// reaching the handlers. Those can only be verified against a real room,
// and the design doc's Testing section puts that in the integration-test /
// manual-sign-off bucket.
#include <atomic>
#include <chrono>
#include <string>
#include <thread>
#include "stplugin/session.h"
#include "stplugin/session_types.h"
#include "test_util.h"
using namespace stplugin;
namespace {
// ---------------------------------------------------------------------------
// Pure logic
// ---------------------------------------------------------------------------
void testFrameGeometry()
{
ST_ASSERT_EQ(planeCount(PixelFormat::I420), 3);
ST_ASSERT_EQ(planeCount(PixelFormat::NV12), 2);
ST_ASSERT_EQ(planeCount(PixelFormat::BGRA), 1);
// 1280x720 I420: 921600 luma + 2 * 230400 chroma.
ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1280, 720), std::size_t(1382400));
ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::NV12, 1280, 720), std::size_t(1382400));
ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::BGRA, 1280, 720), std::size_t(3686400));
// Odd dimensions round the chroma planes up, the way libyuv does.
ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 3, 3), std::size_t(9 + 2 * 4));
ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1, 1), std::size_t(1 + 2));
// Degenerate geometry is 0, which the reader treats as "drop the frame".
ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 0, 720), std::size_t(0));
ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, 1280, 0), std::size_t(0));
ST_ASSERT_EQ(expectedFrameBytes(PixelFormat::I420, -1, -1), std::size_t(0));
ST_ASSERT_EQ(std::string(describePixelFormat(PixelFormat::I420)), std::string("I420"));
}
void testTrackSelection()
{
const std::string want = "cam1";
// The camera we asked for.
ST_ASSERT(isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Camera));
// A video track with no declared source is taken on kind alone.
ST_ASSERT(isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Unknown));
// Someone else's camera.
ST_ASSERT(!isWantedVideoTrack(want, "cam2", MediaKind::Video, MediaSource::Camera));
// The right participant's screenshare is explicitly NOT the camera --
// streamer-tools publishes those as separate sources.
ST_ASSERT(!isWantedVideoTrack(want, "cam1", MediaKind::Video, MediaSource::Screenshare));
// Their microphone is not a video track.
ST_ASSERT(!isWantedVideoTrack(want, "cam1", MediaKind::Audio, MediaSource::Microphone));
// No selection means nothing matches -- never "the first thing we see".
ST_ASSERT(!isWantedVideoTrack("", "cam1", MediaKind::Video, MediaSource::Camera));
ST_ASSERT(!isWantedVideoTrack("", "", MediaKind::Video, MediaSource::Camera));
ST_ASSERT(isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::Microphone));
ST_ASSERT(isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::Unknown));
ST_ASSERT(!isWantedAudioTrack(want, "cam1", MediaKind::Audio, MediaSource::ScreenshareAudio));
ST_ASSERT(!isWantedAudioTrack(want, "cam1", MediaKind::Video, MediaSource::Camera));
ST_ASSERT(!isWantedAudioTrack(want, "other", MediaKind::Audio, MediaSource::Microphone));
}
void testStateMachineHappyPath()
{
SessionStateMachine m;
ST_ASSERT(m.state() == SessionState::Idle);
ST_ASSERT(!m.hasVideo());
ST_ASSERT(!m.waitingForCamera());
m.onConnectRequested();
ST_ASSERT(m.state() == SessionState::Connecting);
// Connecting is not "waiting for camera": the placeholder belongs to a
// live connection with a dark slot, not to a connection in progress.
ST_ASSERT(!m.waitingForCamera());
m.onConnectSucceeded();
ST_ASSERT(m.state() == SessionState::Connected);
ST_ASSERT(m.waitingForCamera());
m.onVideoAttached();
ST_ASSERT(m.hasVideo());
ST_ASSERT(!m.waitingForCamera());
m.onAudioAttached();
ST_ASSERT(m.hasAudio());
m.onLocalDisconnect();
ST_ASSERT(m.state() == SessionState::Disconnected);
ST_ASSERT(!m.hasVideo());
ST_ASSERT(!m.hasAudio());
}
void testPublisherSwapIsNotAnError()
{
// The motivating bug: a slot's publisher restarts mid-show. That must
// read as "waiting for camera", never as a failure, and the connection
// state must not move at all.
SessionStateMachine m;
m.onConnectRequested();
m.onConnectSucceeded();
m.onVideoAttached();
m.onVideoDetached();
ST_ASSERT(m.state() == SessionState::Connected);
ST_ASSERT(!m.hasVideo());
ST_ASSERT(m.waitingForCamera());
ST_ASSERT(m.detail().empty());
m.onVideoAttached();
ST_ASSERT(m.state() == SessionState::Connected);
ST_ASSERT(m.hasVideo());
ST_ASSERT(!m.waitingForCamera());
}
void testReconnect()
{
SessionStateMachine m;
m.onConnectRequested();
m.onConnectSucceeded();
m.onVideoAttached();
m.onReconnecting();
ST_ASSERT(m.state() == SessionState::Reconnecting);
// Tracks are re-subscribed on the far side, so video is not live yet.
ST_ASSERT(!m.hasVideo());
ST_ASSERT(m.waitingForCamera());
ST_ASSERT_EQ(m.detail(), std::string("reconnecting"));
m.onReconnected();
ST_ASSERT(m.state() == SessionState::Connected);
ST_ASSERT(m.detail().empty());
// A stray reconnect notification after a hard failure must not resurrect
// the session.
SessionStateMachine dead;
dead.onConnectRequested();
dead.onConnectFailed("token rejected");
dead.onReconnecting();
ST_ASSERT(dead.state() == SessionState::Failed);
dead.onReconnected();
ST_ASSERT(dead.state() == SessionState::Failed);
}
void testFailureAndRecovery()
{
SessionStateMachine m;
m.onConnectRequested();
m.onConnectFailed("token rejected");
ST_ASSERT(m.state() == SessionState::Failed);
ST_ASSERT_EQ(m.detail(), std::string("token rejected"));
ST_ASSERT(!m.waitingForCamera());
// A fresh attempt clears the stale reason, so a healthy connection can
// never be shown next to the previous failure's message.
m.onConnectRequested();
ST_ASSERT(m.detail().empty());
m.onConnectSucceeded();
ST_ASSERT(m.state() == SessionState::Connected);
ST_ASSERT(m.detail().empty());
// A fatal room end (duplicate identity, token rejected) is Failed; an
// ordinary drop is Disconnected.
SessionStateMachine fatal;
fatal.onConnectRequested();
fatal.onConnectSucceeded();
fatal.onRoomEnded("another client joined with the same identity", true);
ST_ASSERT(fatal.state() == SessionState::Failed);
SessionStateMachine dropped;
dropped.onConnectRequested();
dropped.onConnectSucceeded();
dropped.onRoomEnded("the signalling connection closed", false);
ST_ASSERT(dropped.state() == SessionState::Disconnected);
// Room-ended events after we are already down are ignored, so a late
// event cannot overwrite the reason the operator needs to see.
SessionStateMachine idle;
idle.onRoomEnded("stray", true);
ST_ASSERT(idle.state() == SessionState::Idle);
}
// ---------------------------------------------------------------------------
// Real SDK, failure paths only (no LiveKit server available headlessly)
// ---------------------------------------------------------------------------
void testConnectRejectsIncompleteConfig()
{
LiveKitSession session;
std::atomic<int> state_calls{0};
session.setStateHandler([&](SessionState, const std::string &) { state_calls.fetch_add(1); });
SessionConfig config;
config.ws_url = "";
config.token = "t";
config.participant_identity = "cam1";
ST_ASSERT(!session.connect(config));
ST_ASSERT(session.state() == SessionState::Failed);
ST_ASSERT(!session.stateDetail().empty());
config.ws_url = "ws://127.0.0.1:1";
config.token = "";
ST_ASSERT(!session.connect(config));
ST_ASSERT(session.state() == SessionState::Failed);
config.token = "t";
config.participant_identity = "";
ST_ASSERT(!session.connect(config));
ST_ASSERT(session.state() == SessionState::Failed);
// The state handler fired for each attempt (Connecting + Failed).
ST_ASSERT(state_calls.load() >= 6);
// Frame counters stay at zero and nothing crashes on teardown.
ST_ASSERT_EQ(session.videoFrameCount(), std::uint64_t(0));
ST_ASSERT_EQ(session.audioFrameCount(), std::uint64_t(0));
session.disconnect();
session.disconnect(); // idempotent
ST_ASSERT(session.state() == SessionState::Failed || session.state() == SessionState::Disconnected);
}
void testConnectToUnreachableServerFailsCleanly()
{
// Port 1 on loopback: nothing is listening, and the connection is
// refused immediately rather than hanging. This exercises the real
// livekit::Room::connect() failure path, with a real (garbage) token.
LiveKitSession session;
std::atomic<int> video_frames{0};
session.setVideoHandler([&](const VideoFrameData &) { video_frames.fetch_add(1); });
SessionConfig config;
config.ws_url = "ws://127.0.0.1:1";
config.token = "not.a.real.token";
config.participant_identity = "cam1";
config.connect_timeout_ms = 3000;
const auto start = std::chrono::steady_clock::now();
const bool ok = session.connect(config);
const auto elapsed = std::chrono::steady_clock::now() - start;
ST_ASSERT(!ok);
ST_ASSERT(session.state() == SessionState::Failed);
ST_ASSERT(!session.stateDetail().empty());
ST_ASSERT_EQ(video_frames.load(), 0);
// Must not sit on the caller's thread indefinitely -- this runs on an OBS
// thread in the real adapter.
ST_ASSERT(std::chrono::duration_cast<std::chrono::seconds>(elapsed).count() < 60);
session.disconnect();
}
void testConnectToNonLiveKitServerFailsCleanly()
{
// A URL that resolves and connects but is not a LiveKit signalling
// endpoint. The realistic operator mistake: pasting the app URL.
LiveKitSession session;
SessionConfig config;
config.ws_url = "ws://127.0.0.1:1/rtc";
config.token = "eyJhbGciOiJIUzI1NiJ9.bm90YXRva2Vu.x";
config.participant_identity = "cam1";
config.connect_timeout_ms = 3000;
ST_ASSERT(!session.connect(config));
ST_ASSERT(session.state() == SessionState::Failed);
session.disconnect();
}
void testDestroyWithoutDisconnect()
{
// The OBS adapter destroys sources without necessarily having called
// disconnect() first (an OBS shutdown mid-connect, say). The destructor
// must join every thread it started rather than terminating.
{
LiveKitSession session;
SessionConfig config;
config.ws_url = "ws://127.0.0.1:1";
config.token = "t";
config.participant_identity = "cam1";
config.connect_timeout_ms = 2000;
(void)session.connect(config);
}
ST_ASSERT(true); // reaching here at all is the assertion
}
void testGlobalInitIsReferenceCounted()
{
// Several OBS sources may each hold the SDK open; the last one out turns
// the lights off, and an unbalanced extra shutdown must not underflow.
LiveKitSession::globalInitialize();
LiveKitSession::globalInitialize();
LiveKitSession::globalShutdown();
LiveKitSession::globalShutdown();
LiveKitSession::globalShutdown(); // extra, must be harmless
LiveKitSession::globalInitialize();
LiveKitSession::globalShutdown();
ST_ASSERT(true);
}
} // namespace
int main()
{
testFrameGeometry();
testTrackSelection();
testStateMachineHappyPath();
testPublisherSwapIsNotAnError();
testReconnect();
testFailureAndRecovery();
LiveKitSession::globalInitialize();
testConnectRejectsIncompleteConfig();
testConnectToUnreachableServerFailsCleanly();
testConnectToNonLiveKitServerFailsCleanly();
testDestroyWithoutDisconnect();
LiveKitSession::globalShutdown();
testGlobalInitIsReferenceCounted();
return st_test_report("session");
}
+91
View File
@@ -0,0 +1,91 @@
/*
streamer-tools OBS Camera Plugin - minimal test harness
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/>
*/
#pragma once
// Deliberately dependency-free (no gtest/catch2) so the core library's test
// targets add no package-manager or network step to any of the three CI
// platforms. Unlike bare assert(), this keeps running after a failure and
// prints a real pass/fail count, so CI output says how much actually ran.
#include <cstdio>
#include <string>
namespace st_test_detail {
inline int &checks_run()
{
static int n = 0;
return n;
}
inline int &checks_failed()
{
static int n = 0;
return n;
}
inline std::string to_display(const std::string &v)
{
return "\"" + v + "\"";
}
inline std::string to_display(const char *v)
{
return v ? ("\"" + std::string(v) + "\"") : std::string("(null)");
}
inline std::string to_display(bool v)
{
return v ? "true" : "false";
}
template<typename T> inline std::string to_display(const T &v)
{
return std::to_string(v);
}
} // namespace st_test_detail
#define ST_ASSERT(expr) \
do { \
++st_test_detail::checks_run(); \
if (!(expr)) { \
++st_test_detail::checks_failed(); \
std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #expr); \
} \
} while (0)
#define ST_ASSERT_EQ(actual, expected) \
do { \
++st_test_detail::checks_run(); \
auto _st_a = (actual); \
auto _st_e = (expected); \
if (!(_st_a == _st_e)) { \
++st_test_detail::checks_failed(); \
std::fprintf(stderr, "FAIL %s:%d: %s\n actual: %s\n expected: %s\n", \
__FILE__, __LINE__, #actual " == " #expected, \
st_test_detail::to_display(_st_a).c_str(), \
st_test_detail::to_display(_st_e).c_str()); \
} \
} while (0)
inline int st_test_report(const char *suite)
{
const int run = st_test_detail::checks_run();
const int failed = st_test_detail::checks_failed();
if (failed == 0) {
std::printf("%s: %d checks passed\n", suite, run);
return 0;
}
std::printf("%s: %d/%d checks FAILED\n", suite, failed, run);
return 1;
}
+71 -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,70 @@ 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")
# The binary subdirectory matches what OBS actually searches. From
# AddExtraModulePaths() in obs-studio's UI/window-basic-main.cpp, the
# per-user plugin layout on Linux and Windows is
# <config>/obs-studio/plugins/<name>/bin/64bit/<name>.{so,dll}
# <config>/obs-studio/plugins/<name>/data/
# so staging into bin/64bit makes build/package/ a straight drop-in.
#
# macOS is NOT this shape -- there OBS looks for a
# <name>.plugin/Contents/MacOS bundle -- and this build does not produce one.
# See the macOS packaging gap in README.md; the flat bin/ here is honest
# about being unfinished rather than pretending to be installable.
if(APPLE)
set(STPLUGIN_PACKAGE_BIN_DIR "${STPLUGIN_PACKAGE_DIR}/bin")
else()
set(STPLUGIN_PACKAGE_BIN_DIR "${STPLUGIN_PACKAGE_DIR}/bin/64bit")
endif()
add_custom_command(TARGET ${STPLUGIN_PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_BIN_DIR}"
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:${STPLUGIN_PROJECT_NAME}>" "${STPLUGIN_PACKAGE_BIN_DIR}/"
COMMAND ${CMAKE_COMMAND} -E copy ${LIVEKIT_SDK_RUNTIME_LIBS} "${STPLUGIN_PACKAGE_BIN_DIR}/"
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");
}
+605
View File
@@ -0,0 +1,605 @@
/*
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.
//
// The plain video_format_get_parameters, not the _for_format variant:
// the latter only exists from libobs 30 onwards, and it only differs for
// the 10-bit formats (I010/P010) this source never receives. Using the
// older entry point keeps the module loadable on an older OBS, which is
// the direction that matters -- OBS refuses modules built against a
// NEWER libobs than the one running.
video_format_get_parameters(VIDEO_CS_709, VIDEO_RANGE_PARTIAL, 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;
if (config_changed) {
// A settings change is an operator action: try again immediately,
// whatever the previous attempt's backoff had grown to.
backoff_ms = kBackoffStartMs;
}
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;
// Back off like any other unsuccessful attempt, so an
// unconfigured source is not a once-a-second no-op forever.
// Filling the settings in bumps the generation and wakes this
// thread immediately, so the backoff costs no responsiveness.
backoff_ms = backoff_ms * 2 < kBackoffMaxMs ? backoff_ms * 2 : kBackoffMaxMs;
} 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);
// Forget the last geometry so the next stream logs its first
// frame again; otherwise a reconnect at the same resolution is
// silent, and the log stops answering "did video come back".
self->last_geometry.store(0);
}
});
{
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;
{
// Must be set while holding `mutex`, matching how `generation` is
// mutated in applySettings: the worker's wait predicate reads
// `stopping` under this same lock, so setting it outside the lock
// can race between the worker's predicate check and it entering
// the wait, dropping the notify_all() below and leaving the worker
// asleep for its full backoff (up to kBackoffMaxMs) while this
// (OBS UI) thread blocks in worker.join().
std::lock_guard<std::mutex> guard(self->mutex);
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;
}
// LiveKit's signaling connection URL carries the access token as a
// query parameter. This is defensive, not a response to a confirmed
// leak: if the SDK ever logs that URL (or anything else carrying
// "access_token=" or "key="), the token must not land verbatim in an
// OBS log file that a director might paste into a support ticket. Scrub
// unconditionally before this message ever reaches obs_log.
const std::string scrubbed = ApiClient::redactSensitiveParams(message);
obs_log(obs_level, "livekit: %s", scrubbed.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");
}
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Mint LiveKit JWTs for the integration test against a local dev server.
Usage:
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
Prints shell `export` lines for the four STPLUGIN_IT_* variables
core/tests/test_integration_livekit.cpp looks for. With no arguments it uses
`livekit-server --dev`'s built-in devkey/secret credentials.
Standard library only (hmac + hashlib + base64) -- there is deliberately no
pip install step here, so this runs anywhere the repo is checked out.
"""
import argparse
import base64
import hashlib
import hmac
import json
import os
import time
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def mint(api_key: str, api_secret: str, identity: str, room: str, *, publish: bool, subscribe: bool) -> str:
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
claims = {
"iss": api_key,
"sub": identity,
"name": identity,
"nbf": now - 10,
"exp": now + 3600,
"video": {
"room": room,
"roomJoin": True,
"canPublish": publish,
"canSubscribe": subscribe,
"canPublishData": False,
},
}
signing_input = f"{b64url(json.dumps(header, separators=(',', ':')).encode())}." \
f"{b64url(json.dumps(claims, separators=(',', ':')).encode())}"
signature = hmac.new(api_secret.encode(), signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{b64url(signature)}"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=os.environ.get("LIVEKIT_URL", "ws://127.0.0.1:7880"))
parser.add_argument("--api-key", default=os.environ.get("LIVEKIT_API_KEY", "devkey"))
parser.add_argument("--api-secret", default=os.environ.get("LIVEKIT_API_SECRET", "secret"))
parser.add_argument("--room", default="obs-plugin-it")
parser.add_argument("--publisher-identity", default="cam-test")
parser.add_argument("--subscriber-identity", default="obs:obs-plugin-it:test")
args = parser.parse_args()
publish_token = mint(args.api_key, args.api_secret, args.publisher_identity, args.room,
publish=True, subscribe=False)
# Deliberately the same grant shape the real server mints for the plugin
# (apps/server/src/obs/plugin.routes.ts -> mintCaptionsToken): subscribe
# only, never publish.
subscribe_token = mint(args.api_key, args.api_secret, args.subscriber_identity, args.room,
publish=False, subscribe=True)
print(f'export STPLUGIN_IT_URL="{args.url}"')
print(f'export STPLUGIN_IT_PUBLISH_TOKEN="{publish_token}"')
print(f'export STPLUGIN_IT_SUBSCRIBE_TOKEN="{subscribe_token}"')
print(f'export STPLUGIN_IT_PUBLISHER_IDENTITY="{args.publisher_identity}"')
if __name__ == "__main__":
main()
+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.