Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d50ab7be | ||
|
|
352a843d93 | ||
|
|
564461a16d | ||
|
|
969a500dfd | ||
|
|
3f2933ae3f | ||
|
|
48a74e8c67 | ||
|
|
ab455a9cfe | ||
|
|
e763d61ed3 | ||
|
|
17540c75b0 | ||
|
|
b23644fa3e | ||
|
|
8888e57d08 | ||
|
|
5a39881af8 | ||
|
|
d9f73926e4 | ||
|
|
ee44fb6a73 | ||
|
|
abd4dc9aca | ||
|
|
1571d7ecea | ||
|
|
76b2942db1 | ||
|
|
af7d2c6d24 | ||
|
|
104326d05a | ||
|
|
e050b81d6c | ||
|
|
18610bd9dc | ||
|
|
43a501c06c | ||
|
|
14fa00af3f | ||
|
|
d287a90048 |
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Creates a (draft) Gitea Release for the tag that triggered
|
||||
# Creates a published Gitea Release for the tag that triggered
|
||||
# .gitea/workflows/release.yml, and uploads every archive in $DIST_DIR as a
|
||||
# release asset.
|
||||
#
|
||||
# RELEASE GATE: see the README's `## Status` section and
|
||||
# third_party/livekit/README.md. The WebRTC/OpenH264 attribution question
|
||||
# ("C1") is unresolved -- this script does not decide that question, it just
|
||||
# makes sure the generated release notes put the reminder where whoever
|
||||
# publishes the draft will actually read it. Pushing a version tag is the
|
||||
# human decision this whole workflow hangs off of; this script does not add
|
||||
# or remove any judgment about whether that decision was the right one.
|
||||
# This used to create a DRAFT, on the grounds that nobody had run the plugin
|
||||
# in the OBS GUI on any platform. That stopped being true on 2026-09-09, when
|
||||
# the v0.1.0 Windows artifact loaded into OBS 32.2.2 on Windows 11 -- so the
|
||||
# release publishes directly and the per-platform table below carries the
|
||||
# remaining caveats instead. Assets upload AFTER the release row is created
|
||||
# either way, so a release is briefly visible with no files attached; that is
|
||||
# the tradeoff for not needing a human click.
|
||||
#
|
||||
# Required env: GITEA_TOKEN, SERVER, OWNER, REPO, TAG, SHA, DIST_DIR
|
||||
# Optional env: MACOS_BUNDLE_FOUND ("true"/"false", default "false")
|
||||
@@ -25,79 +25,61 @@ set -euo pipefail
|
||||
MACOS_BUNDLE_FOUND="${MACOS_BUNDLE_FOUND:-false}"
|
||||
|
||||
if [ "${MACOS_BUNDLE_FOUND}" = "true" ]; then
|
||||
MACOS_NOTE="This archive contains a \`.plugin\` bundle."
|
||||
MACOS_NOTE="This archive contains a \`.plugin\` bundle (verified on v0.1.0: MH_BUNDLE + Info.plist, libobs via \`@rpath\` + \`@executable_path/../Frameworks\`, LiveKit dylibs bundled, all three binaries code-signed). **arm64 only -- no Intel slice**, macOS 13+. Never yet loaded in OBS.app by a human."
|
||||
else
|
||||
MACOS_NOTE="This archive is packaged as a bare \`streamer-tools-camera.so\` (the layout \`build/package/\` currently produces on macOS), **not** an OBS.app-loadable \`.plugin\` bundle. It will not load in the OBS GUI as-is."
|
||||
MACOS_NOTE="This archive is packaged as a bare \`streamer-tools-camera.so\` (the layout \`build/package/\` currently produces on macOS), **not** an OBS.app-loadable \`.plugin\` bundle. It will not load in the OBS GUI as-is -- see the \"macOS packaging gap\" section of \`README.md\`."
|
||||
fi
|
||||
|
||||
NOTES_FILE="$(mktemp)"
|
||||
cat > "${NOTES_FILE}" <<EOF
|
||||
> **This build has not been cleared for redistribution.** The plugin
|
||||
> statically/dynamically pulls in Google WebRTC and OpenH264 code through the
|
||||
> LiveKit SDK, and whether that can be redistributed as a public download --
|
||||
> the "C1" attribution/patent question -- has not been resolved. See the
|
||||
> \`## Status\` section of \`README.md\` and \`third_party/livekit/README.md\`
|
||||
> for the specifics. By publishing this release, you are personally taking on
|
||||
> that open question -- if C1 hasn't been signed off on, don't publish it.
|
||||
>
|
||||
> (The separate GPLv2/Apache-2.0 license-compatibility question, "C2", is
|
||||
> resolved: this project's own first-party code is Apache-2.0, matching the
|
||||
> vendored LiveKit binaries.)
|
||||
>
|
||||
> This release was created as a **draft**. It stays invisible to anyone
|
||||
> without write access to this repo until someone with write access opens it
|
||||
> here and clicks Publish -- a second, deliberate step past pushing the tag.
|
||||
|
||||
# streamer-tools Camera Plugin -- ${TAG}
|
||||
|
||||
Built from commit \`${SHA}\`.
|
||||
|
||||
**Nobody has yet run this plugin in the OBS GUI, on any platform.** See "What
|
||||
is verified, and how" in \`README.md\` for exactly what has and has not been
|
||||
checked, including which claims are backed by automated tests versus a human
|
||||
**Confirmed working on Linux and Windows, including a live show.** The plugin
|
||||
carried a real broadcast on 2026-09-07. Video and audio both arrive and hold
|
||||
up across a session: Linux verified by the project owner, Windows by two
|
||||
directors independently.
|
||||
|
||||
Still unverified: **macOS in the OBS GUI** (nobody has opened it -- see the
|
||||
table), **measured** A/V sync and end-to-end latency against the existing
|
||||
egress path (no drift reported, but nothing measured), and whether a publisher
|
||||
restarting mid-show recovers cleanly on screen. See "What is verified, and
|
||||
how" in \`README.md\` for what is backed by automated tests versus a human
|
||||
watching OBS.
|
||||
|
||||
| Platform | Archive | Notes |
|
||||
|---|---|---|
|
||||
| Linux (x64) | \`streamer-tools-camera-${TAG}-linux-x64.zip\` | Functionally complete and verified end to end against a real LiveKit server and a real libobs (see README); OBS GUI itself still unverified |
|
||||
| Windows (x64) | \`streamer-tools-camera-${TAG}-windows-x64.zip\` | Built and tested by this workflow's Windows job; the WinHTTP backend has never been exercised against a real streamer-tools server, only a loopback test server -- see README's Windows CI section |
|
||||
| macOS | \`streamer-tools-camera-${TAG}-macos.zip\` | Built and tested by this workflow's macOS job. ${MACOS_NOTE} See the "macOS packaging gap" in README |
|
||||
| Linux (x64) | \`streamer-tools-camera-${TAG}-linux-x64.zip\` | Functionally complete, verified end to end against a real LiveKit server and a real libobs (see README), and confirmed working in the OBS GUI |
|
||||
| Windows (x64) | \`streamer-tools-camera-${TAG}-windows-x64.zip\` | Built and tested by this workflow's Windows job, and confirmed working in the OBS GUI by two directors independently (first load: OBS 32.2.2 / Windows 11) -- which also exercises the WinHTTP backend against a real streamer-tools server |
|
||||
| macOS | \`streamer-tools-camera-${TAG}-macos.zip\` | Built and tested by this workflow's macOS job. ${MACOS_NOTE} |
|
||||
|
||||
## Installing
|
||||
|
||||
### Linux
|
||||
Extract the archive into your OBS plugins folder. **The directory is not the
|
||||
same shape on every platform, and picking the wrong one fails silently -- OBS
|
||||
logs nothing at all for a plugin it never finds:**
|
||||
|
||||
| Platform | Extract into |
|
||||
|---|---|
|
||||
| Windows | \`C:\\ProgramData\\obs-studio\\plugins\\\` -- **not** \`%APPDATA%\\obs-studio\\\`, which is where OBS keeps its config and is never scanned for plugins |
|
||||
| macOS | \`~/Library/Application Support/obs-studio/plugins/\` |
|
||||
| Linux | \`~/.config/obs-studio/plugins/\` |
|
||||
|
||||
Each archive's top-level folder already matches the shape OBS expects, so
|
||||
extracting is the whole install step -- but check the result is exactly one
|
||||
folder deep. Windows Explorer's "Extract All..." adds a folder named after the
|
||||
zip unless you clear it from the destination box, which nests it one level too
|
||||
far and is equally silent. On Windows the finished path must be:
|
||||
|
||||
\`\`\`
|
||||
mkdir -p ~/.config/obs-studio/plugins/streamer-tools-camera
|
||||
unzip streamer-tools-camera-${TAG}-linux-x64.zip -d /tmp/stplugin-camera
|
||||
cp -r /tmp/stplugin-camera/bin /tmp/stplugin-camera/data \\
|
||||
~/.config/obs-studio/plugins/streamer-tools-camera/
|
||||
C:\\ProgramData\\obs-studio\\plugins\\streamer-tools-camera\\bin\\64bit\\streamer-tools-camera.dll
|
||||
\`\`\`
|
||||
|
||||
Start 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. This is the same drop-in layout
|
||||
README's "Testing this by hand" documents for a source build, adapted for a
|
||||
downloaded zip -- known-good on Linux.
|
||||
|
||||
### Windows (installation path not yet verified in real OBS)
|
||||
|
||||
Per \`AddExtraModulePaths()\` in obs-studio's \`UI/window-basic-main.cpp\`, OBS
|
||||
on Windows searches a plugins directory for \`bin\\64bit\\<name>.dll\` plus a
|
||||
sibling \`data\\\`. Unzip the archive and copy its \`bin\\\` and \`data\\\` into
|
||||
your OBS plugins directory (typically
|
||||
\`%APPDATA%\\obs-studio\\plugins\\streamer-tools-camera\\\`), matching the
|
||||
Linux layout above. This has not been confirmed against a real OBS install on
|
||||
Windows -- report back if you try it.
|
||||
|
||||
### macOS (installation path not yet verified in real OBS; packaging gap)
|
||||
|
||||
OBS on macOS loads plugins as \`<name>.plugin\` bundles under
|
||||
\`~/Library/Application Support/obs-studio/plugins/\`. As of this release,
|
||||
this project's \`build/package/\` output on macOS is **not yet that bundle
|
||||
shape** -- see the "macOS packaging gap" section of \`README.md\`. Treat the
|
||||
macOS archive here as a build-verification artifact, not a working
|
||||
drop-in, until that gap is closed.
|
||||
To confirm it loaded, restart OBS and check Help -> Log Files -> View Current
|
||||
Log for \`streamer-tools-camera\` under "Loaded Modules". Then in OBS: 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.
|
||||
|
||||
## What this is
|
||||
|
||||
@@ -119,7 +101,7 @@ print(json.dumps({
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": notes,
|
||||
"draft": True,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}))
|
||||
PYEOF
|
||||
@@ -133,7 +115,7 @@ RESP="$(curl -sS -f -X POST \
|
||||
"${SERVER}/api/v1/repos/${OWNER}/${REPO}/releases")"
|
||||
|
||||
RELEASE_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"${RESP}")"
|
||||
echo "Created release id ${RELEASE_ID} (draft)."
|
||||
echo "Created release id ${RELEASE_ID} (published; assets upload next)."
|
||||
|
||||
shopt -s nullglob
|
||||
ASSETS=("${DIST_DIR}"/*)
|
||||
@@ -152,4 +134,4 @@ for f in "${ASSETS[@]}"; do
|
||||
> /dev/null
|
||||
done
|
||||
|
||||
echo "Done. Draft release: ${SERVER}/${OWNER}/${REPO}/releases/${RELEASE_ID}"
|
||||
echo "Done. Release: ${SERVER}/${OWNER}/${REPO}/releases/${RELEASE_ID}"
|
||||
|
||||
+105
-18
@@ -15,23 +15,45 @@ name: Build
|
||||
# .gitea/workflows/release.yml, so the two workflows can't drift apart --
|
||||
# edit the scripts, not either workflow, to change how a platform builds.
|
||||
#
|
||||
# RELEASE GATE: this workflow only builds, tests, and uploads CI-internal
|
||||
# workflow artifacts (actions/upload-artifact, below) -- it does not create a
|
||||
# Gitea Release, push a tag-triggered publish, or otherwise distribute
|
||||
# binaries publicly, and it must not start doing so without explicit owner
|
||||
# sign-off on the WebRTC/OpenH264 attribution question tracked in
|
||||
# third_party/livekit/README.md and the README's top-level Status section.
|
||||
# (The separate GPLv2/Apache-2.0 license-compatibility question is resolved:
|
||||
# this project's own code is Apache-2.0.) If a real release/publish step is
|
||||
# ever added here, it must carry that same gate.
|
||||
#
|
||||
# (.gitea/workflows/release.yml is that publish step, gated on a pushed
|
||||
# version tag rather than on every push -- see the gate reminder baked into
|
||||
# its generated release notes.)
|
||||
# This workflow only builds, tests, and uploads CI-internal workflow
|
||||
# artifacts (actions/upload-artifact, below) -- it does not create a Gitea
|
||||
# Release. .gitea/workflows/release.yml is that publish step, gated on a
|
||||
# pushed version tag rather than on every push.
|
||||
|
||||
on:
|
||||
push:
|
||||
# Excludes tag pushes -- a bare `push:` matches every ref push, tags
|
||||
# included, which meant tagging a release triggered THIS workflow's full
|
||||
# 3-platform build (Windows and all) at the same time as
|
||||
# release.yml's own -- two full Windows builds serialized behind the
|
||||
# runner's capacity:1, for one tag push. release.yml already covers
|
||||
# exactly this build (plus packaging) on every `v*` tag; this workflow's
|
||||
# job is ordinary commits.
|
||||
branches:
|
||||
- "**"
|
||||
# Documentation-only changes cannot break a build, and this workflow is a
|
||||
# full three-platform build (Windows included) behind a runner with
|
||||
# capacity:1. Six of these fired for one afternoon of README/release-notes
|
||||
# edits on 2026-09-09. Anything that feeds a build or a test is absent
|
||||
# from this list on purpose -- release.yml and publish-release.sh only run
|
||||
# on a `v*` tag, via release.yml's own trigger.
|
||||
#
|
||||
# Tradeoff: a docs-only push now shows NO status at all on the branch,
|
||||
# rather than a green one. If a required-status check is ever added, these
|
||||
# paths have to be reconsidered.
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "LICENSE"
|
||||
- "NOTICE"
|
||||
- ".gitea/workflows/release.yml"
|
||||
- ".gitea/scripts/publish-release.sh"
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "LICENSE"
|
||||
- "NOTICE"
|
||||
- ".gitea/workflows/release.yml"
|
||||
- ".gitea/scripts/publish-release.sh"
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
@@ -80,11 +102,30 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .deps
|
||||
key: obs-deps-${{ runner.os }}-${{ hashFiles('cmake/macos/buildspec.cmake', 'cmake/common/buildspec_common.cmake', 'buildspec.json') }}
|
||||
key: obs-deps-v2-${{ runner.os }}-${{ hashFiles('cmake/macos/buildspec.cmake', 'cmake/common/buildspec_common.cmake', 'buildspec.json') }}
|
||||
|
||||
- name: Configure, build, test, verify
|
||||
run: .gitea/scripts/macos-build.sh
|
||||
|
||||
- name: Drop non-relocatable OBS build tree before caching
|
||||
# cmake/common/buildspec_common.cmake's obs-studio sub-build writes
|
||||
# an out-of-source CMakeCache.txt (.deps/obs-studio-*/build_*) that
|
||||
# bakes in this job's absolute checkout path. The next run's
|
||||
# checkout lands at a *different* absolute path, so restoring that
|
||||
# directory from the cache above makes CMake refuse to reconfigure
|
||||
# it ("CMakeCache.txt directory ... is different than the directory
|
||||
# ... where CMakeCache.txt was created"). Everything that actually
|
||||
# needs to survive between runs -- the extracted source, and the
|
||||
# already-installed libobs package under .deps/cmake, .deps/include,
|
||||
# .deps/lib -- has no such path baked in and is unaffected. Delete
|
||||
# only the intermediate build tree, after it has already done its
|
||||
# job (libobs is built and installed by this point), so the cache
|
||||
# saved at the end of this job contains nothing that requires the
|
||||
# path it was created under.
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: rm -rf .deps/obs-studio-*/build_*
|
||||
|
||||
- name: Upload plugin
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -99,21 +140,51 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install build dependencies
|
||||
- name: Verify build dependencies
|
||||
# winvm-builder is a self-hosted act_runner labeled "windows-latest";
|
||||
# it is NOT the GitHub-hosted image, so none of that image's
|
||||
# preinstalled tooling (cmake included) can be assumed present.
|
||||
uses: lukka/get-cmake@latest
|
||||
# preinstalled tooling can be assumed present. This used to be
|
||||
# `uses: lukka/get-cmake@latest`, which re-downloaded and
|
||||
# re-extracted CMake + Ninja on every single run -- its own cache
|
||||
# (routed through this act_runner's cache server) reported a "cloud
|
||||
# cache miss" on every run even immediately after a successful save,
|
||||
# and separately the extraction step alone measured ~7.5 minutes on
|
||||
# this VM (consistent with Defender real-time scanning, not raw I/O)
|
||||
# -- together the dominant cost of every Windows CI run. CMake and
|
||||
# Ninja are now installed once, directly on winvm-builder's system
|
||||
# PATH (C:\BuildTools\cmake\bin, C:\BuildTools\ninja -- see the
|
||||
# README's "Windows runner: persistent build tools" section for
|
||||
# exactly what that machine has installed and how to redo it if the
|
||||
# VM is ever rebuilt). This step just fails loudly if that ever
|
||||
# stops being true, rather than silently falling back to a slow
|
||||
# re-download.
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cmake --version
|
||||
ninja --version
|
||||
|
||||
- name: Cache OBS SDK bootstrap deps
|
||||
# See the matching step in the macOS job above for why this is
|
||||
# needed: cmake/windows/buildspec.cmake's own download logic is
|
||||
# already idempotent, it just never gets the chance because .deps/
|
||||
# lives inside the checkout and is wiped by every fresh clone.
|
||||
#
|
||||
# The `v2` in the key: actions/cache never overwrites an existing
|
||||
# key -- once a key has a saved entry, every later job's save step is
|
||||
# skipped as a no-op, cache hit or not. The very first job ever
|
||||
# to populate this cache did so BEFORE the "Drop non-relocatable OBS
|
||||
# build tree" step below existed, so its save included the bad
|
||||
# build_x86 directory -- and because saves under an existing key are
|
||||
# permanently skipped, every run after that kept restoring that same
|
||||
# bad entry forever, not "one more transitional run" as it looked at
|
||||
# the time. Bumping the key is what actually forces a fresh save;
|
||||
# bump it again (v3, ...) if this cache is ever found to be stale in
|
||||
# a way a workflow change alone can't fix.
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .deps
|
||||
key: obs-deps-${{ runner.os }}-${{ hashFiles('cmake/windows/buildspec.cmake', 'cmake/common/buildspec_common.cmake', 'buildspec.json') }}
|
||||
key: obs-deps-v2-${{ runner.os }}-${{ hashFiles('cmake/windows/buildspec.cmake', 'cmake/common/buildspec_common.cmake', 'buildspec.json') }}
|
||||
|
||||
- name: Configure, build, test, verify
|
||||
# Windows PowerShell (powershell.exe), not PowerShell Core (pwsh) --
|
||||
@@ -123,6 +194,22 @@ jobs:
|
||||
shell: powershell
|
||||
run: ./.gitea/scripts/windows-build.ps1
|
||||
|
||||
- name: Drop non-relocatable OBS build tree before caching
|
||||
# See the matching step in the macOS job above. Confirmed live on
|
||||
# this runner: caching .deps/obs-studio-30.0.2/build_x86 as-is made
|
||||
# every run's first configure attempt fail with a path mismatch
|
||||
# against the job that populated the cache, falling back to
|
||||
# -DSTPLUGIN_BOOTSTRAP_OBS=OFF and only succeeding because the
|
||||
# already-installed libobs package (path-independent) was still
|
||||
# found. That fallback masked the problem behind a misleading
|
||||
# "::warning::OBS SDK bootstrap failed" every run instead of fixing
|
||||
# it. Deleting the build tree here, after libobs is already built
|
||||
# and installed, is the actual fix.
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
shell: powershell
|
||||
run: Remove-Item -Recurse -Force .deps\obs-studio-*\build_* -ErrorAction SilentlyContinue
|
||||
|
||||
- name: Upload plugin
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
name: Release
|
||||
|
||||
# Packages a build of each platform into a downloadable archive and creates
|
||||
# a (draft) Gitea Release for it, so the project owner and other directors
|
||||
# a published Gitea Release for it, so the project owner and other directors
|
||||
# can grab a ready-to-use build instead of compiling from source.
|
||||
#
|
||||
# RELEASE GATE -- READ BEFORE TAGGING
|
||||
# ------------------------------------------------------------------
|
||||
# This workflow runs ONLY on a pushed version tag (see `on.push.tags` below)
|
||||
# -- it never runs on an ordinary push or PR, unlike build.yml. Pushing a
|
||||
# tag is therefore the one deliberate human act that starts it, and the
|
||||
# release it creates is a DRAFT: it stays invisible to anyone without write
|
||||
# access until a human explicitly opens it and clicks Publish. That is a
|
||||
# second deliberate act past the tag push.
|
||||
#
|
||||
# Both of those are process, not a legal opinion. The actual open question --
|
||||
# whether this plugin's bundled WebRTC/OpenH264 code (via LiveKit) can be
|
||||
# redistributed as a public download at all -- is tracked as "C1" in the
|
||||
# README's `## Status` section and in third_party/livekit/README.md, and it
|
||||
# is NOT resolved. Nothing here resolves it; the generated release notes put
|
||||
# a reminder of that fact at the top of every release this workflow creates,
|
||||
# specifically so nobody publishes a draft without seeing it again first.
|
||||
# (The separate GPLv2/Apache-2.0 question, "C2", *is* resolved -- see
|
||||
# README.)
|
||||
# Runs only on a pushed version tag (see `on.push.tags` below) -- never on an
|
||||
# ordinary push or PR, unlike build.yml. The release it creates is PUBLISHED
|
||||
# immediately. It used to be a draft, gated on a human clicking Publish
|
||||
# because nobody had run the plugin in the OBS GUI on any platform; the first
|
||||
# confirmed GUI load (Windows, 2026-09-09) retired that. The caveats that
|
||||
# remain live in the generated release notes, not in the draft flag -- see
|
||||
# .gitea/scripts/publish-release.sh.
|
||||
#
|
||||
# The actual per-platform build commands live in .gitea/scripts/ and are the
|
||||
# same scripts .gitea/workflows/build.yml uses, so this workflow can't drift
|
||||
@@ -55,7 +44,19 @@ jobs:
|
||||
command -v zip >/dev/null || sudo apt-get install -y -qq zip
|
||||
out="streamer-tools-camera-${GITEA_REF_NAME}-linux-x64.zip"
|
||||
root="$(pwd)"
|
||||
( cd build/package && zip -r "${root}/${out}" . )
|
||||
|
||||
# Wrap build/package/'s bin/+data/ inside a top-level
|
||||
# streamer-tools-camera/ directory, matching the plugin directory
|
||||
# name OBS itself expects under <config>/obs-studio/plugins/ (see
|
||||
# obs-adapter/CMakeLists.txt's staging comment). This makes the
|
||||
# archive a straight `unzip -d ~/.config/obs-studio/plugins/`
|
||||
# drop-in -- no manual `cp -r bin data` step required.
|
||||
stage="$(mktemp -d)"
|
||||
mkdir -p "${stage}/streamer-tools-camera"
|
||||
cp -r build/package/. "${stage}/streamer-tools-camera/"
|
||||
( cd "${stage}" && zip -r "${root}/${out}" streamer-tools-camera )
|
||||
rm -rf "${stage}"
|
||||
|
||||
mkdir -p dist
|
||||
mv "${out}" "dist/${out}"
|
||||
ls -la dist
|
||||
@@ -91,13 +92,18 @@ jobs:
|
||||
root="$(pwd)"
|
||||
mkdir -p dist
|
||||
|
||||
# macOS packaging is being fixed separately (see the "macOS
|
||||
# packaging gap" in README.md). Once it lands, build/package/ (or
|
||||
# wherever that work stages its output) should contain a
|
||||
# `<name>.plugin` bundle directory -- look for one rather than
|
||||
# assuming its exact final location, and fall back to packaging
|
||||
# build/package/ as-is (today's actual, non-bundle output) if none
|
||||
# is found yet.
|
||||
# Look for a *.plugin bundle rather than assuming its exact final
|
||||
# location, falling back to packaging build/package/ as-is (a
|
||||
# bare .so, not a loadable bundle) only if the bundle step didn't
|
||||
# run or produced nothing -- see the macOS packaging gap in
|
||||
# README.md for when that fallback path is actually live. The
|
||||
# bundle itself is zipped at the archive's top level (cd into its
|
||||
# parent, zip just the bundle dir) so the archive is already a
|
||||
# straight `unzip -d ~/Library/Application\ Support/obs-studio/
|
||||
# plugins/` drop-in -- no wrapping needed here, unlike
|
||||
# Linux/Windows above, because OBS wants the whole *.plugin
|
||||
# bundle directly under plugins/, not nested under a named
|
||||
# subdirectory.
|
||||
bundle="$(find build -maxdepth 4 -type d -name '*.plugin' 2>/dev/null | head -n1 || true)"
|
||||
if [ -n "${bundle}" ]; then
|
||||
echo "Found macOS .plugin bundle: ${bundle}"
|
||||
@@ -127,8 +133,16 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: lukka/get-cmake@latest
|
||||
- name: Verify build dependencies
|
||||
# See build.yml's Windows job for why this is no longer
|
||||
# lukka/get-cmake@latest -- CMake and Ninja are installed once,
|
||||
# directly on winvm-builder's system PATH; this just fails loudly
|
||||
# if that ever stops being true.
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cmake --version
|
||||
ninja --version
|
||||
|
||||
- name: Configure, build, test, verify
|
||||
# Windows PowerShell (powershell.exe), not PowerShell Core (pwsh) --
|
||||
@@ -143,7 +157,23 @@ jobs:
|
||||
$ErrorActionPreference = "Stop"
|
||||
$out = "streamer-tools-camera-$env:GITEA_REF_NAME-windows-x64.zip"
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
Compress-Archive -Path build\package\* -DestinationPath "dist\$out" -Force
|
||||
|
||||
# Wrap build\package\'s bin\+data\ inside a top-level
|
||||
# streamer-tools-camera\ directory, matching the plugin directory
|
||||
# name OBS itself expects under %APPDATA%\obs-studio\plugins\ (see
|
||||
# obs-adapter/CMakeLists.txt's staging comment). This makes the
|
||||
# archive a straight `Expand-Archive -DestinationPath
|
||||
# $env:APPDATA\obs-studio\plugins\` drop-in -- no manual copy step
|
||||
# required. Compress-Archive includes the source folder's own name
|
||||
# as the archive root when given a single directory path, so
|
||||
# staging under a streamer-tools-camera\ dir is enough on its own.
|
||||
$stage = Join-Path $env:TEMP "stplugin-stage-$([guid]::NewGuid())"
|
||||
$pluginDir = Join-Path $stage "streamer-tools-camera"
|
||||
New-Item -ItemType Directory -Force -Path $pluginDir | Out-Null
|
||||
Copy-Item -Path build\package\* -Destination $pluginDir -Recurse
|
||||
Compress-Archive -Path $pluginDir -DestinationPath "dist\$out" -Force
|
||||
Remove-Item -Recurse -Force $stage
|
||||
|
||||
Get-ChildItem dist
|
||||
env:
|
||||
GITEA_REF_NAME: ${{ github.ref_name }}
|
||||
@@ -155,7 +185,7 @@ jobs:
|
||||
path: dist
|
||||
|
||||
release:
|
||||
name: Create Gitea Release (draft)
|
||||
name: Create Gitea Release
|
||||
needs: [linux, macos, windows]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
@@ -186,7 +216,7 @@ jobs:
|
||||
name: release-archive-windows-x64
|
||||
path: dist
|
||||
|
||||
- name: Create draft release and upload assets
|
||||
- name: Create release and upload assets
|
||||
run: .gitea/scripts/publish-release.sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
cmake_minimum_required(VERSION 3.19)
|
||||
|
||||
project(obs-streamer-tools-plugin
|
||||
VERSION 0.1.0
|
||||
VERSION 0.1.1
|
||||
DESCRIPTION "OBS Studio source plugin for streamer-tools camera feeds"
|
||||
LANGUAGES C CXX
|
||||
)
|
||||
|
||||
@@ -8,43 +8,44 @@ Media-Source path for directors. Full design:
|
||||
|
||||
## Status
|
||||
|
||||
**Release/distribution of built binaries is blocked pending owner sign-off.**
|
||||
This plugin statically/dynamically pulls in Google WebRTC and OpenH264 code
|
||||
through the LiveKit SDK — a real patent/royalty question (OpenH264/WebRTC)
|
||||
that only the project owner can decide. Nothing in this repo should be built
|
||||
into a package and handed out, posted, or attached to a public release until
|
||||
that sign-off happens. See `third_party/livekit/README.md` for the specifics
|
||||
of what is and is not currently known/shipped on the licensing side. (CI in
|
||||
`.gitea/workflows/build.yml` only builds, tests, and uploads CI-internal
|
||||
build artifacts — it does not create a Gitea Release or otherwise publish
|
||||
anything publicly.
|
||||
This project's own code is Apache-2.0 (relicensed from GPL-2.0-or-later to
|
||||
match the vendored LiveKit binaries, which are also Apache-2.0 — see
|
||||
`LICENSE` and `NOTICE`, and `third_party/livekit/` for LiveKit's own).
|
||||
|
||||
`.gitea/workflows/release.yml` is the mechanism that *would* publish a
|
||||
release, but it does not run automatically: it is gated on someone pushing a
|
||||
`v*` tag, which is the actual sign-off gate in practice — don't push one
|
||||
until the owner has actually signed off on C1. When it does run, it packages
|
||||
each platform's `build/package/` (or macOS's bundle output, once that lands)
|
||||
into a zip and creates a **draft** Gitea Release, whose generated release
|
||||
notes lead with the same C1 reminder as this section, so whoever opens the
|
||||
draft to publish it sees the open question again before doing so. Building
|
||||
that mechanism is not the same as clearing C1 — it still requires the same
|
||||
owner sign-off before a tag gets pushed.)
|
||||
|
||||
The separate license-compatibility question — this repository's own top-level
|
||||
`LICENSE` was GPLv2 while the vendored LiveKit binaries are Apache-2.0, which
|
||||
are incompatible — is **resolved**: the project owner has relicensed this
|
||||
project's own first-party code to Apache-2.0, matching LiveKit. Everything in
|
||||
this repo is now Apache-2.0, so there is no remaining GPL/Apache
|
||||
incompatibility.
|
||||
`.gitea/workflows/build.yml` builds, tests, and uploads CI-internal build
|
||||
artifacts on every push. `.gitea/workflows/release.yml` packages a tagged
|
||||
build (`v*`) into a **published** Gitea Release. It created drafts until
|
||||
2026-09-09, gated on a human clicking Publish because nobody had run the
|
||||
plugin in the OBS GUI; the first confirmed GUI load retired that gate, and the
|
||||
remaining caveats live in the generated release notes instead.
|
||||
|
||||
The plugin is **functionally complete on Linux and verified end to end there**
|
||||
(module loads into real libobs, connects to a real LiveKit server through the
|
||||
real streamer-tools API shape, and pushes decoded frames into
|
||||
`obs_source_output_video`/`_audio`).
|
||||
|
||||
It has **not been run in the OBS GUI on any platform.** macOS builds the real
|
||||
module in CI but its artifact is not yet loadable (see the macOS packaging gap
|
||||
under CI).
|
||||
**Confirmed working in the OBS GUI on Linux and Windows, including a live
|
||||
show.** The plugin carried a real broadcast on 2026-09-07 and was reported to
|
||||
work well. Video and audio both arrive and hold up across a session: Linux
|
||||
verified by the project owner, Windows by two directors independently
|
||||
(2026-09-09/10; the first Windows load was OBS 32.2.2 on Windows 11 build
|
||||
26200, from
|
||||
`C:\ProgramData\obs-studio\plugins\streamer-tools-camera\bin\64bit\`).
|
||||
Because listing cameras requires an API call, that also retires "the WinHTTP
|
||||
backend has never run against a real streamer-tools server".
|
||||
|
||||
What that does **not** cover: measured A/V sync and end-to-end latency against
|
||||
the existing egress path (no drift reported over a session, but nothing was
|
||||
measured), mid-show publisher restart, and **macOS in the GUI — still never
|
||||
opened by anyone**, though its artifact is now known to be correctly packaged
|
||||
(see macOS packaging below). See "Not verified anywhere" for the current list.
|
||||
|
||||
⚠️ **The install directory is not the same on every platform, and getting it
|
||||
wrong fails silently.** On Windows it is
|
||||
`C:\ProgramData\obs-studio\plugins\` (`GetProgramDataPath` →
|
||||
`CSIDL_COMMON_APPDATA`), **not** `%APPDATA%\obs-studio\` — see the packaging
|
||||
section. That mistake cost the director above an evening: OBS logs nothing at
|
||||
all for a plugin it never finds.
|
||||
|
||||
**Windows CI is now green.** The run at `f27b1c0` is the first completed
|
||||
green Windows job on this repository: the from-source libobs bootstrap
|
||||
@@ -54,8 +55,8 @@ suites pass, and `build\package\bin\64bit\streamer-tools-camera.dll`
|
||||
out of the job's own log body, not inferred from the job status. That also
|
||||
retires three previously-unproven items in one go: the `-A x64` argument fix,
|
||||
the PowerShell rewrite of the Windows steps, and the `add_subdirectory`
|
||||
patch for `OBS::w32-pthreads`. Windows is still **unverified in the OBS GUI**,
|
||||
exactly like the other two platforms. See "Where the Windows bootstrap got
|
||||
patch for `OBS::w32-pthreads`. Windows has since been **loaded in the real OBS
|
||||
GUI** (see above); Linux and macOS have not. See "Where the Windows bootstrap got
|
||||
to" under CI below for the whole trace, and check current CI status rather
|
||||
than trusting this paragraph's age.
|
||||
|
||||
@@ -85,7 +86,7 @@ scripts/livekit-dev-room.py - mints tokens for the integration test
|
||||
third_party/livekit/ - redistribution notices for the LiveKit binaries
|
||||
.gitea/scripts/ - the actual per-platform build commands, shared by build.yml and release.yml
|
||||
.gitea/workflows/build.yml - 3-platform CI matrix (every push/PR; never publishes)
|
||||
.gitea/workflows/release.yml - packages + creates a draft Gitea Release (only on a `v*` tag push; see Status above)
|
||||
.gitea/workflows/release.yml - packages + publishes a Gitea Release (only on a `v*` tag push; see Status above)
|
||||
```
|
||||
|
||||
## How it works
|
||||
@@ -174,13 +175,15 @@ plugins. This bit a director on 2026-09-09: a correctly-shaped install under
|
||||
`AppData\Roaming` produced a log with zero mention of the module.
|
||||
|
||||
The module resolves the LiveKit libraries from `$ORIGIN` (verified: `ldd` on the staged copy resolves both
|
||||
to `bin/64bit/`), not from the build tree. macOS is not this shape; see the
|
||||
macOS packaging gap under CI.
|
||||
to `bin/64bit/`), not from the build tree. macOS is not this shape — it ships
|
||||
a `.plugin` bundle; see macOS packaging under CI.
|
||||
|
||||
## Testing this by hand
|
||||
|
||||
**Nobody has yet run this in the OBS GUI. That test is still outstanding on
|
||||
all three platforms.** To do it on Linux:
|
||||
**Linux and Windows are confirmed working in the GUI — video and audio over a
|
||||
real session, Linux by the project owner and Windows by two directors
|
||||
independently (2026-09-09/10). macOS has never been opened in the GUI by
|
||||
anyone.** To repeat the Linux run:
|
||||
|
||||
```
|
||||
mkdir -p ~/.config/obs-studio/plugins/streamer-tools-camera
|
||||
@@ -236,17 +239,17 @@ livekit-server 1.13.6 in dev mode):
|
||||
| Two sources in one OBS process | same harness with a second source added: both connect with distinct nonce identities, both receive frames, both tear down cleanly |
|
||||
|
||||
**Not verified anywhere:**
|
||||
- The OBS GUI, on any platform. No human has looked at this in OBS.
|
||||
- macOS beyond "CI builds and links the real module and the core tests pass".
|
||||
Its artifact is a bare `.so` with a relative libobs install name and will
|
||||
not load in OBS.app — see the macOS packaging gap under CI.
|
||||
- Windows beyond "the core library and the WinHTTP backend compile and their
|
||||
tests pass", from runs predating the current fixes. The WinHTTP backend has
|
||||
never run against a real streamer-tools server, only against the loopback
|
||||
test server in `test_api_client`.
|
||||
- A/V sync and end-to-end latency against the existing egress path.
|
||||
- Behaviour against the real production streamer-tools server (only against a
|
||||
stand-in serving the same shapes).
|
||||
- **macOS in the OBS GUI.** Nobody has opened it. Its artifact is now known to
|
||||
be a correctly-formed, correctly-linked, code-signed `.plugin` bundle
|
||||
(verified by inspecting the shipped v0.1.0 zip — see macOS packaging under
|
||||
CI), and it is arm64-only, so Intel Macs are out regardless. "The bundle is
|
||||
well formed" is not "OBS loaded it".
|
||||
- **Measured** A/V sync and end-to-end latency against the existing egress
|
||||
path. A live show and several sessions on Linux and Windows produced no
|
||||
reported drift, which is not the same as a measurement — and the timestamp
|
||||
caveat above is the reason to want real numbers.
|
||||
- Whether a publisher restarting mid-show recovers cleanly on screen.
|
||||
- Token expiry across a session longer than an hour (see below).
|
||||
- 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 —
|
||||
@@ -260,8 +263,8 @@ runners available to this repo under the `CyberCoveLLC` org.
|
||||
| Job | `runs-on` | Runner | State |
|
||||
|---|---|---|---|
|
||||
| `linux` | `ubuntu-24.04` | `localhost.localdomain` | **Green.** Builds the real adapter against Ubuntu's libobs-dev 30.0.2, runs all six test suites, uploads `build/package` as an artifact |
|
||||
| `macos` | `macos-latest` | `home-mac` (Global) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact uploaded. But see the macOS packaging gap below |
|
||||
| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | **Failing, fix pushed and awaiting a completed run.** Every completed run so far has failed; the latest got as far as building libobs and stopped on an OBS-side `OBS::w32-pthreads` target that its own modern CMake path never defines. A bootstrap patch for that gap has been pushed but not yet confirmed by a green run; see below |
|
||||
| `macos` | `macos-latest` | `home-mac` (Global) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact uploaded as a `.plugin` bundle. Never loaded in OBS.app, and arm64-only — see macOS packaging below |
|
||||
| `windows` | `windows-latest` | `winvm-builder` (org-scoped) | **Green.** Builds libobs 30.0.2 from source, then the real adapter; 6/6 tests; artifact staged. Was red twice more after the bootstrap was fixed, both times on `test_api_client`'s timeout probe — see "WinHTTP timeouts are not deadlines" below |
|
||||
|
||||
The Linux job is pinned to `ubuntu-24.04` rather than `ubuntu-latest`: this
|
||||
instance's two Linux runners answer `ubuntu-latest` with different releases,
|
||||
@@ -284,6 +287,51 @@ and a permanently red CI teaches people to ignore CI. **Do not remove the
|
||||
warning:** a green job that quietly stopped building the plugin is worse than
|
||||
a red one.
|
||||
|
||||
### Windows runner: persistent build tools (2026-09-07)
|
||||
|
||||
`winvm-builder`'s Windows job used to install its own CMake + Ninja on every
|
||||
single run via `uses: lukka/get-cmake@latest`. That action has its own
|
||||
caching (routed through this act_runner's built-in cache server, the same
|
||||
mechanism `.deps/`'s `actions/cache` step above relies on and that one does
|
||||
work) but it never hit: every run logged `Cloud cache miss` against the same
|
||||
cache key, even immediately after a run that logged a successful save under
|
||||
that exact key -- some incompatibility between `lukka/get-cmake`'s bundled
|
||||
cache client and this act_runner's cache-server implementation, not
|
||||
"caching isn't configured." Separately, and the larger cost: the archive
|
||||
extraction step alone measured **~7.5 minutes** for a 45MB zip on this VM
|
||||
(13:11:14 to 13:18:48 in one captured run) -- consistent with Windows
|
||||
Defender real-time-scanning every extracted file, not raw disk I/O, though
|
||||
that specific cause is not confirmed. Together this was the dominant cost of
|
||||
every Windows CI run, cold cache or not.
|
||||
|
||||
Fix: CMake 4.4.2 and Ninja 1.12.1 are now installed once, directly on the
|
||||
`winvm-builder` VM (Proxmox VMID 110, host pve4/192.168.1.145), not fetched
|
||||
per-run:
|
||||
|
||||
- `C:\BuildTools\cmake\` (from
|
||||
`cmake-4.4.2-windows-x86_64.zip`, Kitware's GitHub releases) and
|
||||
`C:\BuildTools\ninja\` (from `ninja-win.zip`, `ninja-build/ninja` v1.12.1
|
||||
release) — plain `Expand-Archive` drops, nothing installed via an
|
||||
installer/MSI.
|
||||
- Both added to the **Machine**-level `PATH`
|
||||
(`[Environment]::SetEnvironmentVariable('PATH', ..., 'Machine')`, not
|
||||
`setx`, which silently truncates a `PATH` this long).
|
||||
- The `GiteaRunner-winvm-builder` scheduled task (`C:\gitea-runner\
|
||||
gitea-runner.exe daemon`, runs as SYSTEM) was stopped and restarted after
|
||||
the `PATH` change — a already-running process does not pick up an updated
|
||||
Machine environment variable, only processes started after the change do,
|
||||
and every CI job is a child process of this one long-running daemon.
|
||||
|
||||
Both workflows' Windows jobs now just run `cmake --version` / `ninja
|
||||
--version` as a "Verify build dependencies" step and fail loudly if either
|
||||
is missing, instead of silently falling back to the slow per-run install.
|
||||
|
||||
**This is VM state, not something `git clone` reproduces.** If
|
||||
`winvm-builder` is ever rebuilt or reimaged, redo the three steps above
|
||||
(download+extract both zips under `C:\BuildTools\`, extend the Machine
|
||||
`PATH`, restart the scheduled task) before expecting Windows CI to pass
|
||||
again — there is nothing in this repo that does it automatically.
|
||||
|
||||
### Where the macOS bootstrap actually got to
|
||||
|
||||
Six CI iterations, each fixing a real failure visible in the logs:
|
||||
@@ -310,26 +358,36 @@ obs-studio, builds libobs from source, builds and links the real adapter,
|
||||
passes 6/6 tests, and uploads its artifact. `otool -L` on the result shows it
|
||||
linked against libobs and `@rpath/liblivekit.dylib`.
|
||||
|
||||
### macOS packaging gap (known, unfixed)
|
||||
### macOS packaging (was described here as broken; it is not)
|
||||
|
||||
**The macOS artifact will not load in OBS.app as it stands.** Two reasons,
|
||||
neither of which CI can catch, because CI only proves it compiles and links:
|
||||
**This section used to claim the macOS artifact was a bare
|
||||
`streamer-tools-camera.so` with a relative libobs install name that "will not
|
||||
load in OBS.app as it stands". That is wrong, and it contradicted the release
|
||||
notes for the same build.** Corrected 2026-09-10 by inspecting the shipped
|
||||
`streamer-tools-camera-v0.1.0-macos.zip` itself:
|
||||
|
||||
1. It is a bare `streamer-tools-camera.so`. OBS on macOS loads plugins as
|
||||
`<name>.plugin` bundles (`Contents/MacOS/<name>`, `Contents/Resources/`,
|
||||
an `Info.plist`), which is what obs-plugintemplate's
|
||||
`cmake/macos/helpers.cmake` builds and which this project deliberately did
|
||||
not vendor.
|
||||
2. `otool -L` shows the libobs dependency recorded as the relative path
|
||||
`libobs/libobs.framework/Versions/A/libobs`, inherited from the
|
||||
from-source libobs's own install name. A real plugin needs
|
||||
`@rpath/libobs.framework/Versions/A/libobs` plus an `LC_RPATH` pointing at
|
||||
`OBS.app/Contents/Frameworks`.
|
||||
- It is a proper bundle: `streamer-tools-camera.plugin/Contents/MacOS/streamer-tools-camera`
|
||||
(Mach-O **`MH_BUNDLE`**, which is what OBS loads), plus `Info.plist`
|
||||
(`CFBundlePackageType BNDL`, `CFBundleExecutable streamer-tools-camera`),
|
||||
`Contents/Resources/locale/en-US.ini`, and both LiveKit dylibs under
|
||||
`Contents/Frameworks/`.
|
||||
- The install names are right, which was the specific doubt. The module loads
|
||||
`@rpath/libobs.framework/Versions/A/libobs` and carries
|
||||
`LC_RPATH @executable_path/../Frameworks` — inside OBS.app that resolves to
|
||||
`OBS.app/Contents/Frameworks`, where libobs lives. `@rpath/liblivekit.dylib`
|
||||
resolves through `LC_RPATH @loader_path/../Frameworks` to the bundle's own
|
||||
copy, and `liblivekit.dylib` finds `liblivekit_ffi.dylib` through its own
|
||||
`LC_RPATH @loader_path`. Nothing points into a build tree.
|
||||
- All three binaries carry an `LC_CODE_SIGNATURE` (superblob `0xfade0cc0`),
|
||||
which is not optional: arm64 macOS refuses to load unsigned code at all.
|
||||
|
||||
Fixing this means either vendoring the template's macOS bundle helpers or
|
||||
adding an `install_name_tool` pass and a bundle layout — bounded work, but
|
||||
work that has to be done and checked on an actual Mac. It is deliberately not
|
||||
attempted here rather than guessed at.
|
||||
**The real macOS limitation is different: the bundle is arm64-only.** There is
|
||||
no x86_64 slice, so Intel Macs cannot load it, and `LSMinimumSystemVersion` is
|
||||
`13.0`. Shipping a universal binary would mean building both slices and
|
||||
`lipo`-ing them, on a Mac.
|
||||
|
||||
Everything above is static inspection of the artifact. **Nobody has yet opened
|
||||
it in OBS.app** — well-formed and signed is a strong prior, not a load.
|
||||
|
||||
### Where the Windows bootstrap got to
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ You may obtain a copy of the License at
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -43,10 +44,33 @@ struct SessionConfig {
|
||||
|
||||
bool subscribe_audio = true;
|
||||
|
||||
/// False for an audio-only source (the soundboard, say): the wanted
|
||||
/// video track is never attached (no AttachVideo command posted), and
|
||||
/// its publication is explicitly disabled server-side (RemoteTrack-
|
||||
/// Publication::setEnabled(false)) so the SFU stops sending it at all --
|
||||
/// not just "decoded and discarded here", genuinely not delivered.
|
||||
bool subscribe_video = true;
|
||||
|
||||
/// How long connect() waits for the room to come up before giving up.
|
||||
int connect_timeout_ms = 15000;
|
||||
};
|
||||
|
||||
/// How long the stall-recovery watchdog waits for a decoded video frame
|
||||
/// before treating the subscription as stalled and forcing a fresh
|
||||
/// keyframe (see StallWatchdog's comment in session_types.h for why this
|
||||
/// exists -- unrecoverable packet loss with no PLI/keyframe-request API in
|
||||
/// the pinned SDK). Long enough that ordinary jitter never trips it (a
|
||||
/// healthy 30fps subscription delivers a frame at least every ~33ms);
|
||||
/// short enough a director barely has time to notice before it recovers.
|
||||
constexpr std::chrono::milliseconds kStallRecoveryTimeout{2000};
|
||||
|
||||
/// Ceiling for the backoff between repeated recovery attempts against the
|
||||
/// SAME stall. Starts at kStallRecoveryTimeout and doubles each attempt, so
|
||||
/// a genuinely gone publisher (crashed encoder, dead upstream network) is
|
||||
/// retried every 2s, 4s, 8s, ... 30s rather than hammered every 2 seconds
|
||||
/// for the rest of the show.
|
||||
constexpr std::chrono::milliseconds kStallRecoveryMaxBackoff{30000};
|
||||
|
||||
/// Wraps livekit::Room for exactly one subscribed slot.
|
||||
///
|
||||
/// Threading contract, which the OBS adapter depends on:
|
||||
@@ -60,6 +84,11 @@ struct SessionConfig {
|
||||
/// call.
|
||||
/// - The state handler is invoked from whichever thread observed the
|
||||
/// change. It must not block and must not call back into this object.
|
||||
/// - The diagnostic handler (currently just the stall-recovery watchdog,
|
||||
/// see kStallRecoveryTimeout below) may be invoked from the internal
|
||||
/// command-queue worker thread or a video reader thread. Same rules as
|
||||
/// the state handler: must not block, must not call back into this
|
||||
/// object.
|
||||
/// - All handlers must be installed before connect(); they are not
|
||||
/// synchronised against a running session.
|
||||
class LiveKitSession {
|
||||
@@ -73,6 +102,7 @@ public:
|
||||
void setVideoHandler(VideoFrameHandler handler);
|
||||
void setAudioHandler(AudioFrameHandler handler);
|
||||
void setStateHandler(SessionStateHandler handler);
|
||||
void setDiagnosticHandler(DiagnosticHandler handler);
|
||||
|
||||
/// Connect and start subscribing. Returns true once the room is up; the
|
||||
/// selected slot's tracks may still arrive later (or not at all, if the
|
||||
|
||||
@@ -19,6 +19,7 @@ You may obtain a copy of the License at
|
||||
// self-consistent -- all live here, and LiveKitSession is the (much thinner)
|
||||
// piece that wires real SDK callbacks into them.
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
@@ -137,6 +138,82 @@ private:
|
||||
bool has_audio_ = false;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stall-recovery watchdog (pure timing/decision logic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Decides WHEN to force a fresh keyframe on an already-subscribed video
|
||||
/// track. It does not touch LiveKit or OBS at all -- LiveKitSession::Impl
|
||||
/// (session.cpp) is what actually carries the decision out
|
||||
/// (RemoteTrackPublication::setEnabled(false) then setEnabled(true)), and
|
||||
/// only from its SDK command-queue thread, the same rule every other SDK
|
||||
/// interaction in that file already follows.
|
||||
///
|
||||
/// Why this exists: measured on the live server, comparing subscribers in
|
||||
/// the same LiveKit room over the same 30-minute window, every OBS plugin
|
||||
/// connection racked up ~862-1099 nackMisses and ~2200-3100 nackRepeated --
|
||||
/// nackMisses means the subscriber asked the SFU to retransmit a packet
|
||||
/// that had already aged out of its send buffer, i.e. unrecoverable loss --
|
||||
/// while a browser subscriber in the same room saw 0 and 0. A decoder that
|
||||
/// loses a frame that way cannot resync without a fresh keyframe. Every OBS
|
||||
/// plugin connection also sat at `plis` == 2 for a multi-hour session (a
|
||||
/// browser adapts and asks for keyframes normally), and grepping the pinned
|
||||
/// client-sdk-cpp (1.10.1) headers turns up no PLI/keyframe-request API at
|
||||
/// all. So today, once that happens, the source just stays broken for the
|
||||
/// rest of the show -- "drops at random and never recovers". Toggling the
|
||||
/// subscription off and back on is the one lever this SDK exposes that
|
||||
/// forces the SFU to stop and restart delivery of the track, and a restart
|
||||
/// always begins with a keyframe. This class is the "have we gone too long
|
||||
/// without a frame, and is it still worth trying again" clock behind that
|
||||
/// lever; see LiveKitSession::Impl::recoverVideo() for where it is pulled.
|
||||
///
|
||||
/// Not thread-safe on its own, deliberately -- same contract as
|
||||
/// SessionStateMachine above: LiveKitSession::Impl owns the lock (in
|
||||
/// practice the same state_mutex that guards `machine`).
|
||||
class StallWatchdog {
|
||||
public:
|
||||
StallWatchdog(std::chrono::milliseconds timeout, std::chrono::milliseconds max_backoff);
|
||||
|
||||
/// Call whenever whether a frame could legitimately arrive right now
|
||||
/// changes: true once a video track is subscribed (and unmuted), false
|
||||
/// on detach/unsubscribe/mute/disconnect. Flipping to false always
|
||||
/// clears all timing state; flipping back to true always starts a
|
||||
/// brand-new grace period rather than measuring from a stale timestamp.
|
||||
/// That is what stops an unmute -- or an ordinary publisher swap --
|
||||
/// from firing the INSTANT it resumes, off a "last frame" that might
|
||||
/// actually be minutes old: a muted, disabled or unsubscribed track, an
|
||||
/// audio-only source, or a disconnected session must never trip this.
|
||||
void setExpectingFrames(bool expecting, std::chrono::steady_clock::time_point now);
|
||||
|
||||
/// Call every time a decoded video frame is actually delivered.
|
||||
void onFrameDelivered(std::chrono::steady_clock::time_point now);
|
||||
|
||||
/// Call periodically (finer-grained than the configured timeout).
|
||||
/// Returns true exactly when a recovery attempt should be made right
|
||||
/// now; each true also arms the backoff before the next one is even
|
||||
/// considered, so a caller polling in a tight loop still cannot fire
|
||||
/// back-to-back attempts against a publisher that never comes back --
|
||||
/// see the class comment: hammering every 2 seconds forever against a
|
||||
/// genuinely gone publisher is worse than a frozen source.
|
||||
bool poll(std::chrono::steady_clock::time_point now);
|
||||
|
||||
/// Attempts made since the current stall started (since the last frame,
|
||||
/// or since expecting-frames most recently became true). Reset by
|
||||
/// onFrameDelivered and by setExpectingFrames.
|
||||
int attemptsThisStall() const { return attempts_; }
|
||||
|
||||
private:
|
||||
std::chrono::milliseconds timeout_;
|
||||
std::chrono::milliseconds max_backoff_;
|
||||
|
||||
bool expecting_ = false;
|
||||
bool have_baseline_ = false;
|
||||
std::chrono::steady_clock::time_point baseline_{};
|
||||
std::chrono::milliseconds backoff_{};
|
||||
std::chrono::steady_clock::time_point next_attempt_allowed_{};
|
||||
int attempts_ = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frames handed to the OBS adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -177,4 +254,13 @@ using VideoFrameHandler = std::function<void(const VideoFrameData &)>;
|
||||
using AudioFrameHandler = std::function<void(const AudioFrameData &)>;
|
||||
using SessionStateHandler = std::function<void(SessionState state, const std::string &detail)>;
|
||||
|
||||
/// Severity for LiveKitSession's own diagnostic log lines (currently just
|
||||
/// the stall-recovery watchdog). Kept separate from OBS's LOG_* levels and
|
||||
/// from the SDK's own livekit::LogLevel so core/ stays free of any OBS
|
||||
/// dependency -- the adapter maps this onto obs_log the same way it already
|
||||
/// maps livekit::LogLevel (see plugin-main.cpp's livekit log bridge).
|
||||
enum class DiagnosticLevel { Info, Warning };
|
||||
|
||||
using DiagnosticHandler = std::function<void(DiagnosticLevel level, const std::string &message)>;
|
||||
|
||||
} // namespace stplugin
|
||||
|
||||
+147
-23
@@ -19,8 +19,13 @@ You may obtain a copy of the License at
|
||||
#include <windows.h>
|
||||
#include <winhttp.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace stplugin {
|
||||
@@ -72,6 +77,79 @@ private:
|
||||
HINTERNET h_ = nullptr;
|
||||
};
|
||||
|
||||
/// Hard deadline for one WinHTTP exchange, enforced by cancelling it.
|
||||
///
|
||||
/// Neither receive timeout is a guaranteed deadline: Microsoft documents both
|
||||
/// as "checked only when data is received from the socket", so an expired
|
||||
/// timeout is not surfaced until the peer finally sends something. Measured on
|
||||
/// the Windows CI runner against a server that accepts and then stalls 5s: a
|
||||
/// 700ms budget returned after 1490, 1529, 2485, 3493 and 4506ms across five
|
||||
/// attempts -- always cancelled, never on time.
|
||||
///
|
||||
/// That overshoot matters because `fetchSlots` is called synchronously on the
|
||||
/// OBS UI thread behind the properties dialog's "Refresh camera list" button
|
||||
/// (obs-adapter/src/plugin-main.cpp), with a 5s budget. At the ratio above
|
||||
/// that is a frozen dialog for half a minute.
|
||||
///
|
||||
/// The documented way to force cancellation is to close the handle from
|
||||
/// another thread; the pending call then fails with
|
||||
/// ERROR_WINHTTP_OPERATION_CANCELLED. This owns the request handle so that
|
||||
/// exactly one of the two threads ever closes it: `handle_.exchange(nullptr)`
|
||||
/// hands the close to whichever gets there first.
|
||||
///
|
||||
/// Known, accepted race: the caller may load the handle and have the watchdog
|
||||
/// close it before the WinHttp* call reads it, in which case the call fails
|
||||
/// with ERROR_INVALID_HANDLE instead. Both outcomes are "the deadline
|
||||
/// expired", which is what the caller is told either way.
|
||||
class RequestDeadline {
|
||||
public:
|
||||
RequestDeadline(HINTERNET request, DWORD after_ms) : handle_(request)
|
||||
{
|
||||
watchdog_ = std::thread([this, after_ms] {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
if (cv_.wait_for(lock, std::chrono::milliseconds(after_ms), [this] { return finished_; }))
|
||||
return; // exchange finished inside the deadline
|
||||
if (closeOnce())
|
||||
expired_.store(true);
|
||||
});
|
||||
}
|
||||
|
||||
~RequestDeadline()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
finished_ = true;
|
||||
}
|
||||
cv_.notify_all();
|
||||
if (watchdog_.joinable())
|
||||
watchdog_.join();
|
||||
closeOnce(); // no-op if the watchdog got there first
|
||||
}
|
||||
|
||||
RequestDeadline(const RequestDeadline &) = delete;
|
||||
RequestDeadline &operator=(const RequestDeadline &) = delete;
|
||||
|
||||
HINTERNET get() const { return handle_.load(); }
|
||||
bool expired() const { return expired_.load(); }
|
||||
|
||||
private:
|
||||
bool closeOnce()
|
||||
{
|
||||
HINTERNET h = handle_.exchange(nullptr);
|
||||
if (!h)
|
||||
return false;
|
||||
WinHttpCloseHandle(h);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::atomic<HINTERNET> handle_;
|
||||
std::atomic<bool> expired_{false};
|
||||
std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
bool finished_ = false;
|
||||
std::thread watchdog_;
|
||||
};
|
||||
|
||||
class WinHttpClient : public HttpClient {
|
||||
public:
|
||||
HttpResponse send(const HttpRequest &request) override
|
||||
@@ -116,6 +194,39 @@ public:
|
||||
WinHttpSetTimeouts(session.get(), static_cast<int>(timeout), static_cast<int>(timeout),
|
||||
static_cast<int>(timeout), static_cast<int>(timeout));
|
||||
|
||||
// WinHttpSetTimeouts' receive parameter maps to
|
||||
// WINHTTP_OPTION_RECEIVE_TIMEOUT, which Microsoft documents as a
|
||||
// PER-PACKET Winsock-layer read timeout ("applies to fetching each
|
||||
// packet of data off the socket"), not a deadline on the response.
|
||||
// The wait for the response HEADERS is a *separate* option,
|
||||
// WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT ("to wait to receive all
|
||||
// response headers to a request"), which WinHttpSetTimeouts does not
|
||||
// touch and which defaults to 90 SECONDS. Without this call a server
|
||||
// that accepts, reads the request and then stalls can hold this
|
||||
// thread for a minute and a half regardless of request.timeout_ms --
|
||||
// exactly the "blocking an OBS thread indefinitely" failure
|
||||
// testPlatformBackendTimeout exists to prevent, and the likely
|
||||
// mechanism behind that test's intermittent Windows failures.
|
||||
//
|
||||
// Caveat, also documented: this timeout "is checked only when data is
|
||||
// received from the socket", so it bounds the wait but does not
|
||||
// guarantee a hard deadline. A guaranteed deadline needs a watchdog
|
||||
// thread calling WinHttpCloseHandle; not done here.
|
||||
//
|
||||
// Guarded because the constant postdates some Windows SDK headers; a
|
||||
// toolchain without it keeps the previous (90s default) behaviour
|
||||
// rather than failing to build.
|
||||
#ifdef WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT
|
||||
DWORD response_timeout = timeout;
|
||||
// Return value deliberately unchecked: a rejected option leaves the
|
||||
// documented default in place, which is degraded but still correct
|
||||
// behaviour, and there is no logging sink in this layer to report it
|
||||
// to. The timeout probe in test_api_client.cpp is what would catch a
|
||||
// regression here.
|
||||
WinHttpSetOption(session.get(), WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT, &response_timeout,
|
||||
sizeof(response_timeout));
|
||||
#endif
|
||||
|
||||
Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0));
|
||||
if (!connect) {
|
||||
response.network_error = lastErrorMessage("WinHttpConnect");
|
||||
@@ -126,13 +237,36 @@ public:
|
||||
target += extra;
|
||||
|
||||
const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u;
|
||||
Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr,
|
||||
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags));
|
||||
if (!req) {
|
||||
HINTERNET raw_req = WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(),
|
||||
nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
|
||||
flags);
|
||||
if (!raw_req) {
|
||||
response.network_error = lastErrorMessage("WinHttpOpenRequest");
|
||||
return response;
|
||||
}
|
||||
|
||||
// Ceiling at twice the caller's budget: each of the four
|
||||
// WinHttpSetTimeouts phases (resolve, connect, send, receive) is
|
||||
// allowed `timeout` on its own, so a slow-but-progressing exchange can
|
||||
// legitimately exceed one budget, and this must not cancel those. The
|
||||
// floor keeps a very small timeout_ms from producing a deadline the
|
||||
// exchange cannot meet on a cold connection.
|
||||
const DWORD deadline_ms = (timeout > 500u) ? (timeout * 2u) : 1000u;
|
||||
RequestDeadline req(raw_req, deadline_ms);
|
||||
|
||||
// From here on, `req.get()` can be closed underneath us by the
|
||||
// watchdog; every WinHttp* failure below is therefore checked against
|
||||
// req.expired() before its GetLastError text is reported, so an
|
||||
// expired deadline reads as a timeout rather than as
|
||||
// "WinHttpReceiveResponse failed (GetLastError=12017)".
|
||||
const auto fail = [&](const char *what) -> HttpResponse {
|
||||
if (req.expired())
|
||||
response.network_error = "timed out after " + std::to_string(deadline_ms) + " ms";
|
||||
else
|
||||
response.network_error = lastErrorMessage(what);
|
||||
return response;
|
||||
};
|
||||
|
||||
std::wstring headers;
|
||||
if (!request.content_type.empty())
|
||||
headers = L"Content-Type: " + widen(request.content_type) + L"\r\n";
|
||||
@@ -144,31 +278,23 @@ public:
|
||||
: const_cast<char *>(request.body.data());
|
||||
const DWORD body_len = static_cast<DWORD>(request.body.size());
|
||||
|
||||
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0)) {
|
||||
response.network_error = lastErrorMessage("WinHttpSendRequest");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpReceiveResponse(req.get(), nullptr)) {
|
||||
response.network_error = lastErrorMessage("WinHttpReceiveResponse");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0))
|
||||
return fail("WinHttpSendRequest");
|
||||
if (!WinHttpReceiveResponse(req.get(), nullptr))
|
||||
return fail("WinHttpReceiveResponse");
|
||||
|
||||
DWORD status = 0;
|
||||
DWORD status_size = sizeof(status);
|
||||
if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) {
|
||||
response.network_error = lastErrorMessage("WinHttpQueryHeaders");
|
||||
return response;
|
||||
}
|
||||
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX))
|
||||
return fail("WinHttpQueryHeaders");
|
||||
response.status = static_cast<long>(status);
|
||||
|
||||
std::string body;
|
||||
for (;;) {
|
||||
DWORD available = 0;
|
||||
if (!WinHttpQueryDataAvailable(req.get(), &available)) {
|
||||
response.network_error = lastErrorMessage("WinHttpQueryDataAvailable");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpQueryDataAvailable(req.get(), &available))
|
||||
return fail("WinHttpQueryDataAvailable");
|
||||
if (available == 0)
|
||||
break;
|
||||
if (body.size() + available > kMaxResponseBytes) {
|
||||
@@ -177,10 +303,8 @@ public:
|
||||
}
|
||||
std::vector<char> chunk(available);
|
||||
DWORD read = 0;
|
||||
if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) {
|
||||
response.network_error = lastErrorMessage("WinHttpReadData");
|
||||
return response;
|
||||
}
|
||||
if (!WinHttpReadData(req.get(), chunk.data(), available, &read))
|
||||
return fail("WinHttpReadData");
|
||||
if (read == 0)
|
||||
break;
|
||||
body.append(chunk.data(), read);
|
||||
|
||||
+308
-12
@@ -17,6 +17,7 @@ You may obtain a copy of the License at
|
||||
#include <deque>
|
||||
#include <exception>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -30,6 +31,7 @@ You may obtain a copy of the License at
|
||||
#include <livekit/room_delegate.h>
|
||||
#include <livekit/room_event_types.h>
|
||||
#include <livekit/track.h>
|
||||
#include <livekit/track_publication.h>
|
||||
#include <livekit/video_frame.h>
|
||||
#include <livekit/video_stream.h>
|
||||
|
||||
@@ -138,6 +140,13 @@ int &globalRefCount()
|
||||
return n;
|
||||
}
|
||||
|
||||
// --- Stall-recovery watchdog tuning -----------------------------------------
|
||||
|
||||
/// How often the watchdog thread checks StallWatchdog's clock. Deliberately
|
||||
/// finer than kStallRecoveryTimeout (session.h) so detection latency tracks
|
||||
/// the threshold itself, not the threshold plus a whole polling period.
|
||||
constexpr std::chrono::milliseconds kWatchdogPollInterval{250};
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -145,11 +154,12 @@ int &globalRefCount()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
enum class CommandType { AttachVideo, DetachVideo, AttachAudio, DetachAudio, Stop };
|
||||
enum class CommandType { AttachVideo, DetachVideo, AttachAudio, DetachAudio, RecoverVideo, Stop };
|
||||
|
||||
struct Command {
|
||||
CommandType type;
|
||||
std::shared_ptr<livekit::Track> track;
|
||||
std::shared_ptr<livekit::RemoteTrackPublication> publication;
|
||||
};
|
||||
|
||||
livekit::Room room;
|
||||
@@ -157,10 +167,14 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
|
||||
mutable std::mutex state_mutex;
|
||||
SessionStateMachine machine;
|
||||
// Guarded by state_mutex too, same contract as `machine` -- see
|
||||
// StallWatchdog's own comment for what it decides and why it exists.
|
||||
StallWatchdog stall_watchdog{kStallRecoveryTimeout, kStallRecoveryMaxBackoff};
|
||||
|
||||
VideoFrameHandler on_video;
|
||||
AudioFrameHandler on_audio;
|
||||
SessionStateHandler on_state;
|
||||
DiagnosticHandler on_diagnostic;
|
||||
|
||||
std::atomic<std::uint64_t> video_frames{0};
|
||||
std::atomic<std::uint64_t> audio_frames{0};
|
||||
@@ -170,7 +184,10 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
// livekit::AudioStream happens on `worker`, never on a room event thread:
|
||||
// the SDK's room callbacks run on its own event thread and blocking or
|
||||
// re-entering there stalls every other event (and Room::disconnect() from
|
||||
// inside one is documented to deadlock outright).
|
||||
// inside one is documented to deadlock outright). The stall-recovery
|
||||
// watchdog thread follows the same rule: it never touches
|
||||
// RemoteTrackPublication itself, only posts CommandType::RecoverVideo
|
||||
// and lets the worker thread do it (see recoverVideo() below).
|
||||
std::mutex queue_mutex;
|
||||
std::condition_variable queue_cv;
|
||||
std::deque<Command> queue;
|
||||
@@ -183,6 +200,26 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
std::shared_ptr<livekit::AudioStream> audio_stream;
|
||||
std::thread audio_thread;
|
||||
|
||||
// The publication/track backing the CURRENT video subscription, kept
|
||||
// around purely so recoverVideo() and the mute-change handlers have
|
||||
// something to act on without reaching into `video_stream` (which is
|
||||
// worker-thread-exclusive, per the comment above). Set together in
|
||||
// attachVideo(), cleared together in detachVideo(), both on the worker
|
||||
// thread; read from the room event thread (handleMuteChange) and the
|
||||
// worker thread (recoverVideo()) under this mutex.
|
||||
std::mutex video_track_mutex;
|
||||
std::shared_ptr<livekit::Track> current_video_track;
|
||||
std::shared_ptr<livekit::RemoteTrackPublication> current_video_publication;
|
||||
|
||||
// The watchdog's own timer thread. It owns no SDK state and calls no SDK
|
||||
// method directly -- see the queue comment above. `watchdog_mutex` only
|
||||
// ever guards the shutdown flag/condvar pair, never `stall_watchdog`
|
||||
// (that is guarded by `state_mutex`, alongside `machine`).
|
||||
std::mutex watchdog_mutex;
|
||||
std::condition_variable watchdog_cv;
|
||||
bool watchdog_running = false;
|
||||
std::thread watchdog_thread;
|
||||
|
||||
bool connected = false;
|
||||
|
||||
~Impl() override = default;
|
||||
@@ -207,17 +244,110 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
handler(state, detail);
|
||||
}
|
||||
|
||||
void post(CommandType type, std::shared_ptr<livekit::Track> track = nullptr)
|
||||
void post(CommandType type, std::shared_ptr<livekit::Track> track = nullptr,
|
||||
std::shared_ptr<livekit::RemoteTrackPublication> publication = nullptr)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(queue_mutex);
|
||||
if (!worker_running)
|
||||
return;
|
||||
queue.push_back(Command{type, std::move(track)});
|
||||
queue.push_back(Command{type, std::move(track), std::move(publication)});
|
||||
}
|
||||
queue_cv.notify_one();
|
||||
}
|
||||
|
||||
void logDiagnostic(DiagnosticLevel level, const std::string &message)
|
||||
{
|
||||
DiagnosticHandler handler;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(state_mutex);
|
||||
handler = on_diagnostic;
|
||||
}
|
||||
if (handler)
|
||||
handler(level, message);
|
||||
}
|
||||
|
||||
// Handles the wanted video track once matched, shared by onTrackSubscribed
|
||||
// (a fresh subscription) and attachExistingTracks (one already up when
|
||||
// this session started watching). Two responsibilities that only make
|
||||
// sense together, both keyed off the SAME publication:
|
||||
//
|
||||
// - subscribe_video: an audio-only source (the soundboard) never wants
|
||||
// this video at all. Rather than attach it and let the OBS adapter
|
||||
// discard every decoded frame, disable the publication itself
|
||||
// (RemoteTrackPublication::setEnabled(false)) so the SFU stops
|
||||
// sending it -- real bandwidth saved, not just wasted decode.
|
||||
// - Fixed video quality: LiveKit's default subscriber behaviour lets
|
||||
// the SFU switch simulcast layers per its own adaptive/bandwidth
|
||||
// logic, which for a source with no rendered-size hint (this is a
|
||||
// native C++ subscriber, not a sized <video> element) means the
|
||||
// received resolution can hop between layers -- observed live as OBS
|
||||
// source geometry visibly changing size mid-show. Pinning to HIGH
|
||||
// asks the SFU to always send the top layer, which is what a fixed
|
||||
// OBS source needs regardless of bandwidth (the plugin has no
|
||||
// picture-in-picture tier to fall back to the way a browser grid
|
||||
// view would).
|
||||
void handleWantedVideoTrack(const std::shared_ptr<livekit::Track> &track,
|
||||
const std::shared_ptr<livekit::RemoteTrackPublication> &publication)
|
||||
{
|
||||
if (!config.subscribe_video) {
|
||||
if (publication) {
|
||||
try {
|
||||
publication->setEnabled(false);
|
||||
} catch (const std::exception &) {
|
||||
// Best-effort: worst case this track keeps being
|
||||
// delivered and decoded, wasting bandwidth -- it is
|
||||
// still never attached to OBS below.
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (publication) {
|
||||
try {
|
||||
publication->setVideoQuality(livekit::VideoQuality::HIGH);
|
||||
} catch (const std::exception &) {
|
||||
// Best-effort: worst case this track keeps whatever quality
|
||||
// it already had, which is the pre-existing behaviour.
|
||||
}
|
||||
}
|
||||
// `publication` rides along so attachVideo() can remember it: it is
|
||||
// the handle the stall-recovery watchdog later toggles
|
||||
// (setEnabled(false)/(true)) to force a fresh keyframe. See
|
||||
// recoverVideo() and StallWatchdog's comment in session_types.h.
|
||||
post(CommandType::AttachVideo, track, publication);
|
||||
}
|
||||
|
||||
// Fired for ANY track (any participant, any kind) muting or unmuting.
|
||||
// Filtered down to "is this the video publication we are currently
|
||||
// watching" by SID, which also naturally excludes every audio mute and
|
||||
// every other participant's tracks without a separate identity/kind
|
||||
// check.
|
||||
//
|
||||
// Why this exists: the stall-recovery watchdog (see StallWatchdog's
|
||||
// comment) treats "no decoded frame for kStallRecoveryTimeout" as a
|
||||
// stall worth toggling the subscription over. A publisher who
|
||||
// legitimately turned their camera off produces exactly that symptom on
|
||||
// purpose, and toggling their subscription every couple of seconds for
|
||||
// as long as they stay off would be an endless, pointless loop against
|
||||
// healthy behaviour. Muting suspends the watchdog's clock entirely;
|
||||
// unmuting starts a brand-new grace period rather than reading "muted
|
||||
// for twenty minutes" as "stalled for twenty minutes".
|
||||
void handleMuteChange(const std::shared_ptr<livekit::TrackPublication> &publication, bool unmuted)
|
||||
{
|
||||
if (!publication || publication->kind() != livekit::TrackKind::KIND_VIDEO)
|
||||
return;
|
||||
std::string current_sid;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(video_track_mutex);
|
||||
if (current_video_publication)
|
||||
current_sid = current_video_publication->sid();
|
||||
}
|
||||
if (current_sid.empty() || publication->sid() != current_sid)
|
||||
return;
|
||||
std::lock_guard<std::mutex> guard(state_mutex);
|
||||
stall_watchdog.setExpectingFrames(unmuted, std::chrono::steady_clock::now());
|
||||
}
|
||||
|
||||
// --- RoomDelegate ------------------------------------------------------
|
||||
|
||||
void onTrackSubscribed(livekit::Room &, const livekit::TrackSubscribedEvent &event) override
|
||||
@@ -230,7 +360,7 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
event.publication ? toMediaSource(event.publication->source()) : MediaSource::Unknown;
|
||||
|
||||
if (isWantedVideoTrack(config.participant_identity, identity, kind, source))
|
||||
post(CommandType::AttachVideo, event.track);
|
||||
handleWantedVideoTrack(event.track, event.publication);
|
||||
else if (config.subscribe_audio && isWantedAudioTrack(config.participant_identity, identity, kind, source))
|
||||
post(CommandType::AttachAudio, event.track);
|
||||
}
|
||||
@@ -258,6 +388,16 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
post(CommandType::DetachAudio);
|
||||
}
|
||||
|
||||
void onTrackMuted(livekit::Room &, const livekit::TrackMutedEvent &event) override
|
||||
{
|
||||
handleMuteChange(event.publication, false);
|
||||
}
|
||||
|
||||
void onTrackUnmuted(livekit::Room &, const livekit::TrackUnmutedEvent &event) override
|
||||
{
|
||||
handleMuteChange(event.publication, true);
|
||||
}
|
||||
|
||||
void onReconnecting(livekit::Room &, const livekit::ReconnectingEvent &) override
|
||||
{
|
||||
mutateState([](SessionStateMachine &m) { m.onReconnecting(); });
|
||||
@@ -313,7 +453,7 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
void workerLoop()
|
||||
{
|
||||
for (;;) {
|
||||
Command command{CommandType::Stop, nullptr};
|
||||
Command command{CommandType::Stop, nullptr, nullptr};
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
queue_cv.wait(lock, [this] { return !queue.empty(); });
|
||||
@@ -323,7 +463,7 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
|
||||
switch (command.type) {
|
||||
case CommandType::AttachVideo:
|
||||
attachVideo(command.track);
|
||||
attachVideo(command.track, command.publication);
|
||||
break;
|
||||
case CommandType::DetachVideo:
|
||||
detachVideo();
|
||||
@@ -334,6 +474,9 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
case CommandType::DetachAudio:
|
||||
detachAudio();
|
||||
break;
|
||||
case CommandType::RecoverVideo:
|
||||
recoverVideo();
|
||||
break;
|
||||
case CommandType::Stop:
|
||||
detachVideo();
|
||||
detachAudio();
|
||||
@@ -342,7 +485,103 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
void attachVideo(const std::shared_ptr<livekit::Track> &track)
|
||||
// --- stall-recovery watchdog --------------------------------------------
|
||||
//
|
||||
// See StallWatchdog's comment (session_types.h) for the measured
|
||||
// evidence and why toggling the publication is the only lever
|
||||
// available. This thread does exactly one thing: tick StallWatchdog's
|
||||
// clock and, when it says to, post CommandType::RecoverVideo so the
|
||||
// worker thread does the actual SDK call. It never touches
|
||||
// livekit::VideoStream, livekit::Room or RemoteTrackPublication itself.
|
||||
|
||||
void startWatchdog()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(watchdog_mutex);
|
||||
watchdog_running = true;
|
||||
}
|
||||
watchdog_thread = std::thread([this] { watchdogLoop(); });
|
||||
}
|
||||
|
||||
void stopWatchdog()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(watchdog_mutex);
|
||||
if (!watchdog_running)
|
||||
return;
|
||||
watchdog_running = false;
|
||||
}
|
||||
watchdog_cv.notify_all();
|
||||
if (watchdog_thread.joinable())
|
||||
watchdog_thread.join();
|
||||
}
|
||||
|
||||
void watchdogLoop()
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(watchdog_mutex);
|
||||
while (watchdog_running) {
|
||||
// kWatchdogPollInterval is finer than kStallRecoveryTimeout so
|
||||
// detection latency tracks the threshold itself rather than the
|
||||
// threshold plus a whole polling period; woken early on
|
||||
// shutdown by stopWatchdog()'s notify_all().
|
||||
watchdog_cv.wait_for(lock, kWatchdogPollInterval);
|
||||
if (!watchdog_running)
|
||||
break;
|
||||
lock.unlock();
|
||||
|
||||
bool should_fire;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(state_mutex);
|
||||
should_fire = stall_watchdog.poll(std::chrono::steady_clock::now());
|
||||
}
|
||||
if (should_fire)
|
||||
post(CommandType::RecoverVideo);
|
||||
|
||||
lock.lock();
|
||||
}
|
||||
}
|
||||
|
||||
// Actually pulls the lever: toggles the video publication off and back
|
||||
// on, which makes the SFU stop and restart delivery of that track --
|
||||
// and a restart always begins with a keyframe. Only ever called from
|
||||
// the worker thread (via CommandType::RecoverVideo), same as every
|
||||
// other RemoteTrackPublication/VideoStream call in this file.
|
||||
void recoverVideo()
|
||||
{
|
||||
std::shared_ptr<livekit::RemoteTrackPublication> publication;
|
||||
std::shared_ptr<livekit::Track> track;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(video_track_mutex);
|
||||
publication = current_video_publication;
|
||||
track = current_video_track;
|
||||
}
|
||||
// Detached, replaced, or muted between the watchdog deciding to
|
||||
// fire and the worker getting to this command -- nothing to do, and
|
||||
// silently: this is the expected shape of the race, not a failure
|
||||
// worth logging.
|
||||
if (!publication || !track || track->muted())
|
||||
return;
|
||||
|
||||
int attempt = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(state_mutex);
|
||||
attempt = stall_watchdog.attemptsThisStall();
|
||||
}
|
||||
|
||||
logDiagnostic(DiagnosticLevel::Warning,
|
||||
"no decoded video frame for >= " + std::to_string(kStallRecoveryTimeout.count()) +
|
||||
"ms; toggling the subscription to force a fresh keyframe (attempt " +
|
||||
std::to_string(attempt) + ")");
|
||||
try {
|
||||
publication->setEnabled(false);
|
||||
publication->setEnabled(true);
|
||||
} catch (const std::exception &e) {
|
||||
logDiagnostic(DiagnosticLevel::Warning, std::string("stall-recovery toggle failed: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void attachVideo(const std::shared_ptr<livekit::Track> &track,
|
||||
const std::shared_ptr<livekit::RemoteTrackPublication> &publication)
|
||||
{
|
||||
if (!track)
|
||||
return;
|
||||
@@ -367,6 +606,18 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
if (!stream)
|
||||
return;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(video_track_mutex);
|
||||
current_video_track = track;
|
||||
current_video_publication = publication;
|
||||
}
|
||||
{
|
||||
// A fresh subscription (or a publisher swap) starts a brand-new
|
||||
// grace period -- see StallWatchdog::setExpectingFrames.
|
||||
std::lock_guard<std::mutex> guard(state_mutex);
|
||||
stall_watchdog.setExpectingFrames(true, std::chrono::steady_clock::now());
|
||||
}
|
||||
|
||||
video_stream = stream;
|
||||
video_thread = std::thread([this, stream] { videoReaderLoop(stream); });
|
||||
mutateState([](SessionStateMachine &m) { m.onVideoAttached(); });
|
||||
@@ -380,6 +631,20 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
video_thread.join();
|
||||
const bool had = static_cast<bool>(video_stream);
|
||||
video_stream.reset();
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(video_track_mutex);
|
||||
current_video_track.reset();
|
||||
current_video_publication.reset();
|
||||
}
|
||||
{
|
||||
// Nothing subscribed means nothing expected -- see
|
||||
// StallWatchdog::setExpectingFrames. Unconditional, not gated on
|
||||
// `had`: this also covers the detachVideo() at the top of
|
||||
// attachVideo() above, which is exactly the publisher-swap
|
||||
// moment the grace period needs to restart from.
|
||||
std::lock_guard<std::mutex> guard(state_mutex);
|
||||
stall_watchdog.setExpectingFrames(false, std::chrono::steady_clock::now());
|
||||
}
|
||||
if (had)
|
||||
mutateState([](SessionStateMachine &m) { m.onVideoDetached(); });
|
||||
}
|
||||
@@ -502,6 +767,23 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
out.plane_count = count;
|
||||
|
||||
video_frames.fetch_add(1);
|
||||
|
||||
// Tell the stall-recovery watchdog a frame actually made it all the
|
||||
// way to "about to hand to OBS" -- not merely that the SDK's queue
|
||||
// produced an event, which the drop paths above also see. See
|
||||
// StallWatchdog's comment for why this exists.
|
||||
int attempts_before_this_frame = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(state_mutex);
|
||||
attempts_before_this_frame = stall_watchdog.attemptsThisStall();
|
||||
stall_watchdog.onFrameDelivered(std::chrono::steady_clock::now());
|
||||
}
|
||||
if (attempts_before_this_frame > 0) {
|
||||
logDiagnostic(DiagnosticLevel::Info, "video resumed after " +
|
||||
std::to_string(attempts_before_this_frame) +
|
||||
" stall-recovery attempt(s)");
|
||||
}
|
||||
|
||||
handler(out);
|
||||
}
|
||||
|
||||
@@ -551,7 +833,7 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
|
||||
const MediaKind kind = toMediaKind(track->kind());
|
||||
const MediaSource source = toMediaSource(publication->source());
|
||||
if (isWantedVideoTrack(config.participant_identity, identity, kind, source))
|
||||
post(CommandType::AttachVideo, track);
|
||||
handleWantedVideoTrack(track, publication);
|
||||
else if (config.subscribe_audio && isWantedAudioTrack(config.participant_identity, identity, kind, source))
|
||||
post(CommandType::AttachAudio, track);
|
||||
}
|
||||
@@ -587,6 +869,12 @@ void LiveKitSession::setStateHandler(SessionStateHandler handler)
|
||||
impl_->on_state = std::move(handler);
|
||||
}
|
||||
|
||||
void LiveKitSession::setDiagnosticHandler(DiagnosticHandler handler)
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(impl_->state_mutex);
|
||||
impl_->on_diagnostic = std::move(handler);
|
||||
}
|
||||
|
||||
bool LiveKitSession::connect(const SessionConfig &config)
|
||||
{
|
||||
if (impl_->connected)
|
||||
@@ -606,6 +894,10 @@ bool LiveKitSession::connect(const SessionConfig &config)
|
||||
}
|
||||
|
||||
impl_->startWorker();
|
||||
// Runs for the lifetime of the worker: connected-but-nothing-subscribed
|
||||
// is a no-op for StallWatchdog (see setExpectingFrames), so there is no
|
||||
// reason to start/stop it separately from the worker it posts to.
|
||||
impl_->startWatchdog();
|
||||
|
||||
livekit::RoomOptions options;
|
||||
// auto_subscribe is what makes track_subscribed events (and therefore any
|
||||
@@ -634,6 +926,7 @@ bool LiveKitSession::connect(const SessionConfig &config)
|
||||
} catch (const std::exception &e) {
|
||||
ok = false;
|
||||
impl_->mutateState([&](SessionStateMachine &m) { m.onConnectFailed(e.what()); });
|
||||
impl_->stopWatchdog();
|
||||
impl_->stopWorker();
|
||||
impl_->room.setDelegate(nullptr);
|
||||
return false;
|
||||
@@ -643,6 +936,7 @@ bool LiveKitSession::connect(const SessionConfig &config)
|
||||
impl_->mutateState([](SessionStateMachine &m) {
|
||||
m.onConnectFailed("could not connect to LiveKit (check the server URL, or the token may have expired)");
|
||||
});
|
||||
impl_->stopWatchdog();
|
||||
impl_->stopWorker();
|
||||
impl_->room.setDelegate(nullptr);
|
||||
return false;
|
||||
@@ -659,9 +953,11 @@ void LiveKitSession::disconnect()
|
||||
if (!impl_)
|
||||
return;
|
||||
|
||||
// Order matters: stop the readers first so nothing is mid-read on a
|
||||
// stream the room is about to tear down, then disconnect the room, then
|
||||
// drop the delegate so no event can arrive at a half-destroyed object.
|
||||
// Order matters: stop the watchdog and the readers first so nothing is
|
||||
// mid-read (or about to post a recovery command) on a stream the room
|
||||
// is about to tear down, then disconnect the room, then drop the
|
||||
// delegate so no event can arrive at a half-destroyed object.
|
||||
impl_->stopWatchdog();
|
||||
impl_->stopWorker();
|
||||
|
||||
if (impl_->connected) {
|
||||
|
||||
@@ -175,4 +175,89 @@ void SessionStateMachine::onAudioDetached()
|
||||
has_audio_ = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StallWatchdog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
StallWatchdog::StallWatchdog(std::chrono::milliseconds timeout, std::chrono::milliseconds max_backoff)
|
||||
: timeout_(timeout), max_backoff_(max_backoff), backoff_(timeout)
|
||||
{
|
||||
}
|
||||
|
||||
void StallWatchdog::setExpectingFrames(bool expecting, std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
expecting_ = expecting;
|
||||
// Always re-baseline from `now`, whichever direction this flips.
|
||||
// Losing the baseline (rather than, say, keeping the old one around for
|
||||
// when expecting_ next becomes true) is what stops a track that was
|
||||
// muted for the last twenty minutes from reading as "twenty minutes
|
||||
// stalled" the instant it unmutes.
|
||||
have_baseline_ = expecting;
|
||||
baseline_ = now;
|
||||
backoff_ = timeout_;
|
||||
next_attempt_allowed_ = now;
|
||||
attempts_ = 0;
|
||||
}
|
||||
|
||||
void StallWatchdog::onFrameDelivered(std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
have_baseline_ = true;
|
||||
baseline_ = now;
|
||||
backoff_ = timeout_;
|
||||
// A recovered stream must be able to fire again the moment a FRESH
|
||||
// stall clears the (now-reset) timeout, not sit throttled by whatever
|
||||
// backoff a previous, unrelated stall had climbed to -- next_attempt_
|
||||
// allowed_ belongs to that old stall and is meaningless once frames are
|
||||
// flowing again.
|
||||
next_attempt_allowed_ = now;
|
||||
attempts_ = 0;
|
||||
}
|
||||
|
||||
bool StallWatchdog::poll(std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
if (!expecting_ || !have_baseline_)
|
||||
return false;
|
||||
if (now - baseline_ < timeout_)
|
||||
return false;
|
||||
if (now < next_attempt_allowed_)
|
||||
return false;
|
||||
|
||||
++attempts_;
|
||||
// Next attempt against this SAME stall is not allowed until the backoff
|
||||
// elapses, and the backoff itself doubles (capped) each time -- 2s, 4s,
|
||||
// 8s, ... up to max_backoff_ -- so a publisher that is genuinely gone
|
||||
// gets progressively less frequent toggles instead of one every 2
|
||||
// seconds for the rest of the show.
|
||||
//
|
||||
// max_backoff_ is clamped HERE, where the wait is used, and not only
|
||||
// where the backoff is grown. It is a promise about the longest gap
|
||||
// between two recovery attempts, so it is enforced on the gap itself;
|
||||
// that way the promise holds for whatever backoff_ happens to contain,
|
||||
// rather than depending on every earlier growth step having clamped
|
||||
// correctly.
|
||||
//
|
||||
// CORRECTION: an earlier version of this comment blamed a Windows
|
||||
// release build for letting the ceiling engage one attempt late. That
|
||||
// was wrong, and it is worth recording why rather than quietly
|
||||
// deleting it. Windows CI was failing, two successive diagnoses blamed
|
||||
// this arithmetic, and neither fixed anything -- the second produced a
|
||||
// byte-identical failure. Instrumenting the actual test on the Windows
|
||||
// runner showed the watchdog was innocent on all three platforms: the
|
||||
// TEST's loop was miscompiled (see core/tests/test_session.cpp). This
|
||||
// clamp-at-use is kept on its own merit as defence in depth, not
|
||||
// because any platform ever got the ceiling wrong.
|
||||
const std::chrono::milliseconds wait = backoff_ < max_backoff_ ? backoff_ : max_backoff_;
|
||||
next_attempt_allowed_ = now + wait;
|
||||
// Double-and-clamp as plain value arithmetic on a single type. This was
|
||||
// std::min(backoff_ * 2, max_backoff_), which returns a *reference* --
|
||||
// bound, in the growing case, to the materialized `backoff_ * 2`
|
||||
// temporary. That was the only expression in this function that was not
|
||||
// a plain integer computation, and it is the one the Windows release
|
||||
// build disagreed with the other two platforms about. Comparing before
|
||||
// doubling also means the product is computed only when it cannot
|
||||
// exceed max_backoff_, so no intermediate can overflow.
|
||||
backoff_ = (wait > max_backoff_ / 2) ? max_backoff_ : wait * 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace stplugin
|
||||
|
||||
@@ -16,6 +16,7 @@ You may obtain a copy of the License at
|
||||
// three runners rather than assumed to work.
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
@@ -422,22 +423,77 @@ void testPlatformBackendTimeout()
|
||||
{
|
||||
// A server that accepts and then stalls. The plugin must give up on its
|
||||
// own timeout rather than blocking an OBS thread indefinitely.
|
||||
sttest::LoopbackServer server([](const std::string &) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(5));
|
||||
return sttest::httpResponse(200, "OK", R"({"slots":[]})");
|
||||
});
|
||||
ST_ASSERT(server.valid());
|
||||
//
|
||||
// INSTRUMENTED (2026-09-09) while chasing an intermittent Windows-only
|
||||
// failure: on roughly 2 of 6 CI runs both assertions below fail together,
|
||||
// meaning the request waited out the full 5s stall and returned 200 --
|
||||
// the timeout did not fire at all. Same failure seen on 2026-09-07
|
||||
// (job 5834) and 2026-09-09 (job 5911), on identical code that passed on
|
||||
// other runs, so it is not a code change that caused it.
|
||||
//
|
||||
// The probe runs kProbes times and prints one line per attempt so a
|
||||
// single CI run yields a failure RATE and the WinHTTP error code, rather
|
||||
// than one bit. `ST_ASSERT` records and continues, so every attempt is
|
||||
// reported even when one fails. Remove the loop and this comment once the
|
||||
// mechanism is understood and fixed.
|
||||
constexpr int kProbes = 5;
|
||||
constexpr long long kStallMs = 5000;
|
||||
constexpr long kTimeoutMs = 700;
|
||||
|
||||
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
|
||||
HttpRequest request;
|
||||
request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k";
|
||||
request.timeout_ms = 700;
|
||||
int timed_out = 0;
|
||||
for (int i = 0; i < kProbes; ++i) {
|
||||
// A FRESH server per attempt, deliberately. `LoopbackServer` accepts
|
||||
// and handles one connection at a time on a single thread, so reusing
|
||||
// one server across attempts would leave attempts 2..n sitting in the
|
||||
// accept backlog -- a different scenario (never accepted) from the one
|
||||
// that fails on Windows (accepted, request read, then stalled).
|
||||
sttest::LoopbackServer server([kStallMs](const std::string &) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kStallMs));
|
||||
return sttest::httpResponse(200, "OK", R"({"slots":[]})");
|
||||
});
|
||||
ST_ASSERT(server.valid());
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const HttpResponse response = http->send(request);
|
||||
const auto elapsed = std::chrono::steady_clock::now() - start;
|
||||
ST_ASSERT(!response.ok());
|
||||
ST_ASSERT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() < 4000);
|
||||
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
|
||||
HttpRequest request;
|
||||
request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k";
|
||||
request.timeout_ms = kTimeoutMs;
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const HttpResponse response = http->send(request);
|
||||
const auto elapsed = std::chrono::steady_clock::now() - start;
|
||||
const long long ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
|
||||
|
||||
// 4000 was the old bound, chosen when nothing bounded the wait. The
|
||||
// code now promises a hard ceiling of 2x the caller's budget
|
||||
// (RequestDeadline in http_winhttp.cpp), so assert THAT -- 1400ms
|
||||
// here, plus slack for a loaded runner. This is also the only signal
|
||||
// that survives a green run: CTest prints nothing on success, so if
|
||||
// WinHTTP's own erratic cancellation (measured at 1490-4506ms for
|
||||
// this same 700ms budget) were doing the work instead of the
|
||||
// watchdog, roughly half the attempts would land above this bound and
|
||||
// say so, instead of quietly passing under a 4s ceiling.
|
||||
constexpr long long kCeilingMs = 2500;
|
||||
const bool gave_up = !response.ok() && ms < kCeilingMs;
|
||||
if (gave_up)
|
||||
++timed_out;
|
||||
|
||||
// Always printed, pass or fail: elapsed time and the backend's own
|
||||
// error string (which carries GetLastError on Windows) are the
|
||||
// evidence. requests_seen separates "the client never reached the
|
||||
// server" (0) from "the server read the request and the client then
|
||||
// waited it out" (1).
|
||||
std::fprintf(stderr,
|
||||
" [timeout-probe %d/%d] elapsed=%lldms ok=%d status=%ld "
|
||||
"requests_seen=%d network_error='%s' -> %s\n",
|
||||
i + 1, kProbes, ms, response.ok() ? 1 : 0, response.status,
|
||||
server.requestCount(), response.network_error.c_str(),
|
||||
gave_up ? "gave up (expected)" : "WAITED OUT THE STALL");
|
||||
|
||||
ST_ASSERT(!response.ok());
|
||||
ST_ASSERT(ms < kCeilingMs);
|
||||
}
|
||||
std::fprintf(stderr, " [timeout-probe] %d/%d attempts honoured the %ldms timeout\n",
|
||||
timed_out, kProbes, kTimeoutMs);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
+204
-2
@@ -14,8 +14,10 @@ You may obtain a copy of the License at
|
||||
// COVERED headlessly -- the session's own decision-making: the state
|
||||
// machine's transitions (including the publisher-swap and reconnect paths
|
||||
// that motivated this plugin), track selection, frame geometry validation,
|
||||
// and the real connect() failure paths against the real SDK (bad URL,
|
||||
// unreachable host, garbage token).
|
||||
// the stall-recovery watchdog's timing/backoff decisions (StallWatchdog,
|
||||
// driven with a fake clock -- see its own section below), and the real
|
||||
// connect() failure paths against the real SDK (bad URL, unreachable
|
||||
// host, garbage token).
|
||||
//
|
||||
// NOT COVERED here -- anything that needs a LiveKit server to answer:
|
||||
// a successful connect, actual subscription, and actual decoded frames
|
||||
@@ -209,6 +211,201 @@ void testFailureAndRecovery()
|
||||
ST_ASSERT(idle.state() == SessionState::Idle);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StallWatchdog -- the stall-recovery watchdog's pure timing/decision logic.
|
||||
//
|
||||
// This is deliberately driven with an explicit, fake clock (arbitrary
|
||||
// steady_clock::time_points built by hand, never std::chrono::...::now())
|
||||
// rather than real sleeps: every case below needs to be exact about
|
||||
// "1999ms in" vs "2001ms in" and about backoff boundaries, and a test that
|
||||
// actually slept for 30+ seconds to exercise the backoff ceiling would be
|
||||
// exactly the kind of slow, flaky test this project's whole headless-test
|
||||
// philosophy exists to avoid. See StallWatchdog's own comment
|
||||
// (session_types.h) for the measured server evidence this exists to fix.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void testStallWatchdogFiresAfterThreshold()
|
||||
{
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
StallWatchdog w(std::chrono::milliseconds(2000), std::chrono::milliseconds(30000));
|
||||
|
||||
// Nothing subscribed yet: polling is a no-op, no matter how much time
|
||||
// has "passed" -- an audio-only source or a disconnected session must
|
||||
// never fire.
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(10000)));
|
||||
|
||||
// A track becomes subscribed. Still well under the threshold: quiet.
|
||||
w.setExpectingFrames(true, t0);
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(500)));
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(1999)));
|
||||
|
||||
// No frame ever arrived, and the threshold has now elapsed: fires
|
||||
// exactly once when asked right at/after the boundary.
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(2000)));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), 1);
|
||||
|
||||
// A frame arriving resets the clock -- the far more common case in a
|
||||
// healthy stream, where onFrameDelivered() is called every ~33ms and
|
||||
// poll() (every kWatchdogPollInterval) never sees 2000ms of silence.
|
||||
StallWatchdog healthy(std::chrono::milliseconds(2000), std::chrono::milliseconds(30000));
|
||||
healthy.setExpectingFrames(true, t0);
|
||||
for (int ms = 0; ms <= 5000; ms += 33)
|
||||
healthy.onFrameDelivered(t0 + std::chrono::milliseconds(ms));
|
||||
ST_ASSERT(!healthy.poll(t0 + std::chrono::milliseconds(5010)));
|
||||
ST_ASSERT_EQ(healthy.attemptsThisStall(), 0);
|
||||
}
|
||||
|
||||
void testStallWatchdogDoesNotFireWhenNotExpectingFrames()
|
||||
{
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
StallWatchdog w(std::chrono::milliseconds(2000), std::chrono::milliseconds(30000));
|
||||
|
||||
// A muted (or disabled/unsubscribed) track is expected silence, not a
|
||||
// stall -- setExpectingFrames(false, ...) is exactly what
|
||||
// LiveKitSession::Impl::handleMuteChange (and detachVideo()) call in
|
||||
// that case. It must not fire no matter how long it stays that way.
|
||||
w.setExpectingFrames(false, t0);
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(2000)));
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(60000)));
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(600000)));
|
||||
|
||||
// Un-muting (setExpectingFrames(true, ...)) starts a BRAND NEW grace
|
||||
// period from that moment -- it must not read "was silent for ten
|
||||
// minutes" as "stalled for ten minutes" and fire immediately.
|
||||
const auto unmuted_at = t0 + std::chrono::milliseconds(600000);
|
||||
w.setExpectingFrames(true, unmuted_at);
|
||||
ST_ASSERT(!w.poll(unmuted_at + std::chrono::milliseconds(1999)));
|
||||
ST_ASSERT(w.poll(unmuted_at + std::chrono::milliseconds(2000)));
|
||||
}
|
||||
|
||||
// Every time point below is derived ABSOLUTELY from t0 -- `t0 +
|
||||
// milliseconds(at_ms)`, with the cursor kept as a plain integer -- rather
|
||||
// than by accumulating into a steady_clock::time_point local
|
||||
// (`now += milliseconds(30000)`). That is not a style preference; it is
|
||||
// load-bearing on Windows.
|
||||
//
|
||||
// The MSVC 19.44 (VS 2022 BuildTools 14.44.35207) x64 Release build
|
||||
// miscompiles the accumulate-then-pass shape inside a fixed-stride loop:
|
||||
//
|
||||
// for (int i = 0; i < 6; ++i) {
|
||||
// ST_ASSERT(!w.poll(now + milliseconds(29999)));
|
||||
// now += milliseconds(30000);
|
||||
// ST_ASSERT(w.poll(now)); // <-- gets a STALE `now`
|
||||
// }
|
||||
//
|
||||
// Measured in CI, with the value captured on the callee side of a
|
||||
// __declspec(noinline) wrapper so it is what actually crossed the call
|
||||
// boundary: all six iterations passed t0+32000ms -- the value `now` held
|
||||
// BEFORE the first `+=` -- while the caller's own `now` was correct
|
||||
// (a checksum of the arguments in the same loop summed to exactly
|
||||
// 62000+92000+...+212000). The argument was hoisted out of the loop as if
|
||||
// it were loop-invariant. Linux and macOS pass 62000, 92000, ... 212000 for
|
||||
// the same source.
|
||||
//
|
||||
// The watchdog itself is not implicated: in the same Windows binary, the
|
||||
// same StallWatchdog, in the same loop, fed the same instants written as
|
||||
// `t0 + milliseconds(at_ms)` (or even just via a named copy of `now`)
|
||||
// answers correctly on every iteration. Production is not exposed either --
|
||||
// LiveKitSession::Impl::watchdogLoop() calls
|
||||
// stall_watchdog.poll(std::chrono::steady_clock::now()) with a fresh clock
|
||||
// read per tick, not a loop-carried local advanced by a constant.
|
||||
//
|
||||
// No assertion below is weaker than before: every gap is still checked one
|
||||
// millisecond on either side of its boundary.
|
||||
void testStallWatchdogBacksOffRatherThanLooping()
|
||||
{
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
StallWatchdog w(std::chrono::milliseconds(2000), std::chrono::milliseconds(30000));
|
||||
w.setExpectingFrames(true, t0);
|
||||
|
||||
// Milliseconds since t0. A plain integer cursor, advanced explicitly.
|
||||
long long at_ms = 2000;
|
||||
|
||||
// First attempt at the threshold.
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms)));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), 1);
|
||||
|
||||
// A genuinely gone publisher: no frame ever comes back. Immediately
|
||||
// asking again (the naive "retry every poll interval forever" a
|
||||
// watchdog without backoff would do) must NOT fire -- that is precisely
|
||||
// the "hammered every 2 seconds forever" this backoff exists to avoid.
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(at_ms + 250)));
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(at_ms + 1999)));
|
||||
|
||||
// The backoff after attempt 1 is the base timeout (2000ms): the second
|
||||
// attempt is allowed at +2000ms from the first, not before.
|
||||
at_ms += 2000;
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms)));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), 2);
|
||||
|
||||
// Backoff doubles: the third attempt needs a 4000ms gap, not 2000ms.
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(at_ms + 3999)));
|
||||
at_ms += 4000;
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms)));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), 3);
|
||||
|
||||
// ... and again to 8000ms, and again to 16000ms.
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(at_ms + 7999)));
|
||||
at_ms += 8000;
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms)));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), 4);
|
||||
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(at_ms + 15999)));
|
||||
at_ms += 16000;
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms)));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), 5);
|
||||
|
||||
// The backoff is capped: doubling 16000ms would be 32000ms, but it
|
||||
// never exceeds max_backoff (30000ms) no matter how many attempts have
|
||||
// failed, so a publisher that comes back after an hour is still
|
||||
// retried at a bounded cadence, not abandoned.
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(at_ms + 29999)));
|
||||
at_ms += 30000;
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms)));
|
||||
}
|
||||
|
||||
// A frame finally arrives: the stall is over, and the NEXT one (a fresh
|
||||
// stall, not a continuation) starts back at the base cadence rather
|
||||
// than staying parked at the 30s ceiling forever.
|
||||
w.onFrameDelivered(t0 + std::chrono::milliseconds(at_ms));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), 0);
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(at_ms + 1999)));
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms + 2000)));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), 1);
|
||||
}
|
||||
|
||||
// The ceiling has to engage on the FIRST attempt whose doubled backoff would
|
||||
// exceed it, not one attempt later -- a Windows release build got exactly
|
||||
// that step wrong (it waited 32s once before settling at the 30s ceiling),
|
||||
// which is why StallWatchdog::poll() clamps the wait where it is used rather
|
||||
// than trusting every growth step. A cap that is NOT a power-of-two multiple
|
||||
// of the timeout pins the clamp itself: 1000 -> 2000 -> 4000 -> 5000 (not
|
||||
// 8000, and not 4000 again), and 5000 forever after.
|
||||
void testStallWatchdogNeverWaitsLongerThanTheCeiling()
|
||||
{
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
StallWatchdog w(std::chrono::milliseconds(1000), std::chrono::milliseconds(5000));
|
||||
w.setExpectingFrames(true, t0);
|
||||
|
||||
// Absolute instants off t0, for the reason spelled out above
|
||||
// testStallWatchdogBacksOffRatherThanLooping().
|
||||
long long at_ms = 1000;
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms)));
|
||||
|
||||
// Expected gaps between consecutive attempts: 1000, 2000, 4000, then the
|
||||
// ceiling for good. Each gap is checked on both sides of its boundary, so
|
||||
// a gap that is even one millisecond too long or too short fails here.
|
||||
const int expected_gaps[] = {1000, 2000, 4000, 5000, 5000, 5000, 5000};
|
||||
int attempt = 1;
|
||||
for (int gap : expected_gaps) {
|
||||
ST_ASSERT(!w.poll(t0 + std::chrono::milliseconds(at_ms + gap - 1)));
|
||||
at_ms += gap;
|
||||
ST_ASSERT(w.poll(t0 + std::chrono::milliseconds(at_ms)));
|
||||
ST_ASSERT_EQ(w.attemptsThisStall(), ++attempt);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real SDK, failure paths only (no LiveKit server available headlessly)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -335,6 +532,11 @@ int main()
|
||||
testReconnect();
|
||||
testFailureAndRecovery();
|
||||
|
||||
testStallWatchdogFiresAfterThreshold();
|
||||
testStallWatchdogDoesNotFireWhenNotExpectingFrames();
|
||||
testStallWatchdogBacksOffRatherThanLooping();
|
||||
testStallWatchdogNeverWaitsLongerThanTheCeiling();
|
||||
|
||||
LiveKitSession::globalInitialize();
|
||||
testConnectRejectsIncompleteConfig();
|
||||
testConnectToUnreachableServerFailsCleanly();
|
||||
|
||||
@@ -4,6 +4,7 @@ RoomSlug="Room"
|
||||
ReadKey="Read key"
|
||||
Camera="Camera"
|
||||
RefreshCameras="Refresh camera list"
|
||||
AudioOnly="Audio only (no video)"
|
||||
Status="Status"
|
||||
NoCameraSelected="(no camera selected)"
|
||||
OfflineSuffix=" (offline)"
|
||||
|
||||
@@ -57,6 +57,7 @@ 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 *kSettingAudioOnly = "audio_only";
|
||||
constexpr const char *kSettingStatus = "status";
|
||||
constexpr const char *kPropRefresh = "refresh";
|
||||
|
||||
@@ -96,6 +97,10 @@ struct CameraSource {
|
||||
std::mutex mutex;
|
||||
ConnectionConfig config;
|
||||
std::string camera_identity;
|
||||
/// True hides video entirely for this source (the soundboard, typically)
|
||||
/// -- see SessionConfig::subscribe_video for what that actually does at
|
||||
/// the LiveKit level.
|
||||
bool audio_only = false;
|
||||
/// 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.
|
||||
@@ -219,6 +224,7 @@ void workerLoop(CameraSource *self)
|
||||
for (;;) {
|
||||
ConnectionConfig config;
|
||||
std::string camera;
|
||||
bool audio_only = false;
|
||||
std::uint64_t generation = 0;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(self->mutex);
|
||||
@@ -226,6 +232,7 @@ void workerLoop(CameraSource *self)
|
||||
break;
|
||||
config = self->config;
|
||||
camera = self->camera_identity;
|
||||
audio_only = self->audio_only;
|
||||
generation = self->generation;
|
||||
}
|
||||
|
||||
@@ -282,6 +289,7 @@ void workerLoop(CameraSource *self)
|
||||
session_config.ws_url = token.ws_url;
|
||||
session_config.token = token.lk_token;
|
||||
session_config.participant_identity = camera;
|
||||
session_config.subscribe_video = !audio_only;
|
||||
|
||||
if (self->session->connect(session_config)) {
|
||||
connected = true;
|
||||
@@ -330,6 +338,7 @@ void sourceGetDefaults(obs_data_t *settings)
|
||||
obs_data_set_default_string(settings, kSettingRoomSlug, "");
|
||||
obs_data_set_default_string(settings, kSettingReadKey, "");
|
||||
obs_data_set_default_string(settings, kSettingCamera, "");
|
||||
obs_data_set_default_bool(settings, kSettingAudioOnly, false);
|
||||
}
|
||||
|
||||
void applySettings(CameraSource *self, obs_data_t *settings)
|
||||
@@ -339,16 +348,19 @@ void applySettings(CameraSource *self, obs_data_t *settings)
|
||||
config.room_slug = settingString(settings, kSettingRoomSlug);
|
||||
config.read_key = settingString(settings, kSettingReadKey);
|
||||
const std::string camera = settingString(settings, kSettingCamera);
|
||||
const bool audio_only = obs_data_get_bool(settings, kSettingAudioOnly);
|
||||
|
||||
{
|
||||
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;
|
||||
config.read_key != self->config.read_key || camera != self->camera_identity ||
|
||||
audio_only != self->audio_only;
|
||||
if (!changed)
|
||||
return;
|
||||
self->config = config;
|
||||
self->camera_identity = camera;
|
||||
self->audio_only = audio_only;
|
||||
++self->generation;
|
||||
}
|
||||
self->wake.notify_all();
|
||||
@@ -363,6 +375,13 @@ void *sourceCreate(obs_data_t *settings, obs_source_t *source)
|
||||
|
||||
self->session->setVideoHandler([self](const VideoFrameData &frame) { outputVideoFrame(self, frame); });
|
||||
self->session->setAudioHandler([self](const AudioFrameData &frame) { outputAudioFrame(self, frame); });
|
||||
// The stall-recovery watchdog (core/src/session.cpp) is the only thing
|
||||
// that currently uses this: it logs each toggle-the-subscription
|
||||
// recovery attempt, and its eventual success, so a stalled-and-fixed
|
||||
// camera is diagnosable from an OBS log afterward instead of invisible.
|
||||
self->session->setDiagnosticHandler([](DiagnosticLevel level, const std::string &message) {
|
||||
obs_log(level == DiagnosticLevel::Warning ? LOG_WARNING : LOG_INFO, "%s", message.c_str());
|
||||
});
|
||||
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);
|
||||
@@ -385,6 +404,7 @@ void *sourceCreate(obs_data_t *settings, obs_source_t *source)
|
||||
self->config.room_slug = settingString(settings, kSettingRoomSlug);
|
||||
self->config.read_key = settingString(settings, kSettingReadKey);
|
||||
self->camera_identity = settingString(settings, kSettingCamera);
|
||||
self->audio_only = obs_data_get_bool(settings, kSettingAudioOnly);
|
||||
self->generation = 1;
|
||||
}
|
||||
|
||||
@@ -527,6 +547,14 @@ obs_properties_t *sourceGetProperties(void *data)
|
||||
|
||||
obs_properties_add_button(props, kPropRefresh, obs_module_text("RefreshCameras"), refreshButtonClicked);
|
||||
|
||||
// For a picked slot with no visual content worth showing (the
|
||||
// soundboard, which publishes a throwaway black keep-alive frame purely
|
||||
// because RTMP egress needs a video track -- see Soundboard.tsx in the
|
||||
// streamer-tools repo). Disables the video track at the LiveKit level
|
||||
// (RemoteTrackPublication::setEnabled(false), see session.cpp), not just
|
||||
// locally: the SFU stops sending it.
|
||||
obs_properties_add_bool(props, kSettingAudioOnly, obs_module_text("AudioOnly"));
|
||||
|
||||
// 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.
|
||||
|
||||
Vendored
-25
@@ -10,28 +10,3 @@ module — so the SDK's licence and notice files ship with it.
|
||||
by `obs-adapter/CMakeLists.txt` on every build, alongside this plugin's own
|
||||
Apache-2.0 `LICENSE` (this project's own first-party code was relicensed from
|
||||
GPL-2.0 to Apache-2.0 to match).
|
||||
|
||||
## 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