Author SHA1 Message Date
shadowdaoandClaude Opus 5 17540c75b0 test: assert the 2x-budget ceiling the watchdog now guarantees, not 4s
Build / macOS (macos-latest) (push) Successful in 53s
Build / Linux (ubuntu-24.04) (push) Successful in 1m23s
Build / macOS (macos-latest) (pull_request) Successful in 46s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 1m19s
Build / Windows (windows-latest) (push) Successful in 3m57s
Build / Windows (windows-latest) (pull_request) Successful in 3m59s
The Windows job went green, but CTest prints test output only on failure, so a
pass says nothing about WHAT cancelled the request. Under the old 4000ms bound
a pass is ambiguous: the watchdog firing at ~1400ms and WinHTTP's own erratic
cancellation (measured at 1490-4506ms for this same 700ms budget) both fit
under it.

So assert the guarantee the code actually makes now -- a hard ceiling of twice
the caller's budget -- at 2500ms, which is 1400ms plus slack for a loaded
runner. If the watchdog stops doing the work, roughly half the attempts land
above this and print their elapsed time and error string, instead of quietly
passing.

Linux is unaffected: curl honours the 700ms budget exactly, 5/5 at ~701ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 18:43:32 -07:00
shadowdaoandClaude Opus 5 b23644fa3e fix(http): enforce a hard deadline on WinHTTP by cancelling the request
Build / macOS (macos-latest) (push) Successful in 51s
Build / Linux (ubuntu-24.04) (push) Successful in 1m14s
Build / macOS (macos-latest) (pull_request) Successful in 48s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 1m8s
Build / Windows (windows-latest) (push) Successful in 3m56s
Build / Windows (windows-latest) (pull_request) Successful in 3m56s
Setting WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT (previous commit) fixed the
original failure -- the request is now always cancelled instead of waiting out
a stall and returning 200 -- but the instrumented CI probe shows it is not
cancelled on TIME. Five attempts with a 700ms budget against a server that
accepts and then stalls 5s returned after 1490, 1529, 2485, 3493 and 4506ms,
every one of them ERROR_WINHTTP_TIMEOUT (12002). One exceeded the test's 4s
bound, which is why Windows CI was still red.

That is the documented behaviour, not a mystery: both receive timeouts are
"checked only when data is received from the socket", so an expired timeout is
not surfaced until the peer sends something. Neither option is a deadline.

It 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:486, kPropertiesTimeoutMs = 5000). At the
overshoot ratio measured above, a stalling server freezes that dialog for
something like half a minute -- the exact failure the shortened timeout there
was chosen to avoid.

So: a watchdog thread that closes the request handle once the deadline passes,
which is the documented way to cancel a WinHTTP operation. `RequestDeadline`
owns the handle and both threads close it through an
`atomic::exchange(nullptr)`, so exactly one close ever happens. Failures are
reported as a timeout rather than as a raw GetLastError when the deadline is
what fired.

The ceiling is twice the caller's budget, not the budget itself: resolve,
connect, send and receive each get `timeout` from WinHttpSetTimeouts, so a
slow-but-progressing exchange can legitimately exceed one budget and must not
be cancelled. There is one accepted race, documented at the class: the caller
can load the handle just before the watchdog closes it, turning the call into
ERROR_INVALID_HANDLE instead. Both mean the deadline expired.

Cross-compiled with mingw-w64 (`-fsyntax-only`) rather than waiting on CI to
find syntax errors; also confirmed by preprocessor probe that
WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT is defined in those headers, so the
#ifdef guard is not silently skipping the option. Linux: all 6 suites pass.
Real verification is the Windows job's probe output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 18:37:58 -07:00
shadowdaoandClaude Opus 5 8888e57d08 fix(http): bound the WinHTTP response-header wait, and stop docs triggering builds
Build / macOS (macos-latest) (push) Successful in 50s
Build / Linux (ubuntu-24.04) (push) Successful in 1m14s
Build / macOS (macos-latest) (pull_request) Successful in 49s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 1m16s
Build / Windows (windows-latest) (push) Failing after 3m55s
Build / Windows (windows-latest) (pull_request) Failing after 3m51s
Two things, both prompted by an intermittent Windows CI failure in
test_api_client's testPlatformBackendTimeout: roughly 2 runs in 6, both its
assertions failed together, meaning a request with timeout_ms=700 waited out a
5s server stall and returned 200. Same failure on 2026-09-07 (job 5834) and
2026-09-09 (job 5911), on code that passed on other runs -- pre-existing and
intermittent, not caused by a change.

1. The real bug. `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"). The wait for the response HEADERS is a separate option,
   WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT, which WinHttpSetTimeouts does not
   set and which defaults to 90 SECONDS. So a server that accepts, reads the
   request and then stalls could block the calling thread for a minute and a
   half no matter what the caller passed as timeout_ms -- precisely the
   "blocking an OBS thread indefinitely" failure that test exists to prevent.
   Now set explicitly, guarded by #ifdef so an older SDK still builds.

   That is a genuine defect on its own merits. Whether it is the whole
   explanation for the intermittency is NOT established: the same docs say
   this timeout "is checked only when data is received from the socket", so
   neither option guarantees a hard deadline -- that needs a watchdog calling
   WinHttpCloseHandle, deliberately not done here.

2. Evidence, so the next run says more than pass/fail. The probe now runs 5
   times and prints elapsed ms, ok, status, requests_seen and the backend's
   error string (carrying GetLastError) for every attempt, so one CI run
   yields a failure RATE and an error code. Each attempt gets a FRESH
   loopback server: the server handles one connection at a time on a single
   thread, so reusing it would leave attempts 2..n in the accept backlog --
   never accepted, a different scenario from the one that fails. Verified on
   Linux: 5/5 attempts give up at ~701ms.

Also: build.yml now has paths-ignore for **.md, LICENSE, NOTICE, and the two
release-only files. This is a full three-platform build behind a runner with
capacity:1, and six of them fired for one afternoon of documentation edits.
Nothing that feeds a build or a test is on that list. Tradeoff: a docs-only
push now shows no status at all rather than a green one.

The WinHTTP change cannot be compiled locally (Linux host); CI is its first
build. All 6 suites pass locally on Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 18:30:02 -07:00
jknapp 5a39881af8 Merge pull request 'feat(release): publish releases directly, and fix the install path in the notes' (#4) from feat/publish-releases-directly into main
Build / macOS (macos-latest) (push) Successful in 27s
Build / Linux (ubuntu-24.04) (push) Successful in 45s
Build / Windows (windows-latest) (push) Failing after 4m59s
2026-09-09 23:51:05 +00:00
shadowdaoandClaude Opus 5 d9f73926e4 feat(release): publish releases directly, and fix the install path in the notes
Build / macOS (macos-latest) (push) Successful in 27s
Build / macOS (macos-latest) (pull_request) Successful in 26s
Build / Linux (ubuntu-24.04) (push) Successful in 56s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 54s
Build / Windows (windows-latest) (push) Successful in 4m20s
Build / Windows (windows-latest) (pull_request) Successful in 3m55s
Releases were created as drafts for one stated reason: nobody had run the
plugin in the OBS GUI on any platform, so a human had to look before anything
became visible. The first confirmed GUI load (Windows, OBS 32.2.2 on Windows
11, 2026-09-09) retired that gate, so `publish-release.sh` now posts
`"draft": False` and the workflow no longer needs a human click.

The caveats did not go away, they moved: the generated release notes now lead
with what is actually confirmed (module loads and registers its source type,
Windows only) and what is not (video rendering, A/V sync, latency, mid-show
publisher restart, Linux and macOS in the GUI at all), and the per-platform
table carries the rest.

Also fixes the third and last copy of the wrong Windows install path. The
release notes template told every downloader to extract into
`%APPDATA%\obs-studio\plugins\`, which on Windows is OBS's config directory
and is never scanned for plugins -- that is what stopped a director's
correctly-shaped install from loading. The notes now carry a per-platform
table (`C:\ProgramData\obs-studio\plugins\` on Windows), the exact finished
path, a warning about Explorer's "Extract All..." wrapper folder, and how to
confirm the load in the OBS log. Both failure modes are silent, which is
precisely why they belong in the notes.

Note the tradeoff now that nothing is held back: assets upload after the
release row is created, so a release is briefly visible with no files
attached. Called out in the script header.

Verified by rendering the heredoc with a stub tag: backslash escaping survives
into correct markdown, and the YAML parses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 16:50:44 -07:00
jknapp ee44fb6a73 Merge pull request 'docs: record the first confirmed OBS GUI load (Windows)' (#3) from docs/first-gui-load into main
Build / macOS (macos-latest) (push) Successful in 28s
Build / Linux (ubuntu-24.04) (push) Successful in 58s
Build / Windows (windows-latest) (push) Successful in 3m45s
2026-09-09 23:47:05 +00:00
shadowdaoandClaude Opus 5 abd4dc9aca docs: record the first confirmed OBS GUI load (Windows)
Build / macOS (macos-latest) (pull_request) Successful in 26s
Build / Linux (ubuntu-24.04) (pull_request) Successful in 59s
Build / macOS (macos-latest) (push) Successful in 37s
Build / Linux (ubuntu-24.04) (push) Successful in 55s
Build / Windows (windows-latest) (pull_request) Successful in 3m45s
Build / Windows (windows-latest) (push) Successful in 3m48s
The v0.1.0 Windows artifact loaded into OBS 32.2.2 on Windows 11 (build
26200) on a director's machine on 2026-09-09, from
C:\ProgramData\obs-studio\plugins\streamer-tools-camera\bin\64bit\. That
retires "has not been run in the OBS GUI on any platform", which this README
asserted in four places.

Scoped deliberately to what was actually observed: the module loads and
registers its source type. Whether video renders, the colours, A/V sync,
latency, and mid-show publisher restart are all still unverified, and Linux
and macOS have still never been opened in the GUI. The "Not verified
anywhere" list now says so explicitly rather than being deleted.

Also drops the release-draft rationale that pointed at the no-GUI-load state,
and carries the ProgramData install-path warning up into the status section,
since that is what stopped the first GUI load from happening sooner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzGnvQ6wfD7bw7PZN35ft9
2026-09-09 16:47:00 -07:00
jknapp 1571d7ecea Merge pull request 'docs: the Windows plugin dir is ProgramData, not the config dir' (#2) from docs/windows-plugin-path into main
Build / macOS (macos-latest) (push) Successful in 29s
Build / Linux (ubuntu-24.04) (push) Successful in 45s
Build / Windows (windows-latest) (push) Successful in 4m3s
2026-09-09 23:44:20 +00:00
shadowdaoandClaude Sonnet 5 76b2942db1 ci: bump the OBS deps cache key -- it was stuck on a permanently bad entry
Build / macOS (macos-latest) (push) Successful in 45s
Build / Linux (ubuntu-24.04) (push) Successful in 53s
Build / Windows (windows-latest) (push) Successful in 6m0s
Release / macOS (macos-latest) (push) Successful in 38s
Release / Linux (ubuntu-24.04) (push) Successful in 57s
Release / Windows (windows-latest) (push) Successful in 3m28s
Release / Create Gitea Release (draft) (push) Successful in 19s
actions/cache never overwrites an existing key: once a key has a saved
entry, later jobs' save steps are always skipped as a no-op regardless
of cache hit/miss. The very first job to ever populate this cache did
so before the "Drop non-relocatable OBS build tree" cleanup step
existed, so that entry included the bad, path-baked build_x86
directory -- and every run since has kept restoring that same bad
entry, not self-healing after "one more transitional run" the way it
looked when this was last checked. Confirmed live: a run just now hit
the identical stale-path CMakeCache.txt error against the same
original job hash (f72837d12c75c64a) from hours ago.

Bumping the key (obs-deps- -> obs-deps-v2-) is what actually forces a
fresh save under the cleanup step's watch, since the key content
(buildspec files) hasn't changed and nothing else would invalidate it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-07 10:47:56 -07:00
shadowdaoandClaude Sonnet 5 af7d2c6d24 fix: pin video quality to stop OBS source resizing; add audio-only mode
Build / macOS (macos-latest) (push) Successful in 34s
Build / Linux (ubuntu-24.04) (push) Successful in 46s
Build / Windows (windows-latest) (push) Failing after 2m44s
Live testing (2026-09-07) showed two real problems in one root cause:
LiveKit's default subscriber behavior lets the SFU switch simulcast
layers on its own bandwidth/adaptive logic, and this plugin never told
it not to. For a real camera, that showed up as the OBS source's
received frame size visibly hopping between 320x180/640x360/1280x720
mid-show -- OBS's async video source resizes to match, breaking any
manual crop/position a director had set up. For the soundboard (a
Camera-source track that exists only to satisfy RTMP's video
requirement -- Soundboard.tsx -- with no real visual content), the
same instability, plus the video showing at all, was pure noise: there
was no way to pull just its audio.

Both come from RemoteTrackPublication (livekit/remote_track_publication.h
in the pinned SDK), on the exact publication object TrackSubscribedEvent
and attachExistingTracks already hand this code:

  - setVideoQuality(VideoQuality::HIGH) on every wanted video track,
    unconditionally, so the SFU always sends the top simulcast layer
    instead of switching layers underneath a source with no
    rendered-size hint to give it (this is a native subscriber, not a
    sized <video> element).
  - A new SessionConfig::subscribe_video (mirrors subscribe_audio):
    when false, the wanted video track is never attached, and its
    publication is explicitly setEnabled(false) -- the SFU stops
    sending it, not just "decoded and discarded here". Wired to a new
    "Audio only (no video)" checkbox in the source's properties.

Both call sites (a fresh TrackSubscribedEvent, and attachExistingTracks
sweeping tracks already up when the session starts watching) go
through one new handleWantedVideoTrack() so they can't drift apart.

Not unit-testable without a real LiveKit connection (RemoteTrackPublication
isn't fakeable, matching why test_integration_livekit.cpp already needs a
real server) -- verified instead by a full local build against real
libobs-dev + the pinned SDK (clean compile, all 6 existing tests still
pass) and CI. The actual behavioral fix -- stable resolution, no video
for an audio-only source -- needs the same real-OBS verification every
other claim in this repo's "What is verified, and how" section does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-07 10:43:54 -07:00
shadowdaoandClaude Sonnet 5 104326d05a docs/release: drop the C1 licensing gate, simplify install instructions
Build / macOS (macos-latest) (push) Successful in 29s
Build / Linux (ubuntu-24.04) (push) Successful in 49s
Build / Windows (windows-latest) (push) Successful in 2m50s
Release / macOS (macos-latest) (push) Successful in 33s
Release / Linux (ubuntu-24.04) (push) Successful in 56s
Release / Windows (windows-latest) (push) Successful in 3m20s
Release / Create Gitea Release (draft) (push) Successful in 19s
The WebRTC/OpenH264 attribution question tracked as "C1" throughout
README, third_party/livekit/README.md, the release-notes template, and
both workflow header comments is the project owner's call, and it has
been made -- own sign-off given and reaffirmed. Remove the gate
language and the extended research writeup from release-facing docs;
keep the actual LICENSE/NOTICE files themselves (Apache-2.0 requires
shipping those regardless of any of this).

Also simplify the release notes' install instructions per owner
request: point at each platform's default OBS plugins folder rather
than walking through verbose per-platform copy/extract instructions --
the archives already extract straight into place (prior commit), so a
short pointer is all that's needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-07 09:40:49 -07:00
shadowdaoandClaude Sonnet 5 e050b81d6c ci: stop re-downloading CMake/Ninja on every Windows run
Build / macOS (macos-latest) (push) Successful in 32s
Build / Linux (ubuntu-24.04) (push) Successful in 56s
Build / Windows (windows-latest) (push) Successful in 2m52s
Release / macOS (macos-latest) (push) Successful in 43s
Release / Linux (ubuntu-24.04) (push) Successful in 56s
Release / Windows (windows-latest) (push) Successful in 3m23s
Release / Create Gitea Release (draft) (push) Successful in 19s
lukka/get-cmake@latest re-fetched and re-extracted CMake + Ninja on
every single Windows CI run. Its own cache (routed through this
act_runner's built-in cache server) reported "Cloud cache miss" on
every run, even one immediately after a run that logged a successful
save under the exact same key -- an incompatibility between its
bundled cache client and this act_runner's cache-server implementation,
not a config gap. Separately and more importantly: the archive
extraction step alone measured ~7.5 minutes for a 45MB zip on this VM,
consistent with Defender real-time scanning rather than raw disk I/O.
Together this was the dominant cost of every Windows CI run.

CMake 4.4.2 and Ninja 1.12.1 are now installed once, directly on the
winvm-builder VM's system PATH (C:\BuildTools\cmake, C:\BuildTools\
ninja), sidestepping the third-party action's cache entirely rather
than debugging its internals further. Both Windows jobs now just
verify cmake/ninja are present and fail loudly if not, instead of
silently falling back to a slow reinstall. Full detail, including how
to redo this if the VM is ever rebuilt, is in README's new "Windows
runner: persistent build tools" section -- this is VM state, not
something git reproduces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-07 09:07:23 -07:00
shadowdaoandClaude Sonnet 5 18610bd9dc ci: stop build.yml from also running on tag pushes
Build / macOS (macos-latest) (push) Successful in 32s
Build / Linux (ubuntu-24.04) (push) Successful in 48s
Build / Windows (windows-latest) (push) Successful in 10m32s
A bare `push:` trigger matches every ref push, tags included -- so
tagging a release triggered this workflow's full 3-platform build
(Windows and all) at the same time as release.yml's own, both
competing for the Windows runner's capacity:1 and roughly doubling
Windows CI time for every tag push. Scope this workflow's push trigger
to branches only; release.yml already covers this same build (plus
packaging) on every `v*` tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-07 09:03:41 -07:00
shadowdaoandClaude Sonnet 5 43a501c06c release: package archives as direct drop-ins to OBS plugin dirs
Release / macOS (macos-latest) (push) Successful in 43s
Release / Linux (ubuntu-24.04) (push) Successful in 51s
Build / Windows (windows-latest) (push) Successful in 10m43s
Build / Linux (ubuntu-24.04) (push) Successful in 52s
Build / macOS (macos-latest) (push) Successful in 28s
Release / Windows (windows-latest) (push) Failing after 16m46s
Release / Create Gitea Release (draft) (push) Skipped
Previously each release zip mirrored build/package/ verbatim (bin/ +
data/ at the archive root), which needed a manual mkdir + cp -r into
the right OBS plugin subdirectory per the install instructions. Wrap
Linux and Windows archives in a top-level streamer-tools-camera/
directory instead -- matching AddExtraModulePaths()'s expected
<config>/obs-studio/plugins/<name>/bin/64bit + data layout -- so
`unzip -d ~/.config/obs-studio/plugins/` (or Expand-Archive to
%APPDATA%\obs-studio\plugins\ on Windows) is the entire install step.

macOS already produced the right shape (the .plugin bundle itself at
the archive's top level, since OBS wants the whole bundle directly
under plugins/, not nested under a named subdirectory) -- no packaging
change needed there, just an install-instructions rewrite plus making
that text properly conditional on MACOS_BUNDLE_FOUND like MACOS_NOTE
already was, since the old hard-coded "not yet that bundle shape" text
was stale once the macOS bundle packaging fix landed.

Verified locally: staged a fake build/package/ tree, ran the new
packaging logic, and confirmed extracting the resulting zip into a
plugins directory produces <name>/bin/64bit/... and <name>/data/...
directly. Also dry-ran publish-release.sh's notes generation for both
MACOS_BUNDLE_FOUND values to confirm the conditional install text
renders correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-07 08:41:44 -07:00
shadowdaoandClaude Sonnet 5 14fa00af3f docs: OpenH264/MPEG-LA royalty finding sharpens C1 -- not resolved
Build / macOS (macos-latest) (push) Successful in 28s
Build / Linux (ubuntu-24.04) (push) Successful in 59s
Build / Windows (windows-latest) (push) Successful in 10m45s
Cisco's own OpenH264 FAQ (openh264.org/faq.html) is explicit: they cover
MPEG-LA/AVC patent-pool royalties only for their own prebuilt binary,
downloaded at install time. Anyone who compiles OpenH264 from source
and redistributes it inside their own binary takes on "all applicable
license fees" themselves -- Cisco "will not be liable for any licensing
fees incurred by other parties" in that case.

LiveKit's client-sdk-cpp links Google libwebrtc via the webrtc-sdk
org's fork, whose documented build args (rtc_use_h264=true,
ffmpeg_branding="Chrome") are the standard Chromium/WebRTC recipe --
which links a from-source, statically-compiled copy of OpenH264 (from
Google's mirror, not Cisco's runtime binary) into libwebrtc. That is
exactly the shape of case Cisco's FAQ says voids their coverage. Not
independently confirmed against LiveKit's actual pinned v1.10.1 build
(their release archives ship only compiled output, no build manifest)
-- this is webrtc-sdk/libwebrtc's documented default, not a verified
fact about this specific artifact.

This sharpens C1 into a concrete mechanism instead of a general open
question. It does not resolve C1 -- if anything it strengthens the case
for treating it as unresolved -- and none of this is a substitute for
an actual legal opinion. Recorded in third_party/livekit/README.md (the
full writeup), README.md's Status section, and the release-notes
template in publish-release.sh so it reaches whoever opens a draft
release next, not just this one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-07 07:45:36 -07:00
shadowdaoandClaude Sonnet 5 d287a90048 ci: strip the non-relocatable OBS build tree before caching .deps
Build / macOS (macos-latest) (push) Successful in 33s
Build / Linux (ubuntu-24.04) (push) Successful in 56s
Release / macOS (macos-latest) (push) Successful in 32s
Release / Linux (ubuntu-24.04) (push) Successful in 52s
Build / Windows (windows-latest) (push) Successful in 10m35s
Release / Windows (windows-latest) (push) Successful in 11m7s
Release / Create Gitea Release (draft) (push) Successful in 20s
The obs-studio sub-build's out-of-source CMakeCache.txt
(.deps/obs-studio-*/build_*) bakes in the job's absolute checkout
path. Caching it as-is (from the previous .deps caching commit)
meant every run's first configure attempt hard-failed with:

  CMake Error: The current CMakeCache.txt directory ... is different
  than the directory ... where CMakeCache.txt was created.

Confirmed live in run 3091 (commit 1f342b1): this was silently
self-healing, since windows-build.ps1's existing fallback
(-DSTPLUGIN_BOOTSTRAP_OBS=OFF) still found the already-installed,
path-independent libobs package and the build succeeded anyway -- but
every run paid for one guaranteed-failed configure and printed a
misleading "::warning::OBS SDK bootstrap failed" that isn't actually
true. Delete only the build tree, after libobs is already built and
installed from it, so nothing path-dependent survives into the cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-07 06:10:55 -07:00
11 changed files with 585 additions and 214 deletions
+41 -63
View File
@@ -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")
@@ -27,77 +27,55 @@ MACOS_BUNDLE_FOUND="${MACOS_BUNDLE_FOUND:-false}"
if [ "${MACOS_BUNDLE_FOUND}" = "true" ]; then
MACOS_NOTE="This archive contains a \`.plugin\` bundle."
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
watching OBS.
**Read the per-platform notes below before relying on this.** The module has
been loaded in the OBS GUI exactly once -- Windows, OBS 32.2.2 on Windows 11,
2026-09-09 -- and only "it loads and registers its source type" is confirmed
there. Whether video renders correctly, A/V sync, end-to-end latency and
mid-show publisher restart are all still unverified on every platform. See
"What is verified, and how" in \`README.md\` for exactly 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 |
| Windows (x64) | \`streamer-tools-camera-${TAG}-windows-x64.zip\` | Built and tested by this workflow's Windows job. Confirmed to load in the OBS GUI (32.2.2 / Windows 11, 2026-09-09); nothing past module load is verified. 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} |
## 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 +97,7 @@ print(json.dumps({
"tag_name": tag,
"name": tag,
"body": notes,
"draft": True,
"draft": False,
"prerelease": False,
}))
PYEOF
@@ -133,7 +111,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 +130,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
View File
@@ -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
+62 -32
View File
@@ -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 }}
+82 -36
View File
@@ -8,43 +8,38 @@ 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).
**First confirmed OBS GUI load: Windows, 2026-09-09** — the v0.1.0 release
artifact loaded into OBS 32.2.2 on Windows 11 (build 26200) on a director's
machine, from
`C:\ProgramData\obs-studio\plugins\streamer-tools-camera\bin\64bit\`.
That retires "the module will not even load in a real OBS" for Windows. It
does **not** yet cover whether video renders correctly, colours, A/V sync or
latency — see "Not verified anywhere" below for what is still open. Linux and
macOS have still never been opened in the GUI; macOS builds the real module in
CI but its artifact is not yet loadable (see the macOS packaging gap under CI).
⚠️ **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 +49,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 +80,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
@@ -179,8 +174,10 @@ macOS packaging gap under CI.
## Testing this by hand
**Nobody has yet run this in the OBS GUI. That test is still outstanding on
all three platforms.** To do it on Linux:
**The module has been loaded in the OBS GUI on Windows once (2026-09-09, OBS
32.2.2 / Windows 11 26200) — nothing beyond "it loads and registers its source"
is confirmed there, and Linux and macOS have never been opened in the GUI at
all.** To do it on Linux:
```
mkdir -p ~/.config/obs-studio/plugins/streamer-tools-camera
@@ -236,7 +233,11 @@ 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.
- Anything past module load in the OBS GUI. Windows 2026-09-09 confirms the
module loads and its source type appears; whether video actually renders
(right way up, right colours), what the A/V sync and latency look like, and
whether a publisher restarting mid-show recovers on screen are all still
unanswered. Linux and macOS have not been opened in the GUI at all.
- 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.
@@ -284,6 +285,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:
+7
View File
@@ -43,6 +43,13 @@ 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;
};
+147 -23
View File
@@ -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);
+48 -2
View File
@@ -218,6 +218,52 @@ struct LiveKitSession::Impl : public livekit::RoomDelegate {
queue_cv.notify_one();
}
// 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.
}
}
post(CommandType::AttachVideo, track);
}
// --- RoomDelegate ------------------------------------------------------
void onTrackSubscribed(livekit::Room &, const livekit::TrackSubscribedEvent &event) override
@@ -230,7 +276,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);
}
@@ -551,7 +597,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);
}
+70 -14
View File
@@ -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
+1
View File
@@ -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)"
+22 -1
View File
@@ -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();
@@ -385,6 +397,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 +540,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.
-25
View File
@@ -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.