Skip to content

Commit 395db23

Browse files
steveseguinclaude
andcommitted
obs-webrtc: Add pre-offer ICE gathering via OPTIONS
Implements pre-offer ICE gathering via HTTP OPTIONS request as specified in WHIP spec section 4.4. This enables P2P WebRTC connections when STUN/TURN servers are provided by the endpoint. Changes: - Add FetchIceServersViaOptions() to query ICE servers before offer - Parse Link headers for STUN/TURN URIs and credentials - Implement async ICE gathering with 5-second timeout - Always gather local candidates to ensure host candidates collected - Maintain backwards compatibility when OPTIONS fails Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 65afc7a commit 395db23

File tree

2 files changed

+112
-1
lines changed

2 files changed

+112
-1
lines changed

plugins/obs-webrtc/whip-output.cpp

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ WHIPOutput::WHIPOutput(obs_data_t *, obs_output_t *output)
3434
endpoint_url(),
3535
bearer_token(),
3636
resource_url(),
37+
ice_gathering_mutex(),
38+
ice_gathering_cv(),
39+
ice_gathering_complete(false),
40+
has_ice_servers(false),
3741
running(false),
3842
start_stop_mutex(),
3943
start_stop_thread(),
@@ -266,6 +270,67 @@ bool WHIPOutput::Init()
266270
return true;
267271
}
268272

273+
/**
274+
* @brief Fetch ICE servers via OPTIONS request to WHIP endpoint.
275+
*
276+
* Per WHIP spec, the endpoint may provide STUN/TURN servers via Link headers
277+
* in response to an OPTIONS request. This allows ICE gathering to begin
278+
* before the offer is sent, enabling P2P connections behind NAT.
279+
*
280+
* @param iceServers Vector to populate with discovered ICE servers
281+
* @return bool True if request succeeded (even if no ICE servers found)
282+
*/
283+
bool WHIPOutput::FetchIceServersViaOptions(std::vector<rtc::IceServer> &iceServers)
284+
{
285+
struct curl_slist *headers = nullptr;
286+
headers = curl_slist_append(headers, "Accept: application/sdp");
287+
headers = curl_slist_append(headers, user_agent.c_str());
288+
289+
if (!bearer_token.empty()) {
290+
auto bearer_token_header = std::string("Authorization: Bearer ") + bearer_token;
291+
headers = curl_slist_append(headers, bearer_token_header.c_str());
292+
}
293+
294+
std::vector<std::string> http_headers;
295+
296+
CURL *c = curl_easy_init();
297+
curl_easy_setopt(c, CURLOPT_HTTPHEADER, headers);
298+
curl_easy_setopt(c, CURLOPT_URL, endpoint_url.c_str());
299+
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "OPTIONS");
300+
curl_easy_setopt(c, CURLOPT_NOBODY, 1L);
301+
curl_easy_setopt(c, CURLOPT_TIMEOUT, 5L);
302+
curl_easy_setopt(c, CURLOPT_HEADERFUNCTION, curl_header_function);
303+
curl_easy_setopt(c, CURLOPT_HEADERDATA, (void *)&http_headers);
304+
305+
CURLcode res = curl_easy_perform(c);
306+
curl_easy_cleanup(c);
307+
curl_slist_free_all(headers);
308+
309+
if (res != CURLE_OK) {
310+
do_log(LOG_DEBUG, "OPTIONS request failed: %s (will proceed without pre-configured ICE servers)",
311+
curl_easy_strerror(res));
312+
return false;
313+
}
314+
315+
for (auto &http_header : http_headers) {
316+
auto value = value_for_header("link", http_header);
317+
if (value.empty())
318+
continue;
319+
320+
for (auto end = value.find(","); end != std::string::npos; end = value.find(",")) {
321+
this->ParseLinkHeader(value.substr(0, end), iceServers);
322+
value = value.substr(end + 1);
323+
}
324+
this->ParseLinkHeader(value, iceServers);
325+
}
326+
327+
if (!iceServers.empty()) {
328+
do_log(LOG_INFO, "Discovered %zu ICE server(s) via OPTIONS request", iceServers.size());
329+
}
330+
331+
return true;
332+
}
333+
269334
/**
270335
* @brief Set up the PeerConnection and media tracks.
271336
*
@@ -275,12 +340,31 @@ bool WHIPOutput::Setup()
275340
{
276341
rtc::Configuration cfg;
277342

343+
// Fetch ICE servers via OPTIONS request (per WHIP spec section 4.4)
344+
std::vector<rtc::IceServer> iceServers;
345+
FetchIceServersViaOptions(iceServers);
346+
has_ice_servers = !iceServers.empty();
347+
if (has_ice_servers) {
348+
cfg.iceServers = iceServers;
349+
}
350+
278351
#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
279-
cfg.disableAutoGathering = true;
352+
// Enable auto-gathering if we have ICE servers from OPTIONS
353+
cfg.disableAutoGathering = iceServers.empty();
280354
#endif
281355

356+
ice_gathering_complete = false;
282357
peer_connection = std::make_shared<rtc::PeerConnection>(cfg);
283358

359+
// Set up async ICE gathering completion notification
360+
peer_connection->onGatheringStateChange([this](rtc::PeerConnection::GatheringState state) {
361+
if (state == rtc::PeerConnection::GatheringState::Complete) {
362+
std::lock_guard<std::mutex> lock(ice_gathering_mutex);
363+
ice_gathering_complete = true;
364+
ice_gathering_cv.notify_one();
365+
}
366+
});
367+
284368
peer_connection->onStateChange([this](rtc::PeerConnection::State state) {
285369
switch (state) {
286370
case rtc::PeerConnection::State::New:
@@ -400,6 +484,22 @@ bool WHIPOutput::Connect()
400484
std::string read_buffer;
401485
std::vector<std::string> http_headers;
402486

487+
#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
488+
// Wait for ICE gathering to complete (with timeout) so candidates are
489+
// bundled in the offer. This is required for P2P WHIP endpoints that
490+
// may not support trickle ICE. Only wait if we have ICE servers.
491+
if (has_ice_servers) {
492+
std::unique_lock<std::mutex> lock(ice_gathering_mutex);
493+
if (!ice_gathering_complete) {
494+
auto timeout = std::chrono::milliseconds(5000);
495+
if (!ice_gathering_cv.wait_for(lock, timeout,
496+
[this] { return ice_gathering_complete.load(); })) {
497+
do_log(LOG_WARNING, "ICE gathering timed out; sending offer with partial candidates");
498+
}
499+
}
500+
}
501+
#endif
502+
403503
auto offer_sdp = std::string(peer_connection->localDescription().value());
404504

405505
#ifdef DEBUG_SDP
@@ -574,6 +674,9 @@ bool WHIPOutput::Connect()
574674
doCleanup(false);
575675

576676
#if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
677+
// Always gather with POST response servers to:
678+
// 1. Get host candidates even if no ICE servers provided
679+
// 2. Incorporate any TURN servers/credentials from the POST response
577680
peer_connection->gatherLocalCandidates(iceServers);
578681
#endif
579682

plugins/obs-webrtc/whip-output.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
#include <mutex>
1212
#include <thread>
1313
#include <algorithm>
14+
#include <chrono>
15+
#include <condition_variable>
1416

1517
#include <rtc/rtc.hpp>
1618

@@ -45,6 +47,7 @@ class WHIPOutput {
4547
void SendDelete();
4648
void StopThread(bool signal);
4749
void ParseLinkHeader(std::string linkHeader, std::vector<rtc::IceServer> &iceServers);
50+
bool FetchIceServersViaOptions(std::vector<rtc::IceServer> &iceServers);
4851
void Send(void *data, uintptr_t size, uint64_t duration, std::shared_ptr<rtc::Track> track,
4952
std::shared_ptr<rtc::RtcpSrReporter> rtcp_sr_reporter);
5053

@@ -54,6 +57,11 @@ class WHIPOutput {
5457
std::string bearer_token;
5558
std::string resource_url;
5659

60+
std::mutex ice_gathering_mutex;
61+
std::condition_variable ice_gathering_cv;
62+
std::atomic<bool> ice_gathering_complete;
63+
bool has_ice_servers;
64+
5765
std::atomic<bool> running;
5866

5967
std::mutex start_stop_mutex;

0 commit comments

Comments
 (0)