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
|
# obs-streamer-tools-plugin
|
||||||
|
|
||||||
Native OBS Studio source plugin that will pull streamer-tools camera feeds
|
Native OBS Studio source plugin that pulls streamer-tools camera feeds
|
||||||
directly from LiveKit over WebRTC (via LiveKit's `livekit-ffi`), replacing
|
directly from LiveKit over WebRTC, replacing the current SRT/RTSP-via-VLC-or-
|
||||||
the current SRT/RTSP-via-VLC-or-Media-Source path for directors. Full
|
Media-Source path for directors. Full design:
|
||||||
design: `docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md` in
|
`docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md` in the
|
||||||
the `streamer-tools` repo (as of this writing, that doc lives on the
|
`streamer-tools` repo.
|
||||||
`worktree-obs-plugin-server-api` branch there, not yet merged to `main`).
|
|
||||||
|
|
||||||
## Status: scaffolding only
|
## Status
|
||||||
|
|
||||||
**This repository does not talk to LiveKit or streamer-tools yet.** This
|
The plugin is **functionally complete on Linux and verified end to end there**
|
||||||
first pass exists to prove the CMake toolchain, the core-library/OBS-adapter
|
(module loads into real libobs, connects to a real LiveKit server through the
|
||||||
split, and the three-platform Gitea Actions CI pipeline all actually work,
|
real streamer-tools API shape, and pushes decoded frames into
|
||||||
so the next phase (real `livekit-ffi` integration) can be planned against
|
`obs_source_output_video`/`_audio`). It has **not** been run in the OBS GUI on
|
||||||
verified facts instead of assumptions. See the design doc's "Components"
|
any platform, and macOS/Windows have only ever been built by CI, never loaded.
|
||||||
and "CI / build pipeline" sections for the target architecture this scaffold
|
See "What is verified, and how" below for exactly what that means, and
|
||||||
is standing up.
|
"Testing this by hand" for what a human still needs to do.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
cmake/LiveKitSDK.cmake - downloads + unpacks the pinned client-sdk-cpp release
|
||||||
core/ - core library (C++17, no OBS dependency, headless-testable)
|
core/ - core library (C++17, no OBS dependency, headless-testable)
|
||||||
include/stplugin/
|
include/stplugin/
|
||||||
core.h C++ API (ConnectionConfig, core_version())
|
core.h version + ConnectionConfig
|
||||||
core_c.h C ABI wrapper the OBS adapter calls into
|
json.h small strict JSON reader
|
||||||
src/core.cpp
|
http.h injectable HTTP client interface
|
||||||
tests/ dependency-free CTest unit tests
|
api_client.h the two /api/obs/:slug/* calls
|
||||||
|
session_types.h media/state types + the pure session logic
|
||||||
obs-adapter/ - thin OBS glue (C, adapted from obsproject/obs-plugintemplate)
|
session.h LiveKitSession, the livekit::Room wrapper
|
||||||
src/plugin-main.c obs_module_load/unload + a stub source registration
|
src/
|
||||||
src/plugin-support.{h,c.in}
|
http_curl.cpp libcurl backend (Linux/macOS)
|
||||||
|
http_winhttp.cpp WinHTTP backend (Windows)
|
||||||
|
tests/ dependency-free CTest suites
|
||||||
|
obs-adapter/ - thin OBS glue (C++)
|
||||||
|
src/plugin-main.cpp obs_source_info, properties UI, frame output
|
||||||
data/locale/en-US.ini
|
data/locale/en-US.ini
|
||||||
|
scripts/livekit-dev-room.py - mints tokens for the integration test
|
||||||
.gitea/workflows/build.yml - 3-platform CI matrix (see below)
|
third_party/livekit/ - redistribution notices for the LiveKit binaries
|
||||||
|
.gitea/workflows/build.yml - 3-platform CI matrix
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything in `core/` is real, working, unit-tested code -- it just doesn't
|
## How it works
|
||||||
do anything useful yet (a version string, a config struct with non-empty
|
|
||||||
validation). Everything in `obs-adapter/` is real OBS module code -- it
|
|
||||||
registers an actual `obs_source_info` and builds as a real, dynamically
|
|
||||||
loadable OBS module (see "Verified" below) -- but the source is a stub:
|
|
||||||
`create`/`destroy` allocate/free a dummy blob, there is no properties UI,
|
|
||||||
and no frames are ever pushed. That's the boundary this task was scoped to.
|
|
||||||
|
|
||||||
## What's real vs. deliberately deferred
|
1. The operator fills in the streamer-tools server URL, room slug and read key,
|
||||||
|
and picks a camera from the dropdown.
|
||||||
|
2. The source's own worker thread calls `POST /api/obs/:slug/token?key=…` to
|
||||||
|
mint a hidden, subscribe-only LiveKit token
|
||||||
|
(identity `obs:<slug>:<nonce>` — a fresh nonce per mint, so two OBS
|
||||||
|
installs watching the same room can never kick each other).
|
||||||
|
3. `LiveKitSession` connects `livekit::Room` to the returned `wsUrl`, waits for
|
||||||
|
the chosen participant's `Source.Camera` video track (and their microphone),
|
||||||
|
and reads decoded frames off `VideoStream`/`AudioStream`.
|
||||||
|
4. The adapter hands those straight to `obs_source_output_video` /
|
||||||
|
`obs_source_output_audio`.
|
||||||
|
|
||||||
Deferred, per the task that produced this scaffold (out of scope for this
|
Nothing on the OBS UI thread ever blocks on the network. The one deliberate
|
||||||
pass, in scope for the next one):
|
exception is the "Refresh camera list" button, which the operator pressed and
|
||||||
|
is waiting on; it uses a shortened 5s timeout.
|
||||||
|
|
||||||
- No `livekit-ffi` linkage of any kind.
|
### Design decisions worth knowing before changing this
|
||||||
- No streamer-tools API client (auth, slot-listing, token minting).
|
|
||||||
- No properties UI (server URL / room slug / read key / camera dropdown).
|
|
||||||
- No frame output (`obs_source_output_video`/`_audio`).
|
|
||||||
- No packaging/release step (the design doc's "on a version tag" job).
|
|
||||||
|
|
||||||
## Toolchain notes (verified on this machine: Ubuntu 24.04 / Linux)
|
- **Frames come from `VideoStream::fromTrack` with our own reader threads, not
|
||||||
|
from `Room::setOnVideoFrameCallback`.** The dispatcher API is keyed by
|
||||||
|
(participant identity, track *name*), which is only knowable once the track
|
||||||
|
is published — and disassembly of `liblivekit.so` 1.10.1 confirms that
|
||||||
|
neither `Room::setOnVideoFrameCallback` nor the dispatcher's own version
|
||||||
|
starts a reader for an already-subscribed track; they only record the
|
||||||
|
registration. Registering at the only moment the name exists would therefore
|
||||||
|
have silently produced no video.
|
||||||
|
- **Every stream operation runs on one owned worker thread**, never on a
|
||||||
|
LiveKit room event thread: `Room::disconnect()` from inside a delegate
|
||||||
|
callback is documented to deadlock.
|
||||||
|
- **`VideoStream::Options::capacity` is 3**, making the SDK queue a
|
||||||
|
drop-oldest ring buffer. A stalled consumer can only fall three frames
|
||||||
|
behind and then sees the *newest* frame, not a backlog — the structural
|
||||||
|
answer to the stale-media bug that motivated this plugin.
|
||||||
|
- **Video and audio are both timestamped with `os_gettime_ns()` at arrival.**
|
||||||
|
The SDK gives video a WebRTC capture timestamp and audio none; mixing two
|
||||||
|
epochs inside one OBS source would guarantee A/V drift. This relies on the
|
||||||
|
SDK's jitter buffering having already aligned them — the assumption the
|
||||||
|
design doc flags for verification on real hardware. **Still unverified.**
|
||||||
|
- **WebRTC changes resolution mid-stream.** Observed directly in the
|
||||||
|
integration test: the first frames after (re)subscribing arrive at a
|
||||||
|
downscaled spatial layer before ramping to the published size. The adapter
|
||||||
|
passes each frame's own geometry through, and logs geometry changes.
|
||||||
|
|
||||||
- **CMake 3.28.3**, **Ninja 1.11.1**, GCC 13.3.0 -- all installed via
|
## Building
|
||||||
`apt-get install cmake ninja-build`. Top-level `CMakeLists.txt` requires
|
|
||||||
CMake >= 3.16 (deliberately lower than the official
|
|
||||||
obsproject/obs-plugintemplate's `3.28...3.30` floor -- see below).
|
|
||||||
- **OBS plugin template used as reference**: obsproject/obs-plugintemplate,
|
|
||||||
commit `3e7d7ac3b5342cd7d9b88890b9c70b472d1520fc` (2025-12-09, "Fix typo
|
|
||||||
of Visual Studio in README"), fetched fresh from GitHub. `src/plugin-main.c`,
|
|
||||||
`src/plugin-support.{h,c.in}`, and the empty `data/locale/en-US.ini` in
|
|
||||||
`obs-adapter/` are adapted directly from it.
|
|
||||||
- **Deliberate deviation from the template's own build system**: the
|
|
||||||
official template's `CMakeLists.txt` chains into
|
|
||||||
`cmake/common/bootstrap.cmake`, which in turn reads `buildspec.json` and
|
|
||||||
*downloads full OBS source archives (pinned to OBS 31.1.1) plus prebuilt
|
|
||||||
dependency bundles* for macOS and Windows. That machinery is real,
|
|
||||||
actively maintained, and probably the right long-term answer for
|
|
||||||
cross-platform reproducible builds -- but it's heavy (multi-hundred-MB
|
|
||||||
downloads, a whole `cmake/{macos,windows,common}` support tree, Qt6,
|
|
||||||
code-signing hooks) and out of scope to stand up and debug in one pass.
|
|
||||||
This scaffold instead uses a much simpler hand-written top-level
|
|
||||||
`CMakeLists.txt` that calls `find_package(libobs)` directly.
|
|
||||||
- **On Linux, this actually works far better than expected**: Ubuntu ships
|
|
||||||
a real `libobs-dev` package (`30.0.2+dfsg-3build1` on 24.04, i.e. **not**
|
|
||||||
the 31.1.1 the template's buildspec.json pins -- worth reconciling before
|
|
||||||
the next phase if API surface matters) with genuine CMake package config
|
|
||||||
files (`/usr/lib/x86_64-linux-gnu/cmake/libobs/libobsConfig.cmake`,
|
|
||||||
`libobsTargets.cmake`) that export an `OBS::libobs` imported target --
|
|
||||||
the exact target name the official template expects. `find_package(libobs
|
|
||||||
QUIET)` finds it with zero extra plumbing. This means the OBS adapter in
|
|
||||||
this repo links against **real OBS headers and a real `libobs.so`**, not
|
|
||||||
a stub -- confirmed by `ldd` showing `libobs.so.0` and `nm -D` showing
|
|
||||||
real `obs_module_*` exports (see "Verified" below). Install via
|
|
||||||
`apt-get install libobs-dev` (pulls in Qt6 as a dependency chain, ~seconds
|
|
||||||
on a fast mirror).
|
|
||||||
- **macOS/Windows have no equivalent system package** (there's no Homebrew
|
|
||||||
formula or winget package that ships `libobsConfig.cmake` the way Ubuntu's
|
|
||||||
`libobs-dev` does). For those platforms the choices are: (a) adopt the
|
|
||||||
template's full buildspec-driven source/prebuilt-deps download, or (b)
|
|
||||||
find/produce a lighter prebuilt SDK bundle. **This is now a concrete,
|
|
||||||
scoped decision for the next phase**, not a guess -- the CI workflow in
|
|
||||||
this repo currently takes option (c) for this pass only: skip building
|
|
||||||
the OBS adapter on macOS/Windows and build+test just the core library,
|
|
||||||
via the same `find_package(libobs QUIET)` fallback the top-level
|
|
||||||
`CMakeLists.txt` already has for exactly this situation.
|
|
||||||
- **`ENABLE_QT`/`ENABLE_FRONTEND_API` template options were not carried
|
|
||||||
over** -- this scaffold's properties-UI-free stub doesn't need Qt yet;
|
|
||||||
the real adapter will need to revisit this once the properties UI
|
|
||||||
(server URL / room slug / read key / camera dropdown) is built.
|
|
||||||
|
|
||||||
## What actually builds, and how it was verified
|
Linux (the platform that is fully verified):
|
||||||
|
|
||||||
```
|
```
|
||||||
$ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
sudo apt-get install -y cmake ninja-build libobs-dev libcurl4-openssl-dev
|
||||||
-- libobs found (/usr/lib/x86_64-linux-gnu/cmake/libobs) -- building OBS adapter module
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||||
-- Configuring done
|
cmake --build build
|
||||||
-- Generating done
|
ctest --test-dir build --output-on-failure
|
||||||
|
|
||||||
$ cmake --build build
|
|
||||||
[1/7] Building C object obs-adapter/CMakeFiles/streamer-tools-camera.dir/plugin-support.c.o
|
|
||||||
[2/7] Building C object obs-adapter/CMakeFiles/streamer-tools-camera.dir/src/plugin-main.c.o
|
|
||||||
[3/7] Building CXX object core/CMakeFiles/stplugin_core.dir/src/core.cpp.o
|
|
||||||
[4/7] Linking CXX static library core/libstplugin_core.a
|
|
||||||
[5/7] Building CXX object core/tests/CMakeFiles/stplugin_core_tests.dir/test_core.cpp.o
|
|
||||||
[6/7] Linking CXX shared module obs-adapter/streamer-tools-camera.so
|
|
||||||
[7/7] Linking CXX executable core/tests/stplugin_core_tests
|
|
||||||
|
|
||||||
$ ctest --test-dir build --output-on-failure
|
|
||||||
1/1 Test #1: stplugin_core_tests .............. Passed 0.00 sec
|
|
||||||
100% tests passed, 0 tests failed out of 1
|
|
||||||
|
|
||||||
$ ldd build/obs-adapter/streamer-tools-camera.so | grep obs
|
|
||||||
libobs.so.0 => /lib/x86_64-linux-gnu/libobs.so.0 (...)
|
|
||||||
|
|
||||||
$ nm -D build/obs-adapter/streamer-tools-camera.so | grep obs_module
|
|
||||||
0000000000001430 T obs_module_free_locale
|
|
||||||
0000000000001450 T obs_module_load
|
|
||||||
...
|
|
||||||
0000000000001490 T obs_module_unload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This is a genuine, dynamically-linked OBS module -- not the "standalone
|
The configure step downloads the pinned `client-sdk-cpp` release (~13 MB) into
|
||||||
shared library without linking OBS" fallback the scaffolding task's scope
|
`build/_deps/livekit-sdk`. Point `-DSTPLUGIN_LIVEKIT_SDK_DIR=<path>` at a
|
||||||
explicitly allowed as an acceptable compromise. That fallback path is still
|
persistent directory to cache it across builds; `-DSTPLUGIN_LIVEKIT_SDK_VERSION`
|
||||||
exercised (and needed) on macOS/Windows CI for now; see above.
|
and `-DSTPLUGIN_LIVEKIT_SDK_TRIPLE` override the pin and the release triple.
|
||||||
|
|
||||||
|
The build stages a runnable layout into `build/package/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
build/package/bin/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
|
## CI
|
||||||
|
|
||||||
`.gitea/workflows/build.yml` runs on every push/PR, matrixed across the
|
`.gitea/workflows/build.yml` runs on every push, matrixed across the three
|
||||||
three runners confirmed available to this repo by living under the
|
runners available to this repo under the `CyberCoveLLC` org.
|
||||||
`CyberCoveLLC` org (see the design doc's "CI / build pipeline" section):
|
|
||||||
|
|
||||||
| Job | `runs-on` | Runner |
|
| Job | `runs-on` | Runner |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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) |
|
| `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
|
Linux uses Ubuntu's `libobs-dev` and builds the real OBS adapter. macOS and
|
||||||
plus the core library, then runs `ctest`. The macOS/Windows jobs build and
|
Windows use the `obsproject/obs-plugintemplate` buildspec bootstrap, trimmed
|
||||||
test only the core library for now (see toolchain notes above for why).
|
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
|
||||||
No packaging/release step yet -- out of scope for this pass.
|
rejects a module built against a newer libobs than the one running it.
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ endif()
|
|||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
target_link_libraries(stplugin_core PUBLIC Threads::Threads)
|
target_link_libraries(stplugin_core PUBLIC Threads::Threads)
|
||||||
|
|
||||||
|
target_compile_definitions(stplugin_core PRIVATE
|
||||||
|
STPLUGIN_CORE_VERSION="${PROJECT_VERSION}"
|
||||||
|
STPLUGIN_LIVEKIT_SDK_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}"
|
||||||
|
)
|
||||||
|
|
||||||
set_target_properties(stplugin_core PROPERTIES
|
set_target_properties(stplugin_core PROPERTIES
|
||||||
POSITION_INDEPENDENT_CODE ON
|
POSITION_INDEPENDENT_CODE ON
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -98,8 +98,12 @@ public:
|
|||||||
/// Takes ownership of the HTTP client, so tests can inject a fake.
|
/// Takes ownership of the HTTP client, so tests can inject a fake.
|
||||||
explicit ApiClient(std::shared_ptr<HttpClient> http);
|
explicit ApiClient(std::shared_ptr<HttpClient> http);
|
||||||
|
|
||||||
SlotsResult fetchSlots(const ConnectionConfig &config) const;
|
/// @param timeout_ms whole-request timeout. Kept as a parameter because
|
||||||
TokenResult requestToken(const ConnectionConfig &config) const;
|
/// 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
|
/// Accepts what an operator would actually paste: a bare hostname, a URL
|
||||||
/// with a trailing slash, extra whitespace. Returns an empty string if
|
/// 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);
|
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;
|
SlotsResult result;
|
||||||
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
|
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
|
||||||
@@ -132,6 +132,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
|
|||||||
HttpRequest request;
|
HttpRequest request;
|
||||||
request.method = "GET";
|
request.method = "GET";
|
||||||
request.url = buildUrl(config, "/slots");
|
request.url = buildUrl(config, "/slots");
|
||||||
|
request.timeout_ms = timeout_ms;
|
||||||
|
|
||||||
const HttpResponse response = http_->send(request);
|
const HttpResponse response = http_->send(request);
|
||||||
const ApiStatus status = classify(response, result.message);
|
const ApiStatus status = classify(response, result.message);
|
||||||
@@ -170,7 +171,7 @@ SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
TokenResult ApiClient::requestToken(const ConnectionConfig &config) const
|
TokenResult ApiClient::requestToken(const ConnectionConfig &config, int timeout_ms) const
|
||||||
{
|
{
|
||||||
TokenResult result;
|
TokenResult result;
|
||||||
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
|
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.url = buildUrl(config, "/token");
|
||||||
request.content_type = "application/json";
|
request.content_type = "application/json";
|
||||||
request.body = "{}";
|
request.body = "{}";
|
||||||
|
request.timeout_ms = timeout_ms;
|
||||||
|
|
||||||
const HttpResponse response = http_->send(request);
|
const HttpResponse response = http_->send(request);
|
||||||
const ApiStatus status = classify(response, result.message);
|
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 {
|
namespace stplugin {
|
||||||
|
|
||||||
const char *core_version() {
|
const char *core_version() {
|
||||||
return "0.0.1-scaffold";
|
// Injected by CMake from the top-level project() version, so the string
|
||||||
|
// OBS logs on load is the actual build, not a hand-maintained literal.
|
||||||
|
return STPLUGIN_CORE_VERSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConnectionConfig::is_valid() const {
|
bool ConnectionConfig::is_valid() const {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# streamer-tools OBS Camera Plugin - OBS adapter
|
# streamer-tools OBS Camera Plugin - OBS adapter
|
||||||
#
|
#
|
||||||
# Thin glue only, per the design doc: source registration, (eventually)
|
# Thin glue only, per the design doc: source registration, the properties UI,
|
||||||
# properties UI, and pushing frames into OBS. All real logic lives in
|
# and pushing frames into OBS. All real logic lives in ../core. Only added to
|
||||||
# ../core. Only added to the build when find_package(libobs) succeeds
|
# the build when find_package(libobs) succeeds (see top-level CMakeLists.txt).
|
||||||
# (see top-level CMakeLists.txt) -- see README.md for why.
|
|
||||||
|
|
||||||
set(STPLUGIN_PROJECT_NAME "streamer-tools-camera")
|
set(STPLUGIN_PROJECT_NAME "streamer-tools-camera")
|
||||||
set(STPLUGIN_PROJECT_VERSION "${PROJECT_VERSION}")
|
set(STPLUGIN_PROJECT_VERSION "${PROJECT_VERSION}")
|
||||||
@@ -15,7 +14,7 @@ configure_file(
|
|||||||
)
|
)
|
||||||
|
|
||||||
add_library(${STPLUGIN_PROJECT_NAME} MODULE
|
add_library(${STPLUGIN_PROJECT_NAME} MODULE
|
||||||
src/plugin-main.c
|
src/plugin-main.cpp
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/plugin-support.c
|
${CMAKE_CURRENT_BINARY_DIR}/plugin-support.c
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,3 +34,53 @@ set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
|
|||||||
PREFIX ""
|
PREFIX ""
|
||||||
OUTPUT_NAME ${STPLUGIN_PROJECT_NAME}
|
OUTPUT_NAME ${STPLUGIN_PROJECT_NAME}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The module has to find liblivekit / liblivekit_ffi next to itself once it is
|
||||||
|
# installed into an OBS plugin directory, not at the build-tree path CMake's
|
||||||
|
# default RPATH would bake in.
|
||||||
|
# BUILD_WITH_INSTALL_RPATH is ON deliberately: the artifact that ships is a
|
||||||
|
# straight copy of the built module (see the staging step below), so the
|
||||||
|
# build-tree RPATH must never be baked in -- it would work on the build
|
||||||
|
# machine and nowhere else.
|
||||||
|
if(APPLE)
|
||||||
|
set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
|
||||||
|
BUILD_WITH_INSTALL_RPATH ON
|
||||||
|
INSTALL_RPATH "@loader_path"
|
||||||
|
)
|
||||||
|
elseif(UNIX)
|
||||||
|
set_target_properties(${STPLUGIN_PROJECT_NAME} PROPERTIES
|
||||||
|
BUILD_WITH_INSTALL_RPATH ON
|
||||||
|
INSTALL_RPATH "$ORIGIN"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# --- staged, runnable layout ------------------------------------------------
|
||||||
|
# Everything a human needs to copy into an OBS plugin directory ends up under
|
||||||
|
# build/package/, with the LiveKit shared libraries and the licence files
|
||||||
|
# beside the module. Without this the module loads on the build machine only,
|
||||||
|
# via the build-tree RPATH.
|
||||||
|
set(STPLUGIN_PACKAGE_DIR "${CMAKE_BINARY_DIR}/package")
|
||||||
|
|
||||||
|
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
|
StreamerToolsCamera="streamer-tools Camera"
|
||||||
# No user-facing strings yet -- this is scaffolding (see plugin-main.c).
|
ServerUrl="streamer-tools server URL"
|
||||||
|
RoomSlug="Room"
|
||||||
|
ReadKey="Read key"
|
||||||
|
Camera="Camera"
|
||||||
|
RefreshCameras="Refresh camera list"
|
||||||
|
Status="Status"
|
||||||
|
NoCameraSelected="(no camera selected)"
|
||||||
|
OfflineSuffix=" (offline)"
|
||||||
|
NotInRoomSuffix=" (not in this room)"
|
||||||
|
CamerasFound=" cameras found"
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
/*
|
|
||||||
streamer-tools OBS Camera Plugin
|
|
||||||
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
|
||||||
|
|
||||||
This program is free software; you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation; either version 2 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along
|
|
||||||
with this program. If not, see <https://www.gnu.org/licenses/>
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* SCAFFOLDING. This registers a source type so the OBS-adapter/core-
|
|
||||||
* library split and OBS's module-loading toolchain can be proven end to
|
|
||||||
* end, but it does not do anything real yet: no LiveKit FFI session, no
|
|
||||||
* frames pushed via obs_source_output_video/audio, no properties UI.
|
|
||||||
* See docs/superpowers/specs/2026-09-06-obs-camera-plugin-design.md in
|
|
||||||
* the streamer-tools repo for what this becomes. */
|
|
||||||
|
|
||||||
#include <obs-module.h>
|
|
||||||
#include <util/bmem.h>
|
|
||||||
#include <plugin-support.h>
|
|
||||||
#include <stplugin/core_c.h>
|
|
||||||
|
|
||||||
OBS_DECLARE_MODULE()
|
|
||||||
OBS_MODULE_USE_DEFAULT_LOCALE(PLUGIN_NAME, "en-US")
|
|
||||||
|
|
||||||
static const char *stcam_source_get_name(void *unused)
|
|
||||||
{
|
|
||||||
UNUSED_PARAMETER(unused);
|
|
||||||
return "streamer-tools Camera (scaffold - not yet functional)";
|
|
||||||
}
|
|
||||||
|
|
||||||
static void *stcam_source_create(obs_data_t *settings, obs_source_t *source)
|
|
||||||
{
|
|
||||||
UNUSED_PARAMETER(settings);
|
|
||||||
UNUSED_PARAMETER(source);
|
|
||||||
/* No LiveKit session, no state to speak of yet -- just proving the
|
|
||||||
* source registers and OBS can instantiate/destroy it cleanly. */
|
|
||||||
return bzalloc(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void stcam_source_destroy(void *data)
|
|
||||||
{
|
|
||||||
bfree(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
static struct obs_source_info streamer_tools_camera_source = {
|
|
||||||
.id = "streamer_tools_camera_source",
|
|
||||||
.type = OBS_SOURCE_TYPE_INPUT,
|
|
||||||
.output_flags = OBS_SOURCE_ASYNC_VIDEO,
|
|
||||||
.get_name = stcam_source_get_name,
|
|
||||||
.create = stcam_source_create,
|
|
||||||
.destroy = stcam_source_destroy,
|
|
||||||
};
|
|
||||||
|
|
||||||
bool obs_module_load(void)
|
|
||||||
{
|
|
||||||
obs_log(LOG_INFO, "streamer-tools camera plugin scaffold loaded (core library version %s)",
|
|
||||||
stplugin_core_version());
|
|
||||||
obs_register_source(&streamer_tools_camera_source);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void obs_module_unload(void)
|
|
||||||
{
|
|
||||||
obs_log(LOG_INFO, "streamer-tools camera plugin scaffold unloaded");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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