|
1 | 1 | #include "whip-output.h" |
2 | 2 | #include "whip-utils.h" |
3 | 3 |
|
| 4 | +#include <regex> |
4 | 5 | #include <obs.hpp> |
5 | 6 |
|
6 | 7 | /* |
@@ -37,7 +38,14 @@ WHIPOutput::WHIPOutput(obs_data_t *, obs_output_t *output) |
37 | 38 | ice_gathering_mutex(), |
38 | 39 | ice_gathering_cv(), |
39 | 40 | ice_gathering_complete(false), |
| 41 | + has_first_candidate(false), |
| 42 | + offer_sent(false), |
40 | 43 | has_ice_servers(false), |
| 44 | + ice_ufrag(), |
| 45 | + ice_pwd(), |
| 46 | + pending_candidates(), |
| 47 | + pending_candidates_mutex(), |
| 48 | + post_response_gather_started(false), |
41 | 49 | running(false), |
42 | 50 | start_stop_mutex(), |
43 | 51 | start_stop_thread(), |
@@ -354,14 +362,65 @@ bool WHIPOutput::Setup() |
354 | 362 | #endif |
355 | 363 |
|
356 | 364 | ice_gathering_complete = false; |
| 365 | + has_first_candidate = false; |
| 366 | + offer_sent = false; |
| 367 | + post_response_gather_started = false; |
| 368 | + ice_ufrag.clear(); |
| 369 | + ice_pwd.clear(); |
| 370 | + first_mid.clear(); |
| 371 | + { |
| 372 | + std::lock_guard<std::mutex> lock(pending_candidates_mutex); |
| 373 | + pending_candidates.clear(); |
| 374 | + } |
| 375 | + |
357 | 376 | peer_connection = std::make_shared<rtc::PeerConnection>(cfg); |
358 | 377 |
|
| 378 | + // Track when we receive our first ICE candidate |
| 379 | + peer_connection->onLocalCandidate([this](rtc::Candidate candidate) { |
| 380 | + { |
| 381 | + std::lock_guard<std::mutex> lock(ice_gathering_mutex); |
| 382 | + if (!has_first_candidate) { |
| 383 | + has_first_candidate = true; |
| 384 | + first_mid = candidate.mid(); // Saved for end-of-candidates signal |
| 385 | + ice_gathering_cv.notify_one(); |
| 386 | + } |
| 387 | + } |
| 388 | + // If offer already sent, trickle this candidate immediately |
| 389 | + // Otherwise queue it - it will be sent after POST completes |
| 390 | + // Only trickle if we have ICE servers (trickle ICE not needed for host-only) |
| 391 | + if (has_ice_servers) { |
| 392 | + bool should_send = false; |
| 393 | + // Lock to synchronize with the flush in Connect() |
| 394 | + // This prevents candidates from being lost between setting |
| 395 | + // offer_sent and flushing the queue |
| 396 | + { |
| 397 | + std::lock_guard<std::mutex> lock(pending_candidates_mutex); |
| 398 | + if (offer_sent) { |
| 399 | + should_send = true; |
| 400 | + } else { |
| 401 | + pending_candidates.push_back(candidate); |
| 402 | + } |
| 403 | + } |
| 404 | + if (should_send) { |
| 405 | + SendTrickleCandidate(candidate); |
| 406 | + } |
| 407 | + } |
| 408 | + }); |
| 409 | + |
359 | 410 | // Set up async ICE gathering completion notification |
360 | 411 | peer_connection->onGatheringStateChange([this](rtc::PeerConnection::GatheringState state) { |
361 | 412 | 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(); |
| 413 | + { |
| 414 | + std::lock_guard<std::mutex> lock(ice_gathering_mutex); |
| 415 | + ice_gathering_complete = true; |
| 416 | + ice_gathering_cv.notify_one(); |
| 417 | + } |
| 418 | + // Only send end-of-candidates after the final (post-response) gather |
| 419 | + // completes, not after the pre-offer OPTIONS gather. This ensures |
| 420 | + // candidates from POST response ICE servers aren't ignored (RFC 8840). |
| 421 | + if (has_ice_servers && post_response_gather_started) { |
| 422 | + SendEndOfCandidates(); |
| 423 | + } |
365 | 424 | } |
366 | 425 | }); |
367 | 426 |
|
@@ -485,23 +544,33 @@ bool WHIPOutput::Connect() |
485 | 544 | std::vector<std::string> http_headers; |
486 | 545 |
|
487 | 546 | #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. |
| 547 | + // Smart waiting: if we have ICE servers, wait for first candidate OR 150ms. |
| 548 | + // This gets us at least host candidates quickly, and likely some STUN |
| 549 | + // candidates too. Any candidates gathered after offer is sent will be |
| 550 | + // trickled via PATCH. |
491 | 551 | if (has_ice_servers) { |
492 | 552 | 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 | | - } |
| 553 | + if (!has_first_candidate) { |
| 554 | + // 150ms balances latency vs. candidate coverage; typically enough for host + STUN |
| 555 | + auto timeout = std::chrono::milliseconds(150); |
| 556 | + ice_gathering_cv.wait_for(lock, timeout, [this] { return has_first_candidate.load(); }); |
499 | 557 | } |
500 | 558 | } |
501 | 559 | #endif |
502 | 560 |
|
503 | 561 | auto offer_sdp = std::string(peer_connection->localDescription().value()); |
504 | 562 |
|
| 563 | + // Extract ICE credentials for trickle PATCH requests |
| 564 | + std::regex re_ufrag("a=ice-ufrag:([^\\r\\n]+)"); |
| 565 | + std::regex re_pwd("a=ice-pwd:([^\\r\\n]+)"); |
| 566 | + std::smatch match; |
| 567 | + if (std::regex_search(offer_sdp, match, re_ufrag)) { |
| 568 | + ice_ufrag = match[1]; |
| 569 | + } |
| 570 | + if (std::regex_search(offer_sdp, match, re_pwd)) { |
| 571 | + ice_pwd = match[1]; |
| 572 | + } |
| 573 | + |
505 | 574 | #ifdef DEBUG_SDP |
506 | 575 | do_log(LOG_DEBUG, "Offer SDP:\n%s", offer_sdp.c_str()); |
507 | 576 | #endif |
@@ -631,6 +700,23 @@ bool WHIPOutput::Connect() |
631 | 700 | do_log(LOG_DEBUG, "WHIP Resource URL is: %s", resource_url.c_str()); |
632 | 701 | curl_url_cleanup(url_builder); |
633 | 702 |
|
| 703 | + // Flush any candidates that arrived during the POST request (trickle ICE only) |
| 704 | + // Set offer_sent inside the lock to prevent race with onLocalCandidate callback |
| 705 | + if (has_ice_servers) { |
| 706 | + std::vector<rtc::Candidate> candidates_to_send; |
| 707 | + { |
| 708 | + std::lock_guard<std::mutex> lock(pending_candidates_mutex); |
| 709 | + offer_sent = true; |
| 710 | + candidates_to_send = std::move(pending_candidates); |
| 711 | + pending_candidates.clear(); |
| 712 | + } |
| 713 | + for (const auto &candidate : candidates_to_send) { |
| 714 | + SendTrickleCandidate(candidate); |
| 715 | + } |
| 716 | + } else { |
| 717 | + offer_sent = true; |
| 718 | + } |
| 719 | + |
634 | 720 | #ifdef DEBUG_SDP |
635 | 721 | do_log(LOG_DEBUG, "Answer SDP:\n%s", read_buffer.c_str()); |
636 | 722 | #endif |
@@ -677,6 +763,8 @@ bool WHIPOutput::Connect() |
677 | 763 | // Always gather with POST response servers to: |
678 | 764 | // 1. Get host candidates even if no ICE servers provided |
679 | 765 | // 2. Incorporate any TURN servers/credentials from the POST response |
| 766 | + // Mark that this is the final gather - end-of-candidates will be sent when complete |
| 767 | + post_response_gather_started = true; |
680 | 768 | peer_connection->gatherLocalCandidates(iceServers); |
681 | 769 | #endif |
682 | 770 |
|
@@ -821,6 +909,90 @@ void WHIPOutput::Send(void *data, uintptr_t size, uint64_t duration, std::shared |
821 | 909 | } |
822 | 910 | } |
823 | 911 |
|
| 912 | +void WHIPOutput::SendTrickleIcePatch(const std::string &sdp_frag) |
| 913 | +{ |
| 914 | + struct curl_slist *headers = NULL; |
| 915 | + headers = curl_slist_append(headers, "Content-Type: application/trickle-ice-sdpfrag"); |
| 916 | + if (!bearer_token.empty()) { |
| 917 | + auto bearer_token_header = std::string("Authorization: Bearer ") + bearer_token; |
| 918 | + headers = curl_slist_append(headers, bearer_token_header.c_str()); |
| 919 | + } |
| 920 | + headers = curl_slist_append(headers, user_agent.c_str()); |
| 921 | + |
| 922 | + char error_buffer[CURL_ERROR_SIZE] = {}; |
| 923 | + |
| 924 | + CURL *c = curl_easy_init(); |
| 925 | + curl_easy_setopt(c, CURLOPT_HTTPHEADER, headers); |
| 926 | + curl_easy_setopt(c, CURLOPT_URL, resource_url.c_str()); |
| 927 | + curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PATCH"); |
| 928 | + curl_easy_setopt(c, CURLOPT_COPYPOSTFIELDS, sdp_frag.c_str()); |
| 929 | + curl_easy_setopt(c, CURLOPT_TIMEOUT, 8L); |
| 930 | + curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L); |
| 931 | + curl_easy_setopt(c, CURLOPT_UNRESTRICTED_AUTH, 1L); |
| 932 | + curl_easy_setopt(c, CURLOPT_ERRORBUFFER, error_buffer); |
| 933 | + |
| 934 | + CURLcode res = curl_easy_perform(c); |
| 935 | + if (res != CURLE_OK) { |
| 936 | + do_log(LOG_WARNING, "Trickle ICE PATCH failed: %s", |
| 937 | + error_buffer[0] ? error_buffer : curl_easy_strerror(res)); |
| 938 | + } else { |
| 939 | + long response_code = 0; |
| 940 | + curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &response_code); |
| 941 | + if (response_code < 200 || response_code >= 300) { |
| 942 | + do_log(LOG_WARNING, "Trickle ICE PATCH returned HTTP %ld", response_code); |
| 943 | + } |
| 944 | + } |
| 945 | + |
| 946 | + curl_easy_cleanup(c); |
| 947 | + curl_slist_free_all(headers); |
| 948 | +} |
| 949 | + |
| 950 | +void WHIPOutput::SendTrickleCandidate(const rtc::Candidate &candidate) |
| 951 | +{ |
| 952 | + // Guard: credentials not yet extracted from offer SDP |
| 953 | + if (resource_url.empty() || ice_ufrag.empty() || ice_pwd.empty()) { |
| 954 | + return; |
| 955 | + } |
| 956 | + |
| 957 | + // Build SDP fragment with single candidate (RFC 8840 compliant) |
| 958 | + std::string sdp_frag; |
| 959 | + sdp_frag.append("a=ice-ufrag:" + ice_ufrag + "\r\n"); |
| 960 | + sdp_frag.append("a=ice-pwd:" + ice_pwd + "\r\n"); |
| 961 | + std::string mid = candidate.mid(); |
| 962 | + if (!mid.empty()) { |
| 963 | + sdp_frag.append("a=mid:" + mid + "\r\n"); |
| 964 | + } |
| 965 | + sdp_frag.append("a=" + candidate.candidate() + "\r\n"); |
| 966 | + |
| 967 | + do_log(LOG_DEBUG, "Trickle ICE candidate (mid=%s): %s", mid.c_str(), candidate.candidate().c_str()); |
| 968 | + SendTrickleIcePatch(sdp_frag); |
| 969 | +} |
| 970 | + |
| 971 | +void WHIPOutput::SendEndOfCandidates() |
| 972 | +{ |
| 973 | + // Guard: credentials not yet extracted from offer SDP |
| 974 | + if (resource_url.empty() || ice_ufrag.empty() || ice_pwd.empty()) { |
| 975 | + return; |
| 976 | + } |
| 977 | + |
| 978 | + // Build SDP fragment with end-of-candidates marker (RFC 8840 compliant) |
| 979 | + std::string sdp_frag; |
| 980 | + sdp_frag.append("a=ice-ufrag:" + ice_ufrag + "\r\n"); |
| 981 | + sdp_frag.append("a=ice-pwd:" + ice_pwd + "\r\n"); |
| 982 | + std::string mid; |
| 983 | + { |
| 984 | + std::lock_guard<std::mutex> lock(ice_gathering_mutex); |
| 985 | + mid = first_mid; |
| 986 | + } |
| 987 | + if (!mid.empty()) { |
| 988 | + sdp_frag.append("a=mid:" + mid + "\r\n"); |
| 989 | + } |
| 990 | + sdp_frag.append("a=end-of-candidates\r\n"); |
| 991 | + |
| 992 | + do_log(LOG_DEBUG, "Sending end-of-candidates"); |
| 993 | + SendTrickleIcePatch(sdp_frag); |
| 994 | +} |
| 995 | + |
824 | 996 | void register_whip_output() |
825 | 997 | { |
826 | 998 | const uint32_t base_flags = OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE | OBS_OUTPUT_MULTI_TRACK_AV; |
|
0 commit comments