Make the OBS adapter real: properties UI, connect, and frame output
The stub source becomes an actual streamer-tools camera. On create it reads
server URL / room slug / read key / camera identity from obs_data_t, mints a
subscribe-only token through ApiClient, connects LiveKitSession, and pushes
decoded frames into obs_source_output_video / obs_source_output_audio. The
source is now OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO |
OBS_SOURCE_DO_NOT_DUPLICATE with an OBS_ICON_TYPE_CAMERA icon.
The file is C++ rather than C now: the core library's API is C++ and the C ABI
shim existed only to avoid that. obs-module.h already declares the module
entry points extern "C", so nothing is lost.
Properties UI: server URL, room slug, a masked read-key field (it is a
credential and is masked everywhere else in streamer-tools), a camera dropdown,
a "Refresh camera list" button, and a status line.
- The dropdown is built from a cache the worker keeps warm on every connect,
so opening properties never blocks on the network. The button is the
explicit way to force a round trip, with a shortened 5s timeout -- for which
ApiClient's two calls gained a timeout_ms parameter.
- The currently-selected identity is always in the list, labelled "(not in
this room)" if absent, so OBS cannot silently clear a working setting just
because the room happens to be dark.
- The status line is the OBS_TEXT_INFO property's description (which is what
OBS actually renders) and switches to the warning info type on a real error.
Threading: OBS's UI and graphics threads are never blocked on the network.
Each source owns a worker thread that mints, connects, and reconnects with
exponential backoff (1s -> 30s), waking early on any settings change via a
generation counter. Frames are pushed from LiveKitSession's reader threads
directly; obs_source_output_video/_audio are thread-safe.
Two details that matter operationally:
- A null frame is pushed whenever the session leaves Connected, so a camera
that stopped publishing clears instead of leaving its last frame on screen.
Leaving stale media up is precisely the failure this plugin exists to avoid.
- The SDK's own logging is routed into OBS's log file via
livekit::setLogCallback, instead of stderr where a director would never
see it. The adapter also logs the first frame and every later geometry
change, so a log answers "did video ever arrive, and at what size".
Packaging: the build now stages a runnable layout into build/package/ --
the module (RPATH $ORIGIN / @loader_path, so it resolves the LiveKit
libraries from beside itself rather than from the build tree), liblivekit +
liblivekit_ffi, the locale data, and the licence files. third_party/livekit/
carries client-sdk-cpp's Apache-2.0 LICENSE and NOTICE from the pinned tag.
Its README records a correction to the design doc: the "bundled LICENSE.md
with ~28 third-party licence blocks" the doc expects DOES NOT EXIST at
v1.10.1 -- not in any of the five release archives (which contain only
include/, lib/, bin/ and build-info.json) and not in the repo at that tag,
which has only LICENSE and NOTICE. The aggregated third-party notice covering
the WebRTC/OpenH264 code inside liblivekit_ffi.so has not been located, and
that is flagged as an open licensing question rather than papered over.
Verified on Ubuntu 24.04 against real libobs 30.0.2, a real
livekit-server 1.13.6, and a stand-in API serving plugin.routes.ts's exact
shapes, using a headless libobs harness (obs_startup + obs_reset_audio +
obs_reset_video + obs_open_module + obs_source_create):
registered=1 output_flags=0x87
[streamer-tools-camera] connected to ws://127.0.0.1:7880 as
obs:main-room:qY85r9D0PaPt, watching cam-test
[streamer-tools-camera] video frame 640x360 I420
camera dropdown has 3 items:
[0] (no camera selected) =
[1] Test Camera = cam-test
[2] Dark Camera (offline) = other-cam
status: connected (info_type=0)
and with a deliberately wrong read key:
status: unknown room slug, or the read key is wrong or has been rotated
(info_type=1)
with retry-and-backoff and no crash. ctest: 6/6 passed.
Still unverified, and the README says so plainly: the OBS GUI on any platform,
macOS/Windows beyond compiling, A/V sync, and end-to-end latency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
This commit is contained in:
@@ -1,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:<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
|
||||
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=<path>` 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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -98,8 +98,12 @@ public:
|
||||
/// Takes ownership of the HTTP client, so tests can inject a fake.
|
||||
explicit ApiClient(std::shared_ptr<HttpClient> 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-1
@@ -22,7 +22,9 @@ with this program. If not, see <https://www.gnu.org/licenses/>
|
||||
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 {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# streamer-tools OBS Camera Plugin - OBS adapter
|
||||
#
|
||||
# Thin glue only, per the design doc: source registration, (eventually)
|
||||
# properties UI, and pushing frames into OBS. All real logic lives in
|
||||
# ../core. Only added to the build when find_package(libobs) succeeds
|
||||
# (see top-level CMakeLists.txt) -- see README.md for why.
|
||||
# Thin glue only, per the design doc: source registration, the properties UI,
|
||||
# and pushing frames into OBS. All real logic lives in ../core. Only added to
|
||||
# the build when find_package(libobs) succeeds (see top-level CMakeLists.txt).
|
||||
|
||||
set(STPLUGIN_PROJECT_NAME "streamer-tools-camera")
|
||||
set(STPLUGIN_PROJECT_VERSION "${PROJECT_VERSION}")
|
||||
@@ -15,7 +14,7 @@ configure_file(
|
||||
)
|
||||
|
||||
add_library(${STPLUGIN_PROJECT_NAME} MODULE
|
||||
src/plugin-main.c
|
||||
src/plugin-main.cpp
|
||||
${CMAKE_CURRENT_BINARY_DIR}/plugin-support.c
|
||||
)
|
||||
|
||||
@@ -35,3 +34,53 @@ set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
|
||||
PREFIX ""
|
||||
OUTPUT_NAME ${STPLUGIN_PROJECT_NAME}
|
||||
)
|
||||
|
||||
# The module has to find liblivekit / liblivekit_ffi next to itself once it is
|
||||
# installed into an OBS plugin directory, not at the build-tree path CMake's
|
||||
# default RPATH would bake in.
|
||||
# BUILD_WITH_INSTALL_RPATH is ON deliberately: the artifact that ships is a
|
||||
# straight copy of the built module (see the staging step below), so the
|
||||
# build-tree RPATH must never be baked in -- it would work on the build
|
||||
# machine and nowhere else.
|
||||
if(APPLE)
|
||||
set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
|
||||
BUILD_WITH_INSTALL_RPATH ON
|
||||
INSTALL_RPATH "@loader_path"
|
||||
)
|
||||
elseif(UNIX)
|
||||
set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
|
||||
BUILD_WITH_INSTALL_RPATH ON
|
||||
INSTALL_RPATH "$ORIGIN"
|
||||
)
|
||||
endif()
|
||||
|
||||
# --- staged, runnable layout ------------------------------------------------
|
||||
# Everything a human needs to copy into an OBS plugin directory ends up under
|
||||
# build/package/, with the LiveKit shared libraries and the licence files
|
||||
# beside the module. Without this the module loads on the build machine only,
|
||||
# via the build-tree RPATH.
|
||||
set(STPLUGIN_PACKAGE_DIR "${CMAKE_BINARY_DIR}/package")
|
||||
|
||||
add_custom_command(TARGET ${STPLUGIN_PROJECT_NAME} POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/bin"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy "$<TARGET_FILE:${STPLUGIN_PROJECT_NAME}>" "${STPLUGIN_PACKAGE_DIR}/bin/"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy ${LIVEKIT_SDK_RUNTIME_LIBS} "${STPLUGIN_PACKAGE_DIR}/bin/"
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/data/locale"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/data/locale/en-US.ini"
|
||||
"${STPLUGIN_PACKAGE_DIR}/data/locale/"
|
||||
# Redistributing LiveKit's prebuilt binaries means shipping their licence
|
||||
# and notice with them. See third_party/livekit/README.md -- including
|
||||
# what upstream does NOT ship, which is an open question, not a solved one.
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${STPLUGIN_PACKAGE_DIR}/licenses/livekit"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
"${CMAKE_SOURCE_DIR}/third_party/livekit/LICENSE"
|
||||
"${CMAKE_SOURCE_DIR}/third_party/livekit/NOTICE"
|
||||
"${CMAKE_SOURCE_DIR}/third_party/livekit/README.md"
|
||||
"${STPLUGIN_PACKAGE_DIR}/licenses/livekit/"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy
|
||||
"${CMAKE_SOURCE_DIR}/LICENSE"
|
||||
"${STPLUGIN_PACKAGE_DIR}/licenses/"
|
||||
COMMENT "Staging plugin + LiveKit runtime libraries + licences into ${STPLUGIN_PACKAGE_DIR}"
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
# streamer-tools OBS Camera Plugin - en-US locale
|
||||
# No user-facing strings yet -- this is scaffolding (see plugin-main.c).
|
||||
StreamerToolsCamera="streamer-tools Camera"
|
||||
ServerUrl="streamer-tools server URL"
|
||||
RoomSlug="Room"
|
||||
ReadKey="Read key"
|
||||
Camera="Camera"
|
||||
RefreshCameras="Refresh camera list"
|
||||
Status="Status"
|
||||
NoCameraSelected="(no camera selected)"
|
||||
OfflineSuffix=" (offline)"
|
||||
NotInRoomSuffix=" (not in this room)"
|
||||
CamerasFound=" cameras found"
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin
|
||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program. If not, see <https://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
/* SCAFFOLDING. This registers a source type so the OBS-adapter/core-
|
||||
* library split and OBS's module-loading toolchain can be proven end to
|
||||
* end, but it does not do anything real yet: no LiveKit FFI session, no
|
||||
* frames pushed via obs_source_output_video/audio, no properties UI.
|
||||
* See docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md in
|
||||
* the streamer-tools repo for what this becomes. */
|
||||
|
||||
#include <obs-module.h>
|
||||
#include <util/bmem.h>
|
||||
#include <plugin-support.h>
|
||||
#include <stplugin/core_c.h>
|
||||
|
||||
OBS_DECLARE_MODULE()
|
||||
OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US")
|
||||
|
||||
static const char *stcam_source_get_name(void *unused)
|
||||
{
|
||||
UNUSED_PARAMETER(unused);
|
||||
return "streamer-tools Camera (scaffold - not yet functional)";
|
||||
}
|
||||
|
||||
static void *stcam_source_create(obs_data_t *settings, obs_source_t *source)
|
||||
{
|
||||
UNUSED_PARAMETER(settings);
|
||||
UNUSED_PARAMETER(source);
|
||||
/* No LiveKit session, no state to speak of yet -- just proving the
|
||||
* source registers and OBS can instantiate/destroy it cleanly. */
|
||||
return bzalloc(1);
|
||||
}
|
||||
|
||||
static void stcam_source_destroy(void *data)
|
||||
{
|
||||
bfree(data);
|
||||
}
|
||||
|
||||
static struct obs_source_info streamer_tools_camera_source = {
|
||||
.id = "streamer_tools_camera_source",
|
||||
.type = OBS_SOURCE_TYPE_INPUT,
|
||||
.output_flags = OBS_SOURCE_ASYNC_VIDEO,
|
||||
.get_name = stcam_source_get_name,
|
||||
.create = stcam_source_create,
|
||||
.destroy = stcam_source_destroy,
|
||||
};
|
||||
|
||||
bool obs_module_load(void)
|
||||
{
|
||||
obs_log(LOG_INFO, "streamer-tools camera plugin scaffold loaded (core library version %s)",
|
||||
stplugin_core_version());
|
||||
obs_register_source(&streamer_tools_camera_source);
|
||||
return true;
|
||||
}
|
||||
|
||||
void obs_module_unload(void)
|
||||
{
|
||||
obs_log(LOG_INFO, "streamer-tools camera plugin scaffold unloaded");
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin
|
||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program. If not, see <https://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
// The thin glue layer, per the design doc: source registration, the
|
||||
// properties UI, and pushing frames into OBS. Everything that can be tested
|
||||
// headlessly lives in ../core.
|
||||
//
|
||||
// Two threading rules shape this whole file:
|
||||
// - OBS calls create/update/destroy/get_properties on its UI or graphics
|
||||
// thread. Nothing here may block them on the network, so every API call
|
||||
// and every LiveKit connect happens on the source's own worker thread.
|
||||
// The one exception is the explicit "Refresh camera list" button, where
|
||||
// the operator asked for a round trip and is waiting for its result.
|
||||
// - Frames arrive on LiveKitSession's reader threads. obs_source_output_video
|
||||
// and obs_source_output_audio are safe to call from any thread, so they
|
||||
// are called directly from there with no extra copy.
|
||||
|
||||
#include <obs-module.h>
|
||||
#include <util/platform.h>
|
||||
|
||||
#include <plugin-support.h>
|
||||
|
||||
#include <stplugin/api_client.h>
|
||||
#include <stplugin/core.h>
|
||||
#include <stplugin/http.h>
|
||||
#include <stplugin/session.h>
|
||||
#include <stplugin/session_types.h>
|
||||
|
||||
#include <livekit/logging.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
using namespace stplugin;
|
||||
|
||||
OBS_DECLARE_MODULE()
|
||||
OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US")
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kSettingServerUrl = "server_url";
|
||||
constexpr const char *kSettingRoomSlug = "room_slug";
|
||||
constexpr const char *kSettingReadKey = "read_key";
|
||||
constexpr const char *kSettingCamera = "camera";
|
||||
constexpr const char *kSettingStatus = "status";
|
||||
constexpr const char *kPropRefresh = "refresh";
|
||||
|
||||
/// Shorter than the core default: this one runs while an operator is staring
|
||||
/// at a properties dialog they pressed a button in.
|
||||
constexpr int kPropertiesTimeoutMs = 5000;
|
||||
|
||||
/// Reconnect backoff bounds. A dark room or a stopped server must not turn
|
||||
/// into a request storm, but a transient blip should recover quickly.
|
||||
constexpr int kBackoffStartMs = 1000;
|
||||
constexpr int kBackoffMaxMs = 30000;
|
||||
|
||||
std::string settingString(obs_data_t *settings, const char *key)
|
||||
{
|
||||
const char *value = obs_data_get_string(settings, key);
|
||||
return value ? std::string(value) : std::string();
|
||||
}
|
||||
|
||||
video_format toObsVideoFormat(PixelFormat format)
|
||||
{
|
||||
switch (format) {
|
||||
case PixelFormat::I420: return VIDEO_FORMAT_I420;
|
||||
case PixelFormat::NV12: return VIDEO_FORMAT_NV12;
|
||||
case PixelFormat::BGRA: return VIDEO_FORMAT_BGRA;
|
||||
}
|
||||
return VIDEO_FORMAT_I420;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct CameraSource {
|
||||
obs_source_t *source = nullptr;
|
||||
|
||||
// --- configuration, guarded by `mutex` ---
|
||||
std::mutex mutex;
|
||||
ConnectionConfig config;
|
||||
std::string camera_identity;
|
||||
/// Bumped every time settings change; the worker compares it to what it
|
||||
/// last connected with, so a stale in-flight connect is abandoned rather
|
||||
/// than fought over.
|
||||
std::uint64_t generation = 0;
|
||||
std::vector<SlotInfo> slot_cache;
|
||||
std::string status_text = "not configured";
|
||||
|
||||
// --- worker ---
|
||||
std::thread worker;
|
||||
std::condition_variable wake;
|
||||
std::atomic<bool> stopping{false};
|
||||
|
||||
std::shared_ptr<ApiClient> api;
|
||||
std::unique_ptr<LiveKitSession> session;
|
||||
|
||||
std::atomic<std::uint64_t> frames_out{0};
|
||||
/// width<<16 | height of the last frame pushed, so a geometry change can
|
||||
/// be logged exactly once.
|
||||
std::atomic<std::uint32_t> last_geometry{0};
|
||||
/// Whether the current status line is a problem the operator must act on
|
||||
/// (a wrong read key), rather than ordinary progress.
|
||||
std::atomic<bool> status_is_error{false};
|
||||
|
||||
void setStatus(std::string text)
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(mutex);
|
||||
status_text = std::move(text);
|
||||
}
|
||||
|
||||
std::string statusText()
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(mutex);
|
||||
return status_text;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void outputVideoFrame(CameraSource *self, const VideoFrameData &frame)
|
||||
{
|
||||
obs_source_frame out = {};
|
||||
out.width = static_cast<std::uint32_t>(frame.width);
|
||||
out.height = static_cast<std::uint32_t>(frame.height);
|
||||
out.format = toObsVideoFormat(frame.format);
|
||||
|
||||
// Both video and audio are stamped with the SAME clock (os_gettime_ns at
|
||||
// arrival) rather than video using WebRTC's timestamp_us and audio using
|
||||
// arrival time. The SDK's VideoFrameCallback carries a capture-time
|
||||
// timestamp but its AudioFrameCallback carries none, and mixing two
|
||||
// epochs inside one OBS source is a guaranteed A/V drift. This relies on
|
||||
// the SDK's jitter buffering having already aligned the two -- the
|
||||
// assumption the design doc flags for verification on real hardware.
|
||||
out.timestamp = os_gettime_ns();
|
||||
|
||||
for (int i = 0; i < frame.plane_count && i < MAX_AV_PLANES; ++i) {
|
||||
out.data[i] = const_cast<std::uint8_t *>(frame.planes[i].data);
|
||||
out.linesize[i] = frame.planes[i].stride;
|
||||
}
|
||||
|
||||
// WebRTC delivers limited-range BT.709 for anything at or above SD.
|
||||
video_format_get_parameters_for_format(VIDEO_CS_709, VIDEO_RANGE_PARTIAL, out.format, out.color_matrix,
|
||||
out.color_range_min, out.color_range_max);
|
||||
out.full_range = false;
|
||||
|
||||
obs_source_output_video(self->source, &out);
|
||||
self->frames_out.fetch_add(1);
|
||||
|
||||
// Log the first frame, and any later change of geometry. A director's
|
||||
// log then answers "did video ever arrive, and at what size" without
|
||||
// anyone having to reproduce the problem -- and WebRTC really does
|
||||
// change resolution mid-stream as it ramps a subscription up.
|
||||
const std::uint32_t geometry = out.width << 16 | out.height;
|
||||
const std::uint32_t previous = self->last_geometry.exchange(geometry);
|
||||
if (previous != geometry)
|
||||
obs_log(LOG_INFO, "video frame %ux%u %s", out.width, out.height, describePixelFormat(frame.format));
|
||||
}
|
||||
|
||||
void outputAudioFrame(CameraSource *self, const AudioFrameData &frame)
|
||||
{
|
||||
if (!frame.samples || frame.samples_per_channel <= 0)
|
||||
return;
|
||||
|
||||
obs_source_audio out = {};
|
||||
out.data[0] = reinterpret_cast<const std::uint8_t *>(frame.samples);
|
||||
out.frames = static_cast<std::uint32_t>(frame.samples_per_channel);
|
||||
out.format = AUDIO_FORMAT_16BIT; // interleaved int16, which is what the SDK hands us
|
||||
out.samples_per_sec = static_cast<std::uint32_t>(frame.sample_rate);
|
||||
out.timestamp = os_gettime_ns();
|
||||
|
||||
switch (frame.channels) {
|
||||
case 1: out.speakers = SPEAKERS_MONO; break;
|
||||
case 2: out.speakers = SPEAKERS_STEREO; break;
|
||||
default:
|
||||
// Anything else would need a channel-map decision we have no reason
|
||||
// to guess at; a streamer-tools mic is mono or stereo.
|
||||
return;
|
||||
}
|
||||
|
||||
obs_source_output_audio(self->source, &out);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker: mint a token, connect, keep it connected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void workerLoop(CameraSource *self)
|
||||
{
|
||||
std::uint64_t connected_generation = 0;
|
||||
bool connected = false;
|
||||
int backoff_ms = kBackoffStartMs;
|
||||
|
||||
for (;;) {
|
||||
ConnectionConfig config;
|
||||
std::string camera;
|
||||
std::uint64_t generation = 0;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(self->mutex);
|
||||
if (self->stopping.load())
|
||||
break;
|
||||
config = self->config;
|
||||
camera = self->camera_identity;
|
||||
generation = self->generation;
|
||||
}
|
||||
|
||||
const bool config_changed = generation != connected_generation;
|
||||
const bool needs_connect =
|
||||
!connected || config_changed ||
|
||||
(self->session && (self->session->state() == SessionState::Failed ||
|
||||
self->session->state() == SessionState::Disconnected));
|
||||
|
||||
if (needs_connect) {
|
||||
if (connected || config_changed) {
|
||||
if (self->session)
|
||||
self->session->disconnect();
|
||||
obs_source_output_video(self->source, nullptr);
|
||||
connected = false;
|
||||
}
|
||||
|
||||
if (!config.is_valid() || camera.empty()) {
|
||||
self->setStatus("not configured -- set the server URL, room, read key and camera");
|
||||
connected_generation = generation;
|
||||
backoff_ms = kBackoffStartMs;
|
||||
} else {
|
||||
self->setStatus("connecting...");
|
||||
const TokenResult token = self->api->requestToken(config);
|
||||
if (!token.ok()) {
|
||||
// token.message is the specific one ("the read key is
|
||||
// wrong or has been rotated"); describeApiStatus is the
|
||||
// generic fallback. Printing both just reads as noise.
|
||||
const std::string message =
|
||||
token.message.empty() ? describeApiStatus(token.status) : token.message;
|
||||
self->setStatus(message);
|
||||
self->status_is_error.store(true);
|
||||
obs_log(LOG_WARNING, "token request failed: %s", message.c_str());
|
||||
} else {
|
||||
// Refresh the dropdown cache while we are here; the
|
||||
// properties UI then opens instantly instead of blocking
|
||||
// on the network.
|
||||
const SlotsResult slots = self->api->fetchSlots(config);
|
||||
if (slots.ok()) {
|
||||
std::lock_guard<std::mutex> guard(self->mutex);
|
||||
self->slot_cache = slots.slots;
|
||||
}
|
||||
|
||||
SessionConfig session_config;
|
||||
session_config.ws_url = token.ws_url;
|
||||
session_config.token = token.lk_token;
|
||||
session_config.participant_identity = camera;
|
||||
|
||||
if (self->session->connect(session_config)) {
|
||||
connected = true;
|
||||
connected_generation = generation;
|
||||
backoff_ms = kBackoffStartMs;
|
||||
self->status_is_error.store(false);
|
||||
obs_log(LOG_INFO, "connected to %s as %s, watching %s", token.ws_url.c_str(),
|
||||
token.identity.c_str(), camera.c_str());
|
||||
} else {
|
||||
self->setStatus(self->session->stateDetail());
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
backoff_ms = backoff_ms * 2 < kBackoffMaxMs ? backoff_ms * 2 : kBackoffMaxMs;
|
||||
connected_generation = generation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll rather than push: the state handler could notify us, but it
|
||||
// runs on a LiveKit thread and this keeps the wake-up path single.
|
||||
std::unique_lock<std::mutex> lock(self->mutex);
|
||||
self->wake.wait_for(lock, std::chrono::milliseconds(connected ? 1000 : backoff_ms),
|
||||
[self, generation] { return self->stopping.load() || self->generation != generation; });
|
||||
if (self->stopping.load())
|
||||
break;
|
||||
}
|
||||
|
||||
if (self->session)
|
||||
self->session->disconnect();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// obs_source_info callbacks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const char *sourceGetName(void *)
|
||||
{
|
||||
return obs_module_text("StreamerToolsCamera");
|
||||
}
|
||||
|
||||
void sourceGetDefaults(obs_data_t *settings)
|
||||
{
|
||||
obs_data_set_default_string(settings, kSettingServerUrl, "");
|
||||
obs_data_set_default_string(settings, kSettingRoomSlug, "");
|
||||
obs_data_set_default_string(settings, kSettingReadKey, "");
|
||||
obs_data_set_default_string(settings, kSettingCamera, "");
|
||||
}
|
||||
|
||||
void applySettings(CameraSource *self, obs_data_t *settings)
|
||||
{
|
||||
ConnectionConfig config;
|
||||
config.server_url = settingString(settings, kSettingServerUrl);
|
||||
config.room_slug = settingString(settings, kSettingRoomSlug);
|
||||
config.read_key = settingString(settings, kSettingReadKey);
|
||||
const std::string camera = settingString(settings, kSettingCamera);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(self->mutex);
|
||||
const bool changed = config.server_url != self->config.server_url ||
|
||||
config.room_slug != self->config.room_slug ||
|
||||
config.read_key != self->config.read_key || camera != self->camera_identity;
|
||||
if (!changed)
|
||||
return;
|
||||
self->config = config;
|
||||
self->camera_identity = camera;
|
||||
++self->generation;
|
||||
}
|
||||
self->wake.notify_all();
|
||||
}
|
||||
|
||||
void *sourceCreate(obs_data_t *settings, obs_source_t *source)
|
||||
{
|
||||
auto *self = new CameraSource();
|
||||
self->source = source;
|
||||
self->api = std::make_shared<ApiClient>(std::shared_ptr<HttpClient>(createPlatformHttpClient()));
|
||||
self->session = std::unique_ptr<LiveKitSession>(new LiveKitSession());
|
||||
|
||||
self->session->setVideoHandler([self](const VideoFrameData &frame) { outputVideoFrame(self, frame); });
|
||||
self->session->setAudioHandler([self](const AudioFrameData &frame) { outputAudioFrame(self, frame); });
|
||||
self->session->setStateHandler([self](SessionState state, const std::string &detail) {
|
||||
self->setStatus(detail.empty() ? describeSessionState(state) : detail);
|
||||
self->status_is_error.store(state == SessionState::Failed);
|
||||
|
||||
// A camera that stopped publishing must not leave its last frame on
|
||||
// screen -- that is precisely the stale-media failure this plugin
|
||||
// exists to avoid. A null frame clears the source.
|
||||
if (state != SessionState::Connected)
|
||||
obs_source_output_video(self->source, nullptr);
|
||||
});
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(self->mutex);
|
||||
self->config.server_url = settingString(settings, kSettingServerUrl);
|
||||
self->config.room_slug = settingString(settings, kSettingRoomSlug);
|
||||
self->config.read_key = settingString(settings, kSettingReadKey);
|
||||
self->camera_identity = settingString(settings, kSettingCamera);
|
||||
self->generation = 1;
|
||||
}
|
||||
|
||||
self->worker = std::thread([self] { workerLoop(self); });
|
||||
return self;
|
||||
}
|
||||
|
||||
void sourceUpdate(void *data, obs_data_t *settings)
|
||||
{
|
||||
applySettings(static_cast<CameraSource *>(data), settings);
|
||||
}
|
||||
|
||||
void sourceDestroy(void *data)
|
||||
{
|
||||
auto *self = static_cast<CameraSource *>(data);
|
||||
if (!self)
|
||||
return;
|
||||
|
||||
self->stopping.store(true);
|
||||
self->wake.notify_all();
|
||||
if (self->worker.joinable())
|
||||
self->worker.join();
|
||||
|
||||
// The worker already disconnected, but do it again explicitly: the
|
||||
// session's own destructor would too, and all three are idempotent.
|
||||
if (self->session)
|
||||
self->session->disconnect();
|
||||
self->session.reset();
|
||||
|
||||
delete self;
|
||||
}
|
||||
|
||||
/// Rebuild the camera dropdown from the cached slot list, always including
|
||||
/// whatever identity is currently selected so OBS cannot silently clear a
|
||||
/// setting just because the room is dark right now.
|
||||
void populateCameraList(CameraSource *self, obs_property_t *list, const std::string &selected)
|
||||
{
|
||||
obs_property_list_clear(list);
|
||||
obs_property_list_add_string(list, obs_module_text("NoCameraSelected"), "");
|
||||
|
||||
bool saw_selected = selected.empty();
|
||||
std::vector<SlotInfo> slots;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(self->mutex);
|
||||
slots = self->slot_cache;
|
||||
}
|
||||
for (const SlotInfo &slot : slots) {
|
||||
std::string label = slot.display_name;
|
||||
if (!slot.live)
|
||||
label += obs_module_text("OfflineSuffix");
|
||||
obs_property_list_add_string(list, label.c_str(), slot.identity.c_str());
|
||||
if (slot.identity == selected)
|
||||
saw_selected = true;
|
||||
}
|
||||
if (!saw_selected) {
|
||||
std::string label = selected + obs_module_text("NotInRoomSuffix");
|
||||
obs_property_list_add_string(list, label.c_str(), selected.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool refreshButtonClicked(obs_properties_t *props, obs_property_t *, void *data)
|
||||
{
|
||||
auto *self = static_cast<CameraSource *>(data);
|
||||
if (!self)
|
||||
return false;
|
||||
|
||||
ConnectionConfig config;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(self->mutex);
|
||||
config = self->config;
|
||||
}
|
||||
|
||||
// Deliberately synchronous: the operator pressed a button and is waiting
|
||||
// for the list to change. The timeout is shortened from the core default
|
||||
// so a dead server cannot freeze the properties dialog for ten seconds.
|
||||
const SlotsResult result = self->api->fetchSlots(config, kPropertiesTimeoutMs);
|
||||
|
||||
if (result.ok()) {
|
||||
std::string selected;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(self->mutex);
|
||||
self->slot_cache = result.slots;
|
||||
selected = self->camera_identity;
|
||||
}
|
||||
if (obs_property_t *list = obs_properties_get(props, kSettingCamera))
|
||||
populateCameraList(self, list, selected);
|
||||
self->setStatus(std::to_string(result.slots.size()) + std::string(obs_module_text("CamerasFound")));
|
||||
self->status_is_error.store(false);
|
||||
} else {
|
||||
const std::string message = result.message.empty() ? describeApiStatus(result.status) : result.message;
|
||||
self->setStatus(message);
|
||||
self->status_is_error.store(true);
|
||||
obs_log(LOG_WARNING, "slot listing failed: %s", message.c_str());
|
||||
}
|
||||
|
||||
if (obs_property_t *status = obs_properties_get(props, kSettingStatus)) {
|
||||
const std::string text = self->statusText();
|
||||
obs_property_set_description(status, text.c_str());
|
||||
obs_property_text_set_info_type(status, self->status_is_error.load() ? OBS_TEXT_INFO_WARNING
|
||||
: OBS_TEXT_INFO_NORMAL);
|
||||
}
|
||||
|
||||
return true; // properties changed, redraw them
|
||||
}
|
||||
|
||||
obs_properties_t *sourceGetProperties(void *data)
|
||||
{
|
||||
auto *self = static_cast<CameraSource *>(data);
|
||||
obs_properties_t *props = obs_properties_create();
|
||||
|
||||
obs_properties_add_text(props, kSettingServerUrl, obs_module_text("ServerUrl"), OBS_TEXT_DEFAULT);
|
||||
obs_properties_add_text(props, kSettingRoomSlug, obs_module_text("RoomSlug"), OBS_TEXT_DEFAULT);
|
||||
// The read key is a credential and is masked everywhere else in
|
||||
// streamer-tools; it is masked here too.
|
||||
obs_properties_add_text(props, kSettingReadKey, obs_module_text("ReadKey"), OBS_TEXT_PASSWORD);
|
||||
|
||||
obs_property_t *list = obs_properties_add_list(props, kSettingCamera, obs_module_text("Camera"),
|
||||
OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING);
|
||||
if (self) {
|
||||
std::string selected;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(self->mutex);
|
||||
selected = self->camera_identity;
|
||||
}
|
||||
// Built from the cache the worker keeps warm, so opening properties
|
||||
// never blocks on the network. The button below is the way to force
|
||||
// a round trip.
|
||||
populateCameraList(self, list, selected);
|
||||
}
|
||||
|
||||
obs_properties_add_button(props, kPropRefresh, obs_module_text("RefreshCameras"), refreshButtonClicked);
|
||||
|
||||
// An OBS_TEXT_INFO property renders its *description* as the visible
|
||||
// label, so the status line goes there rather than into a tooltip an
|
||||
// operator would never hover over mid-show.
|
||||
const std::string status_text = self ? self->statusText() : std::string(obs_module_text("Status"));
|
||||
obs_property_t *status = obs_properties_add_text(props, kSettingStatus, status_text.c_str(), OBS_TEXT_INFO);
|
||||
if (self && self->status_is_error.load())
|
||||
obs_property_text_set_info_type(status, OBS_TEXT_INFO_WARNING);
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
struct obs_source_info cameraSourceInfo()
|
||||
{
|
||||
struct obs_source_info info = {};
|
||||
info.id = "streamer_tools_camera_source";
|
||||
info.type = OBS_SOURCE_TYPE_INPUT;
|
||||
info.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE;
|
||||
info.icon_type = OBS_ICON_TYPE_CAMERA;
|
||||
info.get_name = sourceGetName;
|
||||
info.create = sourceCreate;
|
||||
info.destroy = sourceDestroy;
|
||||
info.update = sourceUpdate;
|
||||
info.get_defaults = sourceGetDefaults;
|
||||
info.get_properties = sourceGetProperties;
|
||||
return info;
|
||||
}
|
||||
|
||||
struct obs_source_info streamer_tools_camera_source = cameraSourceInfo();
|
||||
|
||||
void livekitLogToObs(livekit::LogLevel level, const std::string &, const std::string &message)
|
||||
{
|
||||
int obs_level = LOG_INFO;
|
||||
switch (level) {
|
||||
case livekit::LogLevel::Error:
|
||||
case livekit::LogLevel::Critical: obs_level = LOG_ERROR; break;
|
||||
case livekit::LogLevel::Warn: obs_level = LOG_WARNING; break;
|
||||
case livekit::LogLevel::Info: obs_level = LOG_INFO; break;
|
||||
default: obs_level = LOG_DEBUG; break;
|
||||
}
|
||||
obs_log(obs_level, "livekit: %s", message.c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool obs_module_load(void)
|
||||
{
|
||||
LiveKitSession::globalInitialize();
|
||||
// Route the SDK's own logging into OBS's log file instead of stderr,
|
||||
// where a director would never see it.
|
||||
livekit::setLogCallback(livekitLogToObs);
|
||||
|
||||
obs_register_source(&streamer_tools_camera_source);
|
||||
obs_log(LOG_INFO, "streamer-tools camera plugin loaded (core %s)", core_version());
|
||||
return true;
|
||||
}
|
||||
|
||||
void obs_module_unload(void)
|
||||
{
|
||||
livekit::setLogCallback(nullptr);
|
||||
LiveKitSession::globalShutdown();
|
||||
obs_log(LOG_INFO, "streamer-tools camera plugin unloaded");
|
||||
}
|
||||
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