fix: pin video quality to stop OBS source resizing; add audio-only mode
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:
@@ -4,6 +4,7 @@ RoomSlug="Room"
|
||||
ReadKey="Read key"
|
||||
Camera="Camera"
|
||||
RefreshCameras="Refresh camera list"
|
||||
AudioOnly="Audio only (no video)"
|
||||
Status="Status"
|
||||
NoCameraSelected="(no camera selected)"
|
||||
OfflineSuffix=" (offline)"
|
||||
|
||||
@@ -57,6 +57,7 @@ constexpr const char *kSettingServerUrl = "server_url";
|
||||
constexpr const char *kSettingRoomSlug = "room_slug";
|
||||
constexpr const char *kSettingReadKey = "read_key";
|
||||
constexpr const char *kSettingCamera = "camera";
|
||||
constexpr const char *kSettingAudioOnly = "audio_only";
|
||||
constexpr const char *kSettingStatus = "status";
|
||||
constexpr const char *kPropRefresh = "refresh";
|
||||
|
||||
@@ -96,6 +97,10 @@ struct CameraSource {
|
||||
std::mutex mutex;
|
||||
ConnectionConfig config;
|
||||
std::string camera_identity;
|
||||
/// True hides video entirely for this source (the soundboard, typically)
|
||||
/// -- see SessionConfig::subscribe_video for what that actually does at
|
||||
/// the LiveKit level.
|
||||
bool audio_only = false;
|
||||
/// Bumped every time settings change; the worker compares it to what it
|
||||
/// last connected with, so a stale in-flight connect is abandoned rather
|
||||
/// than fought over.
|
||||
@@ -219,6 +224,7 @@ void workerLoop(CameraSource *self)
|
||||
for (;;) {
|
||||
ConnectionConfig config;
|
||||
std::string camera;
|
||||
bool audio_only = false;
|
||||
std::uint64_t generation = 0;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(self->mutex);
|
||||
@@ -226,6 +232,7 @@ void workerLoop(CameraSource *self)
|
||||
break;
|
||||
config = self->config;
|
||||
camera = self->camera_identity;
|
||||
audio_only = self->audio_only;
|
||||
generation = self->generation;
|
||||
}
|
||||
|
||||
@@ -282,6 +289,7 @@ void workerLoop(CameraSource *self)
|
||||
session_config.ws_url = token.ws_url;
|
||||
session_config.token = token.lk_token;
|
||||
session_config.participant_identity = camera;
|
||||
session_config.subscribe_video = !audio_only;
|
||||
|
||||
if (self->session->connect(session_config)) {
|
||||
connected = true;
|
||||
@@ -330,6 +338,7 @@ void sourceGetDefaults(obs_data_t *settings)
|
||||
obs_data_set_default_string(settings, kSettingRoomSlug, "");
|
||||
obs_data_set_default_string(settings, kSettingReadKey, "");
|
||||
obs_data_set_default_string(settings, kSettingCamera, "");
|
||||
obs_data_set_default_bool(settings, kSettingAudioOnly, false);
|
||||
}
|
||||
|
||||
void applySettings(CameraSource *self, obs_data_t *settings)
|
||||
@@ -339,16 +348,19 @@ void applySettings(CameraSource *self, obs_data_t *settings)
|
||||
config.room_slug = settingString(settings, kSettingRoomSlug);
|
||||
config.read_key = settingString(settings, kSettingReadKey);
|
||||
const std::string camera = settingString(settings, kSettingCamera);
|
||||
const bool audio_only = obs_data_get_bool(settings, kSettingAudioOnly);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(self->mutex);
|
||||
const bool changed = config.server_url != self->config.server_url ||
|
||||
config.room_slug != self->config.room_slug ||
|
||||
config.read_key != self->config.read_key || camera != self->camera_identity;
|
||||
config.read_key != self->config.read_key || camera != self->camera_identity ||
|
||||
audio_only != self->audio_only;
|
||||
if (!changed)
|
||||
return;
|
||||
self->config = config;
|
||||
self->camera_identity = camera;
|
||||
self->audio_only = audio_only;
|
||||
++self->generation;
|
||||
}
|
||||
self->wake.notify_all();
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user