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
This commit is contained in:
2026-09-07 10:43:54 -07:00
co-authored by Claude Sonnet 5
parent 104326d05a
commit af7d2c6d24
4 changed files with 78 additions and 3 deletions
+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;
};
+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);
}
+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.