diff --git a/README.md b/README.md index 3bbf6f8..1a7e175 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,196 @@ # obs-streamer-tools-plugin -Native OBS Studio source plugin that will pull streamer-tools camera feeds -directly from LiveKit over WebRTC (via LiveKit's `livekit-ffi`), replacing -the current SRT/RTSP-via-VLC-or-Media-Source path for directors. Full -design: `docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md` in -the `streamer-tools` repo (as of this writing, that doc lives on the -`worktree-obs-plugin-server-api` branch there, not yet merged to `main`). +Native OBS Studio source plugin that pulls streamer-tools camera feeds +directly from LiveKit over WebRTC, replacing the current SRT/RTSP-via-VLC-or- +Media-Source path for directors. Full design: +`docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md` in the +`streamer-tools` repo. -## Status: scaffolding only +## Status -**This repository does not talk to LiveKit or streamer-tools yet.** This -first pass exists to prove the CMake toolchain, the core-library/OBS-adapter -split, and the three-platform Gitea Actions CI pipeline all actually work, -so the next phase (real `livekit-ffi` integration) can be planned against -verified facts instead of assumptions. See the design doc's "Components" -and "CI / build pipeline" sections for the target architecture this scaffold -is standing up. +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, and macOS/Windows have only ever been built by CI, never loaded. +See "What is verified, and how" below for exactly what that means, and +"Testing this by hand" for what a human still needs to do. ## 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/ - core.h C++ API (ConnectionConfig, core_version()) - core_c.h C ABI wrapper the OBS adapter calls into - src/core.cpp - tests/ dependency-free CTest unit tests - -obs-adapter/ - thin OBS glue (C, adapted from obsproject/obs-plugintemplate) - src/plugin-main.c obs_module_load/unload + a stub source registration - src/plugin-support.{h,c.in} + core.h version + ConnectionConfig + json.h small strict JSON reader + http.h injectable HTTP client interface + api_client.h the two /api/obs/:slug/* calls + session_types.h media/state types + the pure session logic + session.h LiveKitSession, the livekit::Room wrapper + src/ + 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 - -.gitea/workflows/build.yml - 3-platform CI matrix (see below) +scripts/livekit-dev-room.py - mints tokens for the integration test +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 -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. +## How it works -## 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::` — 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 -pass, in scope for the next one): +Nothing on the OBS UI thread ever blocks on the network. The one deliberate +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. -- 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). +### Design decisions worth knowing before changing this -## 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 - `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. +## Building -## What actually builds, and how it was verified +Linux (the platform that is fully verified): ``` -$ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release --- libobs found (/usr/lib/x86_64-linux-gnu/cmake/libobs) -- building OBS adapter module --- Configuring done --- Generating done - -$ 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 +sudo apt-get install -y cmake ninja-build libobs-dev libcurl4-openssl-dev +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build +ctest --test-dir build --output-on-failure ``` -This is a genuine, dynamically-linked OBS module -- not the "standalone -shared library without linking OBS" fallback the scaffolding task's scope -explicitly allowed as an acceptable compromise. That fallback path is still -exercised (and needed) on macOS/Windows CI for now; see above. +The configure step downloads the pinned `client-sdk-cpp` release (~13 MB) into +`build/_deps/livekit-sdk`. Point `-DSTPLUGIN_LIVEKIT_SDK_DIR=` at a +persistent directory to cache it across builds; `-DSTPLUGIN_LIVEKIT_SDK_VERSION` +and `-DSTPLUGIN_LIVEKIT_SDK_TRIPLE` override the pin and the release triple. + +The build stages a runnable layout into `build/package/`: + +``` +build/package/bin/streamer-tools-camera.so (RPATH=$ORIGIN) +build/package/bin/liblivekit.so +build/package/bin/liblivekit_ffi.so +build/package/data/locale/en-US.ini +build/package/licenses/... +``` + +`build/package/bin` is what gets installed — the module resolves the LiveKit +libraries from `$ORIGIN` / `@loader_path`, not from the build tree. + +## 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 +``` + +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`, 121 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 | + +**Not verified anywhere:** +- The OBS GUI, on any platform. No human has looked at this in OBS. +- macOS and Windows beyond "CI compiles and the core tests pass". The WinHTTP + backend has never run against a real streamer-tools server. +- 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 -`.gitea/workflows/build.yml` runs on every push/PR, matrixed across the -three runners confirmed available to this repo by living under the -`CyberCoveLLC` org (see the design doc's "CI / build pipeline" section): +`.gitea/workflows/build.yml` runs on every push, matrixed across the three +runners available to this repo under the `CyberCoveLLC` org. | Job | `runs-on` | Runner | |---|---|---| -| `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-latest` | `gitea-runner.internal.cloud-hosting.io` (Global) | | `macos` | `macos-latest` | `home-mac` (Global) | -| `windows` | `windows-latest` | `winvm-builder` (org-scoped to `CyberCoveLLC`) | +| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | -The Linux job installs `libobs-dev` and builds the real OBS adapter module -plus the core library, then runs `ctest`. The macOS/Windows jobs build and -test only the core library for now (see toolchain notes above for why). - -No packaging/release step yet -- out of scope for this pass. +Linux uses Ubuntu's `libobs-dev` and builds the real OBS adapter. 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 no newer than what Linux builds against — OBS +rejects a module built against a newer libobs than the one running it. diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index b13f033..8ffef13 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -43,6 +43,11 @@ endif() find_package(Threads REQUIRED) target_link_libraries(stplugin_core PUBLIC Threads::Threads) +target_compile_definitions(stplugin_core PRIVATE + STPLUGIN_CORE_VERSION="${PROJECT_VERSION}" + STPLUGIN_LIVEKIT_SDK_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}" +) + set_target_properties(stplugin_core PROPERTIES POSITION_INDEPENDENT_CODE ON ) diff --git a/core/include/stplugin/api_client.h b/core/include/stplugin/api_client.h index bf00faf..e09c954 100644 --- a/core/include/stplugin/api_client.h +++ b/core/include/stplugin/api_client.h @@ -98,8 +98,12 @@ public: /// Takes ownership of the HTTP client, so tests can inject a fake. explicit ApiClient(std::shared_ptr http); - SlotsResult fetchSlots(const ConnectionConfig &config) const; - TokenResult requestToken(const ConnectionConfig &config) const; + /// @param timeout_ms whole-request timeout. Kept as a parameter because + /// the properties dialog's "Refresh" button runs on OBS's UI thread with + /// an operator waiting, and must give up sooner than a background + /// reconnect would. + SlotsResult fetchSlots(const ConnectionConfig &config, int timeout_ms = 10000) const; + TokenResult requestToken(const ConnectionConfig &config, int timeout_ms = 10000) const; /// Accepts what an operator would actually paste: a bare hostname, a URL /// with a trailing slash, extra whitespace. Returns an empty string if diff --git a/core/src/api_client.cpp b/core/src/api_client.cpp index 8b49bd7..5cff058 100644 --- a/core/src/api_client.cpp +++ b/core/src/api_client.cpp @@ -120,7 +120,7 @@ std::string ApiClient::redactedUrl(const std::string &url) return url.substr(0, value) + "***" + url.substr(end); } -SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const +SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const { SlotsResult result; if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { @@ -132,6 +132,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const HttpRequest request; request.method = "GET"; request.url = buildUrl(config, "/slots"); + request.timeout_ms = timeout_ms; const HttpResponse response = http_->send(request); const ApiStatus status = classify(response, result.message); @@ -170,7 +171,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const return result; } -TokenResult ApiClient::requestToken(const ConnectionConfig &config) const +TokenResult ApiClient::requestToken(const ConnectionConfig &config, int timeout_ms) const { TokenResult result; if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) { @@ -184,6 +185,7 @@ TokenResult ApiClient::requestToken(const ConnectionConfig &config) const request.url = buildUrl(config, "/token"); request.content_type = "application/json"; request.body = "{}"; + request.timeout_ms = timeout_ms; const HttpResponse response = http_->send(request); const ApiStatus status = classify(response, result.message); diff --git a/core/src/core.cpp b/core/src/core.cpp index f9e2fc7..4e85727 100644 --- a/core/src/core.cpp +++ b/core/src/core.cpp @@ -22,7 +22,9 @@ with this program. If not, see namespace stplugin { const char *core_version() { - return "0.0.1-scaffold"; + // Injected by CMake from the top-level project() version, so the string + // OBS logs on load is the actual build, not a hand-maintained literal. + return STPLUGIN_CORE_VERSION; } bool ConnectionConfig::is_valid() const { diff --git a/obs-adapter/CMakeLists.txt b/obs-adapter/CMakeLists.txt index d5be005..22046b9 100644 --- a/obs-adapter/CMakeLists.txt +++ b/obs-adapter/CMakeLists.txt @@ -1,9 +1,8 @@ # streamer-tools OBS Camera Plugin - OBS adapter # -# Thin glue only, per the design doc: source registration, (eventually) -# properties UI, and pushing frames into OBS. All real logic lives in -# ../core. Only added to the build when find_package(libobs) succeeds -# (see top-level CMakeLists.txt) -- see README.md for why. +# Thin glue only, per the design doc: source registration, the properties UI, +# and pushing frames into OBS. All real logic lives in ../core. Only added to +# the build when find_package(libobs) succeeds (see top-level CMakeLists.txt). set(STPLUGIN_PROJECT_NAME "streamer-tools-camera") set(STPLUGIN_PROJECT_VERSION "${PROJECT_VERSION}") @@ -15,7 +14,7 @@ configure_file( ) add_library(${STPLUGIN_PROJECT_NAME} MODULE - src/plugin-main.c + src/plugin-main.cpp ${CMAKE_CURRENT_BINARY_DIR}/plugin-support.c ) @@ -35,3 +34,53 @@ set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES PREFIX "" OUTPUT_NAME ${STPLUGIN_PROJECT_NAME} ) + +# The module has to find liblivekit / liblivekit_ffi next to itself once it is +# installed into an OBS plugin directory, not at the build-tree path CMake's +# default RPATH would bake in. +# BUILD_WITH_INSTALL_RPATH is ON deliberately: the artifact that ships is a +# straight copy of the built module (see the staging step below), so the +# build-tree RPATH must never be baked in -- it would work on the build +# machine and nowhere else. +if(APPLE) + set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES + BUILD_WITH_INSTALL_RPATH ON + INSTALL_RPATH "@loader_path" + ) +elseif(UNIX) + set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES + BUILD_WITH_INSTALL_RPATH ON + INSTALL_RPATH "$ORIGIN" + ) +endif() + +# --- staged, runnable layout ------------------------------------------------ +# Everything a human needs to copy into an OBS plugin directory ends up under +# build/package/, with the LiveKit shared libraries and the licence files +# beside the module. Without this the module loads on the build machine only, +# via the build-tree RPATH. +set(STPLUGIN_PACKAGE_DIR "${CMAKE_BINARY_DIR}/package") + +add_custom_command(TARGET ${STPLUGIN_PROJECT_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/bin" + COMMAND ${CMAKE_COMMAND} -E copy "$" "${STPLUGIN_PACKAGE_DIR}/bin/" + COMMAND ${CMAKE_COMMAND} -E copy ${LIVEKIT_SDK_RUNTIME_LIBS} "${STPLUGIN_PACKAGE_DIR}/bin/" + COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/data/locale" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/data/locale/en-US.ini" + "${STPLUGIN_PACKAGE_DIR}/data/locale/" + # Redistributing LiveKit's prebuilt binaries means shipping their licence + # and notice with them. See third_party/livekit/README.md -- including + # what upstream does NOT ship, which is an open question, not a solved one. + COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/licenses/livekit" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_SOURCE_DIR}/third_party/livekit/LICENSE" + "${CMAKE_SOURCE_DIR}/third_party/livekit/NOTICE" + "${CMAKE_SOURCE_DIR}/third_party/livekit/README.md" + "${STPLUGIN_PACKAGE_DIR}/licenses/livekit/" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_SOURCE_DIR}/LICENSE" + "${STPLUGIN_PACKAGE_DIR}/licenses/" + COMMENT "Staging plugin + LiveKit runtime libraries + licences into ${STPLUGIN_PACKAGE_DIR}" + VERBATIM +) diff --git a/obs-adapter/data/locale/en-US.ini b/obs-adapter/data/locale/en-US.ini index 7d2a041..aafe740 100644 --- a/obs-adapter/data/locale/en-US.ini +++ b/obs-adapter/data/locale/en-US.ini @@ -1,2 +1,11 @@ -# streamer-tools OBS Camera Plugin - en-US locale -# No user-facing strings yet -- this is scaffolding (see plugin-main.c). +StreamerToolsCamera="streamer-tools Camera" +ServerUrl="streamer-tools server URL" +RoomSlug="Room" +ReadKey="Read key" +Camera="Camera" +RefreshCameras="Refresh camera list" +Status="Status" +NoCameraSelected="(no camera selected)" +OfflineSuffix=" (offline)" +NotInRoomSuffix=" (not in this room)" +CamerasFound=" cameras found" diff --git a/obs-adapter/src/plugin-main.c b/obs-adapter/src/plugin-main.c deleted file mode 100644 index cc86c13..0000000 --- a/obs-adapter/src/plugin-main.c +++ /dev/null @@ -1,74 +0,0 @@ -/* -streamer-tools OBS Camera Plugin -Copyright (C) 2026 CyberCoveLLC - -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 -*/ - -/* 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 -#include -#include -#include - -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"); -} diff --git a/obs-adapter/src/plugin-main.cpp b/obs-adapter/src/plugin-main.cpp new file mode 100644 index 0000000..c55b6be --- /dev/null +++ b/obs-adapter/src/plugin-main.cpp @@ -0,0 +1,567 @@ +/* +streamer-tools OBS Camera Plugin +Copyright (C) 2026 CyberCoveLLC + +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 +*/ + +// 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 +#include + +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 slot_cache; + std::string status_text = "not configured"; + + // --- worker --- + std::thread worker; + std::condition_variable wake; + std::atomic stopping{false}; + + std::shared_ptr api; + std::unique_ptr session; + + std::atomic frames_out{0}; + /// width<<16 | height of the last frame pushed, so a geometry change can + /// be logged exactly once. + std::atomic 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 status_is_error{false}; + + void setStatus(std::string text) + { + std::lock_guard guard(mutex); + status_text = std::move(text); + } + + std::string statusText() + { + std::lock_guard guard(mutex); + return status_text; + } +}; + +// --------------------------------------------------------------------------- +// Frame output +// --------------------------------------------------------------------------- + +void outputVideoFrame(CameraSource *self, const VideoFrameData &frame) +{ + obs_source_frame out = {}; + out.width = static_cast(frame.width); + out.height = static_cast(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(frame.planes[i].data); + out.linesize[i] = frame.planes[i].stride; + } + + // WebRTC delivers limited-range BT.709 for anything at or above SD. + video_format_get_parameters_for_format(VIDEO_CS_709, VIDEO_RANGE_PARTIAL, out.format, out.color_matrix, + out.color_range_min, out.color_range_max); + out.full_range = false; + + obs_source_output_video(self->source, &out); + self->frames_out.fetch_add(1); + + // Log the first frame, and any later change of geometry. A director's + // log then answers "did video ever arrive, and at what size" without + // anyone having to reproduce the problem -- and WebRTC really does + // change resolution mid-stream as it ramps a subscription up. + const std::uint32_t geometry = out.width << 16 | out.height; + const std::uint32_t previous = self->last_geometry.exchange(geometry); + if (previous != geometry) + obs_log(LOG_INFO, "video frame %ux%u %s", out.width, out.height, describePixelFormat(frame.format)); +} + +void outputAudioFrame(CameraSource *self, const AudioFrameData &frame) +{ + if (!frame.samples || frame.samples_per_channel <= 0) + return; + + obs_source_audio out = {}; + out.data[0] = reinterpret_cast(frame.samples); + out.frames = static_cast(frame.samples_per_channel); + out.format = AUDIO_FORMAT_16BIT; // interleaved int16, which is what the SDK hands us + out.samples_per_sec = static_cast(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 lock(self->mutex); + if (self->stopping.load()) + break; + config = self->config; + camera = self->camera_identity; + generation = self->generation; + } + + const bool config_changed = generation != connected_generation; + const bool needs_connect = + !connected || config_changed || + (self->session && (self->session->state() == SessionState::Failed || + self->session->state() == SessionState::Disconnected)); + + if (needs_connect) { + if (connected || config_changed) { + if (self->session) + self->session->disconnect(); + obs_source_output_video(self->source, nullptr); + connected = false; + } + + if (!config.is_valid() || camera.empty()) { + self->setStatus("not configured -- set the server URL, room, read key and camera"); + connected_generation = generation; + backoff_ms = kBackoffStartMs; + } else { + self->setStatus("connecting..."); + const TokenResult token = self->api->requestToken(config); + if (!token.ok()) { + // token.message is the specific one ("the read key is + // wrong or has been rotated"); describeApiStatus is the + // generic fallback. Printing both just reads as noise. + const std::string message = + token.message.empty() ? describeApiStatus(token.status) : token.message; + self->setStatus(message); + self->status_is_error.store(true); + obs_log(LOG_WARNING, "token request failed: %s", message.c_str()); + } else { + // Refresh the dropdown cache while we are here; the + // properties UI then opens instantly instead of blocking + // on the network. + const SlotsResult slots = self->api->fetchSlots(config); + if (slots.ok()) { + std::lock_guard 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 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 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(std::shared_ptr(createPlatformHttpClient())); + self->session = std::unique_ptr(new LiveKitSession()); + + self->session->setVideoHandler([self](const VideoFrameData &frame) { outputVideoFrame(self, frame); }); + self->session->setAudioHandler([self](const AudioFrameData &frame) { outputAudioFrame(self, frame); }); + self->session->setStateHandler([self](SessionState state, const std::string &detail) { + self->setStatus(detail.empty() ? describeSessionState(state) : detail); + self->status_is_error.store(state == SessionState::Failed); + + // A camera that stopped publishing must not leave its last frame on + // screen -- that is precisely the stale-media failure this plugin + // exists to avoid. A null frame clears the source. + if (state != SessionState::Connected) + obs_source_output_video(self->source, nullptr); + }); + + { + std::lock_guard 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(data), settings); +} + +void sourceDestroy(void *data) +{ + auto *self = static_cast(data); + if (!self) + return; + + self->stopping.store(true); + self->wake.notify_all(); + if (self->worker.joinable()) + self->worker.join(); + + // The worker already disconnected, but do it again explicitly: the + // session's own destructor would too, and all three are idempotent. + if (self->session) + self->session->disconnect(); + self->session.reset(); + + delete self; +} + +/// Rebuild the camera dropdown from the cached slot list, always including +/// whatever identity is currently selected so OBS cannot silently clear a +/// setting just because the room is dark right now. +void populateCameraList(CameraSource *self, obs_property_t *list, const std::string &selected) +{ + obs_property_list_clear(list); + obs_property_list_add_string(list, obs_module_text("NoCameraSelected"), ""); + + bool saw_selected = selected.empty(); + std::vector slots; + { + std::lock_guard 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(data); + if (!self) + return false; + + ConnectionConfig config; + { + std::lock_guard 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 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(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 guard(self->mutex); + selected = self->camera_identity; + } + // Built from the cache the worker keeps warm, so opening properties + // never blocks on the network. The button below is the way to force + // a round trip. + populateCameraList(self, list, selected); + } + + obs_properties_add_button(props, kPropRefresh, obs_module_text("RefreshCameras"), refreshButtonClicked); + + // An OBS_TEXT_INFO property renders its *description* as the visible + // label, so the status line goes there rather than into a tooltip an + // operator would never hover over mid-show. + const std::string status_text = self ? self->statusText() : std::string(obs_module_text("Status")); + obs_property_t *status = obs_properties_add_text(props, kSettingStatus, status_text.c_str(), OBS_TEXT_INFO); + if (self && self->status_is_error.load()) + obs_property_text_set_info_type(status, OBS_TEXT_INFO_WARNING); + + return props; +} + +struct obs_source_info cameraSourceInfo() +{ + struct obs_source_info info = {}; + info.id = "streamer_tools_camera_source"; + info.type = OBS_SOURCE_TYPE_INPUT; + info.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE; + info.icon_type = OBS_ICON_TYPE_CAMERA; + info.get_name = sourceGetName; + info.create = sourceCreate; + info.destroy = sourceDestroy; + info.update = sourceUpdate; + info.get_defaults = sourceGetDefaults; + info.get_properties = sourceGetProperties; + return info; +} + +struct obs_source_info streamer_tools_camera_source = cameraSourceInfo(); + +void livekitLogToObs(livekit::LogLevel level, const std::string &, const std::string &message) +{ + int obs_level = LOG_INFO; + switch (level) { + case livekit::LogLevel::Error: + case livekit::LogLevel::Critical: obs_level = LOG_ERROR; break; + case livekit::LogLevel::Warn: obs_level = LOG_WARNING; break; + case livekit::LogLevel::Info: obs_level = LOG_INFO; break; + default: obs_level = LOG_DEBUG; break; + } + obs_log(obs_level, "livekit: %s", message.c_str()); +} + +} // namespace + +bool obs_module_load(void) +{ + LiveKitSession::globalInitialize(); + // Route the SDK's own logging into OBS's log file instead of stderr, + // where a director would never see it. + livekit::setLogCallback(livekitLogToObs); + + obs_register_source(&streamer_tools_camera_source); + obs_log(LOG_INFO, "streamer-tools camera plugin loaded (core %s)", core_version()); + return true; +} + +void obs_module_unload(void) +{ + livekit::setLogCallback(nullptr); + LiveKitSession::globalShutdown(); + obs_log(LOG_INFO, "streamer-tools camera plugin unloaded"); +} diff --git a/third_party/livekit/LICENSE b/third_party/livekit/LICENSE new file mode 100644 index 0000000..67db858 --- /dev/null +++ b/third_party/livekit/LICENSE @@ -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. diff --git a/third_party/livekit/NOTICE b/third_party/livekit/NOTICE new file mode 100644 index 0000000..692adc9 --- /dev/null +++ b/third_party/livekit/NOTICE @@ -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. diff --git a/third_party/livekit/README.md b/third_party/livekit/README.md new file mode 100644 index 0000000..c7e599f --- /dev/null +++ b/third_party/livekit/README.md @@ -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--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.