Merge pull request 'LiveKit integration: real camera feed pipeline' (#1) from feat/livekit-integration into main
This commit was merged in pull request #1.
This commit is contained in:
+104
-22
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
core/ - core library (C++17, no OBS dependency, headless-testable)
|
cmake/LiveKitSDK.cmake - downloads + unpacks the pinned client-sdk-cpp release
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
/*
|
|
||||||
streamer-tools OBS Camera Plugin
|
|
||||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
|
||||||
|
|
||||||
This program is free software; you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation; either version 2 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along
|
|
||||||
with this program. If not, see <https://www.gnu.org/licenses/>
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* SCAFFOLDING. This registers a source type so the OBS-adapter/core-
|
|
||||||
* library split and OBS's module-loading toolchain can be proven end to
|
|
||||||
* end, but it does not do anything real yet: no LiveKit FFI session, no
|
|
||||||
* frames pushed via obs_source_output_video/audio, no properties UI.
|
|
||||||
* See docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md in
|
|
||||||
* the streamer-tools repo for what this becomes. */
|
|
||||||
|
|
||||||
#include <obs-module.h>
|
|
||||||
#include <util/bmem.h>
|
|
||||||
#include <plugin-support.h>
|
|
||||||
#include <stplugin/core_c.h>
|
|
||||||
|
|
||||||
OBS_DECLARE_MODULE()
|
|
||||||
OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US")
|
|
||||||
|
|
||||||
static const char *stcam_source_get_name(void *unused)
|
|
||||||
{
|
|
||||||
UNUSED_PARAMETER(unused);
|
|
||||||
return "streamer-tools Camera (scaffold - not yet functional)";
|
|
||||||
}
|
|
||||||
|
|
||||||
static void *stcam_source_create(obs_data_t *settings, obs_source_t *source)
|
|
||||||
{
|
|
||||||
UNUSED_PARAMETER(settings);
|
|
||||||
UNUSED_PARAMETER(source);
|
|
||||||
/* No LiveKit session, no state to speak of yet -- just proving the
|
|
||||||
* source registers and OBS can instantiate/destroy it cleanly. */
|
|
||||||
return bzalloc(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void stcam_source_destroy(void *data)
|
|
||||||
{
|
|
||||||
bfree(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
static struct obs_source_info streamer_tools_camera_source = {
|
|
||||||
.id = "streamer_tools_camera_source",
|
|
||||||
.type = OBS_SOURCE_TYPE_INPUT,
|
|
||||||
.output_flags = OBS_SOURCE_ASYNC_VIDEO,
|
|
||||||
.get_name = stcam_source_get_name,
|
|
||||||
.create = stcam_source_create,
|
|
||||||
.destroy = stcam_source_destroy,
|
|
||||||
};
|
|
||||||
|
|
||||||
bool obs_module_load(void)
|
|
||||||
{
|
|
||||||
obs_log(LOG_INFO, "streamer-tools camera plugin scaffold loaded (core library version %s)",
|
|
||||||
stplugin_core_version());
|
|
||||||
obs_register_source(&streamer_tools_camera_source);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void obs_module_unload(void)
|
|
||||||
{
|
|
||||||
obs_log(LOG_INFO, "streamer-tools camera plugin scaffold unloaded");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Vendored
+175
@@ -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.
|
||||||
Vendored
+13
@@ -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.
|
||||||
Vendored
+36
@@ -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.
|
||||||
Reference in New Issue
Block a user