Răsfoiți Sursa

obs-webrtc: Add Simulcast Support

Sean DuBois 1 an în urmă
părinte
comite
cd4d624ec3

+ 3 - 0
frontend/data/locale/en-US.ini

@@ -1014,6 +1014,9 @@ Basic.Settings.Stream.MultitrackVideoConfigOverride="Config Override (JSON)"
 Basic.Settings.Stream.MultitrackVideoConfigOverrideEnable="Enable Config Override"
 Basic.Settings.Stream.MultitrackVideoLabel="Multitrack Video"
 Basic.Settings.Stream.MultitrackVideoExtraCanvas="Additional Canvas"
+Basic.Settings.Stream.WHIPSimulcastLabel="Simulcast"
+Basic.Settings.Stream.WHIPSimulcastInfo="Simulcast allows you to encode and send multiple video qualities. <a href='https://obsproject.com/kb/whip-streaming-guide'>Learn More</a>"
+Basic.Settings.Stream.WHIPSimulcastTotalLayers="Total Layers"
 Basic.Settings.Stream.AdvancedOptions="Advanced Options"
 
 # basic mode 'output' settings

+ 94 - 0
frontend/forms/OBSBasicSettings.ui

@@ -2082,6 +2082,100 @@
                 </layout>
                </widget>
               </item>
+              <item>
+               <widget class="QGroupBox" name="whipSimulcastGroupBox">
+                <property name="title">
+                 <string>Basic.Settings.Stream.WHIPSimulcastLabel</string>
+                </property>
+                <layout class="QVBoxLayout" name="verticalLayout_35">
+                 <property name="leftMargin">
+                  <number>9</number>
+                 </property>
+                 <property name="topMargin">
+                  <number>2</number>
+                 </property>
+                 <property name="rightMargin">
+                  <number>9</number>
+                 </property>
+                 <property name="bottomMargin">
+                  <number>9</number>
+                 </property>
+                 <item>
+                  <layout class="QHBoxLayout" name="horizontalLayout_33">
+                   <item>
+                    <spacer name="horizontalSpacer_33">
+                     <property name="orientation">
+                      <enum>Qt::Horizontal</enum>
+                     </property>
+                     <property name="sizeType">
+                      <enum>QSizePolicy::Fixed</enum>
+                     </property>
+                     <property name="sizeHint" stdset="0">
+                      <size>
+                       <width>170</width>
+                       <height>10</height>
+                      </size>
+                     </property>
+                    </spacer>
+                   </item>
+                   <item>
+                    <widget class="QLabel" name="whipSimulcastInfo">
+                     <property name="text">
+                      <string>Basic.Settings.Stream.WHIPSimulcastInfo</string>
+                     </property>
+                     <property name="textFormat">
+                      <enum>Qt::RichText</enum>
+                     </property>
+                     <property name="wordWrap">
+                      <bool>true</bool>
+                     </property>
+                     <property name="openExternalLinks">
+                      <bool>true</bool>
+                     </property>
+                    </widget>
+                   </item>
+                  </layout>
+                 </item>
+                 <item>
+                  <layout class="QFormLayout" name="formLayout_39">
+                   <property name="fieldGrowthPolicy">
+                    <enum>QFormLayout::AllNonFixedFieldsGrow</enum>
+                   </property>
+                   <item row="1" column="0">
+                    <widget class="QLabel" name="whipSimulcastTotalLayersLabel">
+                     <property name="text">
+                      <string>Basic.Settings.Stream.WHIPSimulcastTotalLayers</string>
+                     </property>
+                     <property name="minimumSize">
+                      <size>
+                       <width>170</width>
+                       <height>0</height>
+                      </size>
+                     </property>
+                    </widget>
+                   </item>
+                   <item row="1" column="1">
+                    <layout class="QHBoxLayout" name="horizontalLayout_34" stretch="0,0">
+                     <item>
+                      <widget class="QSpinBox" name="whipSimulcastTotalLayers">
+                       <property name="minimum">
+                        <number>1</number>
+                       </property>
+                       <property name="maximum">
+                        <number>4</number>
+                       </property>
+                       <property name="value">
+                        <number>1</number>
+                       </property>
+                      </widget>
+                     </item>
+                    </layout>
+                   </item>
+                  </layout>
+                 </item>
+                </layout>
+               </widget>
+               </item>
               <item>
                <widget class="QGroupBox" name="serviceAdvancedOptionsGroupBox">
                 <property name="title">

+ 1 - 0
frontend/settings/OBSBasicSettings.cpp

@@ -385,6 +385,7 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 	HookWidget(ui->authUsername,         EDIT_CHANGED,   STREAM1_CHANGED);
 	HookWidget(ui->authPw,               EDIT_CHANGED,   STREAM1_CHANGED);
 	HookWidget(ui->ignoreRecommended,    CHECK_CHANGED,  STREAM1_CHANGED);
+	HookWidget(ui->whipSimulcastTotalLayers, SCROLL_CHANGED, STREAM1_CHANGED);
 	HookWidget(ui->enableMultitrackVideo,      CHECK_CHANGED,  STREAM1_CHANGED);
 	HookWidget(ui->multitrackVideoMaximumAggregateBitrateAuto, CHECK_CHANGED,  STREAM1_CHANGED);
 	HookWidget(ui->multitrackVideoMaximumAggregateBitrate,     SCROLL_CHANGED, STREAM1_CHANGED);

+ 18 - 3
frontend/settings/OBSBasicSettings_Stream.cpp

@@ -95,6 +95,7 @@ void OBSBasicSettings::InitStreamPage()
 void OBSBasicSettings::LoadStream1Settings()
 {
 	bool ignoreRecommended = config_get_bool(main->Config(), "Stream1", "IgnoreRecommended");
+	int whipSimulcastTotalLayers = config_get_int(main->Config(), "Stream1", "WHIPSimulcastTotalLayers");
 
 	obs_service_t *service_obj = main->GetService();
 	const char *type = obs_service_get_type(service_obj);
@@ -209,10 +210,13 @@ void OBSBasicSettings::LoadStream1Settings()
 	if (use_custom_server)
 		ui->serviceCustomServer->setText(server);
 
-	if (is_whip)
+	if (is_whip) {
 		ui->key->setText(bearer_token);
-	else
+		ui->whipSimulcastGroupBox->show();
+	} else {
 		ui->key->setText(key);
+		ui->whipSimulcastGroupBox->hide();
+	}
 
 	ServiceChanged(true);
 
@@ -226,6 +230,7 @@ void OBSBasicSettings::LoadStream1Settings()
 	ui->streamPage->setEnabled(!streamActive);
 
 	ui->ignoreRecommended->setChecked(ignoreRecommended);
+	ui->whipSimulcastTotalLayers->setValue(whipSimulcastTotalLayers);
 
 	loading = false;
 
@@ -327,6 +332,9 @@ void OBSBasicSettings::SaveStream1Settings()
 
 	SaveCheckBox(ui->ignoreRecommended, "Stream1", "IgnoreRecommended");
 
+	auto oldWHIPSimulcastTotalLayers = config_get_int(main->Config(), "Stream1", "WHIPSimulcastTotalLayers");
+	SaveSpinBox(ui->whipSimulcastTotalLayers, "Stream1", "WHIPSimulcastTotalLayers");
+
 	auto oldMultitrackVideoSetting = config_get_bool(main->Config(), "Stream1", "EnableMultitrackVideo");
 
 	if (!IsCustomService()) {
@@ -355,7 +363,8 @@ void OBSBasicSettings::SaveStream1Settings()
 	SaveText(ui->multitrackVideoConfigOverride, "Stream1", "MultitrackVideoConfigOverride");
 	SaveComboData(ui->multitrackVideoAdditionalCanvas, "Stream1", "MultitrackExtraCanvas");
 
-	if (oldMultitrackVideoSetting != ui->enableMultitrackVideo->isChecked())
+	if (oldMultitrackVideoSetting != ui->enableMultitrackVideo->isChecked() ||
+	    oldWHIPSimulcastTotalLayers != ui->whipSimulcastTotalLayers->value())
 		main->ResetOutputs();
 
 	SwapMultiTrack(QT_TO_UTF8(protocol));
@@ -588,6 +597,12 @@ void OBSBasicSettings::on_service_currentIndexChanged(int idx)
 	} else {
 		SwapMultiTrack(QT_TO_UTF8(protocol));
 	}
+
+	if (IsWHIP()) {
+		ui->whipSimulcastGroupBox->show();
+	} else {
+		ui->whipSimulcastGroupBox->hide();
+	}
 }
 
 void OBSBasicSettings::on_customServer_textChanged(const QString &)

+ 12 - 0
frontend/utility/AdvancedOutput.cpp

@@ -132,6 +132,12 @@ AdvancedOutput::AdvancedOutput(OBSBasic *main_) : BasicOutputHandler(main_)
 		throw "Failed to create streaming video encoder "
 		      "(advanced output)";
 	obs_encoder_release(videoStreaming);
+	if (whipSimulcastEncoders != nullptr) {
+		whipSimulcastEncoders->Create(streamEncoder, config_get_int(main->Config(), "AdvOut", "RescaleFilter"),
+					      config_get_int(main->Config(), "Stream1", "WHIPSimulcastTotalLayers"),
+					      video_output_get_width(obs_get_video()),
+					      video_output_get_height(obs_get_video()));
+	}
 
 	const char *rate_control =
 		obs_data_get_string(useStreamEncoder ? streamEncSettings : recordEncSettings, "rate_control");
@@ -247,6 +253,9 @@ void AdvancedOutput::UpdateStreamSettings()
 	}
 
 	obs_encoder_update(videoStreaming, settings);
+	if (whipSimulcastEncoders != nullptr) {
+		whipSimulcastEncoders->Update(settings, obs_data_get_int(settings, "bitrate"));
+	}
 }
 
 inline void AdvancedOutput::UpdateRecordingSettings()
@@ -649,6 +658,9 @@ std::shared_future<void> AdvancedOutput::SetupStreaming(obs_service_t *service,
 		}
 
 		obs_output_set_video_encoder(streamOutput, videoStreaming);
+		if (whipSimulcastEncoders != nullptr) {
+			whipSimulcastEncoders->SetStreamOutput(streamOutput);
+		}
 		obs_output_set_audio_encoder(streamOutput, streamAudioEnc, 0);
 
 		if (!is_multitrack_output) {

+ 3 - 0
frontend/utility/BasicOutputHandler.cpp

@@ -236,6 +236,9 @@ BasicOutputHandler::BasicOutputHandler(OBSBasic *main_) : main(main_)
 
 	if (multitrack_enabled)
 		multitrackVideo = make_unique<MultitrackVideoOutput>();
+
+	if (config_get_int(main->Config(), "Stream1", "WHIPSimulcastTotalLayers") > 1)
+		whipSimulcastEncoders = make_unique<WHIPSimulcastEncoders>();
 }
 
 extern void log_vcam_changed(const VCamConfig &config, bool starting);

+ 3 - 0
frontend/utility/BasicOutputHandler.hpp

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <utility/MultitrackVideoOutput.hpp>
+#include <utility/WHIPSimulcastEncoders.hpp>
 
 #include <obs.hpp>
 #include <util/dstr.hpp>
@@ -42,6 +43,8 @@ struct BasicOutputHandler {
 	obs_scene_t *vCamSourceScene = nullptr;
 	obs_sceneitem_t *vCamSourceSceneItem = nullptr;
 
+	std::unique_ptr<WHIPSimulcastEncoders> whipSimulcastEncoders;
+
 	std::string outputType;
 	std::string lastError;
 

+ 17 - 0
frontend/utility/SimpleOutput.cpp

@@ -75,6 +75,13 @@ void SimpleOutput::LoadStreamingPreset_Lossy(const char *encoderId)
 	if (!videoStreaming)
 		throw "Failed to create video streaming encoder (simple output)";
 	obs_encoder_release(videoStreaming);
+
+	if (whipSimulcastEncoders != nullptr) {
+		whipSimulcastEncoders->Create(encoderId, config_get_int(main->Config(), "AdvOut", "RescaleFilter"),
+					      config_get_int(main->Config(), "Stream1", "WHIPSimulcastTotalLayers"),
+					      video_output_get_width(obs_get_video()),
+					      video_output_get_height(obs_get_video()));
+	}
 }
 
 /* mistakes have been made to lead us to this. */
@@ -351,11 +358,18 @@ void SimpleOutput::Update()
 		break;
 	default:
 		obs_encoder_set_preferred_video_format(videoStreaming, VIDEO_FORMAT_NV12);
+		if (whipSimulcastEncoders != nullptr) {
+			whipSimulcastEncoders->SetVideoFormat(VIDEO_FORMAT_NV12);
+		}
 	}
 
 	obs_encoder_update(videoStreaming, videoSettings);
 	obs_encoder_update(audioStreaming, audioSettings);
 	obs_encoder_update(audioArchive, audioSettings);
+
+	if (whipSimulcastEncoders != nullptr) {
+		whipSimulcastEncoders->Update(videoSettings, videoBitrate);
+	}
 }
 
 void SimpleOutput::UpdateRecordingAudioSettings()
@@ -630,6 +644,9 @@ std::shared_future<void> SimpleOutput::SetupStreaming(obs_service_t *service, Se
 		}
 
 		obs_output_set_video_encoder(streamOutput, videoStreaming);
+		if (whipSimulcastEncoders != nullptr) {
+			whipSimulcastEncoders->SetStreamOutput(streamOutput);
+		}
 		obs_output_set_audio_encoder(streamOutput, audioStreaming, 0);
 		obs_output_set_service(streamOutput, service);
 		return true;

+ 84 - 0
frontend/utility/WHIPSimulcastEncoders.hpp

@@ -0,0 +1,84 @@
+/******************************************************************************
+    Copyright (C) 2025 by Sean DuBois <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+#pragma once
+
+struct WHIPSimulcastEncoders {
+public:
+	void Create(const char *encoderId, int rescaleFilter, int whipSimulcastTotalLayers, uint32_t outputWidth,
+		    uint32_t outputHeight)
+	{
+		if (rescaleFilter == OBS_SCALE_DISABLE) {
+			rescaleFilter = OBS_SCALE_BICUBIC;
+		}
+
+		if (whipSimulcastTotalLayers <= 1) {
+			return;
+		}
+
+		auto widthStep = outputWidth / whipSimulcastTotalLayers;
+		auto heightStep = outputHeight / whipSimulcastTotalLayers;
+		std::string encoder_name = "whip_simulcast_0";
+
+		for (auto i = whipSimulcastTotalLayers - 1; i > 0; i--) {
+			uint32_t width = widthStep * i;
+			width -= width % 2;
+
+			uint32_t height = heightStep * i;
+			height -= height % 2;
+
+			encoder_name[encoder_name.size() - 1] = std::to_string(i).at(0);
+			auto whip_simulcast_encoder =
+				obs_video_encoder_create(encoderId, encoder_name.c_str(), nullptr, nullptr);
+
+			if (whip_simulcast_encoder) {
+				obs_encoder_set_video(whip_simulcast_encoder, obs_get_video());
+				obs_encoder_set_scaled_size(whip_simulcast_encoder, width, height);
+				obs_encoder_set_gpu_scale_type(whip_simulcast_encoder, (obs_scale_type)rescaleFilter);
+				whipSimulcastEncoders.push_back(whip_simulcast_encoder);
+				obs_encoder_release(whip_simulcast_encoder);
+			} else {
+				blog(LOG_WARNING,
+				     "Failed to create video streaming WHIP Simulcast encoders (BasicOutputHandler)");
+			}
+		}
+	}
+
+	void Update(obs_data_t *videoSettings, int videoBitrate)
+	{
+		auto bitrateStep = videoBitrate / static_cast<int>(whipSimulcastEncoders.size() + 1);
+		for (auto &whipSimulcastEncoder : whipSimulcastEncoders) {
+			videoBitrate -= bitrateStep;
+			obs_data_set_int(videoSettings, "bitrate", videoBitrate);
+			obs_encoder_update(whipSimulcastEncoder, videoSettings);
+		}
+	}
+
+	void SetVideoFormat(enum video_format format)
+	{
+		for (auto enc : whipSimulcastEncoders)
+			obs_encoder_set_preferred_video_format(enc, format);
+	}
+
+	void SetStreamOutput(obs_output_t *streamOutput)
+	{
+		for (size_t i = 0; i < whipSimulcastEncoders.size(); i++)
+			obs_output_set_video_encoder2(streamOutput, whipSimulcastEncoders[i], i + 1);
+	}
+
+private:
+	std::vector<OBSEncoder> whipSimulcastEncoders;
+};

+ 1 - 0
plugins/obs-webrtc/data/locale/en-US.ini

@@ -4,3 +4,4 @@ Service.BearerToken="Bearer Token"
 
 Error.InvalidSDP="WHIP server responded with invalid SDP: %1"
 Error.NoRemoteDescription="Failed to set remote description: %1"
+Error.SimulcastLayersRejected="WHIP server only accepted %1 simulcast layers"

+ 95 - 27
plugins/obs-webrtc/whip-output.cpp

@@ -26,6 +26,9 @@ const uint8_t video_payload_type = 96;
 // ~3 seconds of 8.5 Megabit video
 const int video_nack_buffer_size = 4000;
 
+const std::string rtpHeaderExtUriMid = "urn:ietf:params:rtp-hdrext:sdes:mid";
+const std::string rtpHeaderExtUriRid = "urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id";
+
 WHIPOutput::WHIPOutput(obs_data_t *, obs_output_t *output)
 	: output(output),
 	  endpoint_url(),
@@ -41,8 +44,7 @@ WHIPOutput::WHIPOutput(obs_data_t *, obs_output_t *output)
 	  total_bytes_sent(0),
 	  connect_time_ms(0),
 	  start_time_ns(0),
-	  last_audio_timestamp(0),
-	  last_video_timestamp(0)
+	  last_audio_timestamp(0)
 {
 }
 
@@ -59,6 +61,19 @@ bool WHIPOutput::Start()
 {
 	std::lock_guard<std::mutex> l(start_stop_mutex);
 
+	for (uint32_t idx = 0; idx < MAX_OUTPUT_VIDEO_ENCODERS; idx++) {
+		auto encoder = obs_output_get_video_encoder2(output, idx);
+		if (encoder == nullptr) {
+			break;
+		}
+
+		auto v = std::make_shared<videoLayerState>();
+		// base_ssrc is ssrc for audio track. We do `+ 1` for the video, then idx for each Simulcast layer.
+		v->ssrc = base_ssrc + 1 + idx;
+		v->rid = std::to_string(idx);
+		videoLayerStates[encoder] = v;
+	}
+
 	if (!obs_output_can_begin_data_capture(output, 0))
 		return false;
 	if (!obs_output_initialize_encoders(output, 0))
@@ -93,9 +108,25 @@ void WHIPOutput::Data(struct encoder_packet *packet)
 		Send(packet->data, packet->size, duration, audio_track, audio_sr_reporter);
 		last_audio_timestamp = packet->dts_usec;
 	} else if (video_track && packet->type == OBS_ENCODER_VIDEO) {
-		int64_t duration = packet->dts_usec - last_video_timestamp;
+		auto rtp_config = video_sr_reporter->rtpConfig;
+		auto videoLayerState = videoLayerStates[packet->encoder];
+		if (videoLayerState == nullptr) {
+			Stop(false);
+			obs_output_signal_stop(output, OBS_OUTPUT_ENCODE_ERROR);
+			return;
+		}
+
+		rtp_config->sequenceNumber = videoLayerState->sequenceNumber;
+		rtp_config->ssrc = videoLayerState->ssrc;
+		rtp_config->rid = videoLayerState->rid;
+		rtp_config->timestamp = videoLayerState->rtpTimestamp;
+		int64_t duration = packet->dts_usec - videoLayerState->lastVideoTimestamp;
+
 		Send(packet->data, packet->size, duration, video_track, video_sr_reporter);
-		last_video_timestamp = packet->dts_usec;
+
+		videoLayerState->sequenceNumber = rtp_config->sequenceNumber;
+		videoLayerState->lastVideoTimestamp = packet->dts_usec;
+		videoLayerState->rtpTimestamp = rtp_config->timestamp;
 	}
 }
 
@@ -142,6 +173,24 @@ void WHIPOutput::ConfigureVideoTrack(std::string media_stream_id, std::string cn
 	rtc::Description::Video video_description(video_mid, rtc::Description::Direction::SendOnly);
 	video_description.addSSRC(ssrc, cname, media_stream_id, media_stream_track_id);
 
+	video_description.addExtMap(rtc::Description::Entry::ExtMap(1, rtpHeaderExtUriMid));
+	video_description.addExtMap(rtc::Description::Entry::ExtMap(2, rtpHeaderExtUriRid));
+
+	if (videoLayerStates.size() >= 2) {
+		std::vector<std::pair<int, std::string>> sortedRids;
+
+		for (const auto &[encoder, state] : videoLayerStates) {
+			sortedRids.push_back({std::stoi(state->rid), state->rid});
+		}
+
+		std::sort(sortedRids.begin(), sortedRids.end(),
+			  [](const auto &a, const auto &b) { return a.first < b.first; });
+
+		for (const auto &[_, rid] : sortedRids) {
+			video_description.addRid(rid);
+		}
+	}
+
 	auto rtp_config = std::make_shared<rtc::RtpPacketizationConfig>(ssrc, cname, video_payload_type,
 #if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 22 || RTC_VERSION_MAJOR > 0
 									rtc::H264RtpPacketizer::ClockRate);
@@ -149,6 +198,10 @@ void WHIPOutput::ConfigureVideoTrack(std::string media_stream_id, std::string cn
 									rtc::H264RtpPacketizer::defaultClockRate);
 #endif
 
+	rtp_config->midId = 1;
+	rtp_config->ridId = 2;
+	rtp_config->mid = video_mid;
+
 	const obs_encoder_t *encoder = obs_output_get_video_encoder2(output, 0);
 	if (!encoder)
 		return;
@@ -372,16 +425,26 @@ bool WHIPOutput::Connect()
 	curl_easy_setopt(c, CURLOPT_UNRESTRICTED_AUTH, 1L);
 	curl_easy_setopt(c, CURLOPT_ERRORBUFFER, error_buffer);
 
-	auto cleanup = [&]() {
+	auto doCleanup = [&](bool connectFailed) {
 		curl_easy_cleanup(c);
 		curl_slist_free_all(headers);
+		if (connectFailed) {
+			obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED);
+		}
+	};
+
+	auto displayError = [&](const char *what, const char *errorMessage) {
+		struct dstr error_message;
+		dstr_init_copy(&error_message, obs_module_text(errorMessage));
+		dstr_replace(&error_message, "%1", what);
+		obs_output_set_last_error(output, error_message.array);
+		dstr_free(&error_message);
 	};
 
 	CURLcode res = curl_easy_perform(c);
 	if (res != CURLE_OK) {
 		do_log(LOG_ERROR, "Connect failed: %s", error_buffer[0] ? error_buffer : curl_easy_strerror(res));
-		cleanup();
-		obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED);
+		doCleanup(true);
 		return false;
 	}
 
@@ -389,15 +452,14 @@ bool WHIPOutput::Connect()
 	curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &response_code);
 	if (response_code != 201) {
 		do_log(LOG_ERROR, "Connect failed: HTTP endpoint returned response code %ld", response_code);
-		cleanup();
+		doCleanup(false);
 		obs_output_signal_stop(output, OBS_OUTPUT_INVALID_STREAM);
 		return false;
 	}
 
 	if (read_buffer.empty()) {
 		do_log(LOG_ERROR, "Connect failed: No data returned from HTTP endpoint request");
-		cleanup();
-		obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED);
+		doCleanup(true);
 		return false;
 	}
 
@@ -417,8 +479,7 @@ bool WHIPOutput::Connect()
 
 	if (location_header_count < static_cast<size_t>(redirect_count) + 1) {
 		do_log(LOG_ERROR, "WHIP server did not provide a resource URL via the Location header");
-		cleanup();
-		obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED);
+		doCleanup(true);
 		return false;
 	}
 
@@ -446,8 +507,7 @@ bool WHIPOutput::Connect()
 		curl_easy_getinfo(c, CURLINFO_EFFECTIVE_URL, &effective_url);
 		if (effective_url == nullptr) {
 			do_log(LOG_ERROR, "Failed to build Resource URL");
-			cleanup();
-			obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED);
+			doCleanup(true);
 			return false;
 		}
 
@@ -462,8 +522,7 @@ bool WHIPOutput::Connect()
 	CURLUcode rc = curl_url_get(url_builder, CURLUPART_URL, &url, CURLU_NO_DEFAULT_PORT);
 	if (rc) {
 		do_log(LOG_ERROR, "WHIP server provided a invalid resource URL via the Location header");
-		cleanup();
-		obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED);
+		doCleanup(true);
 		return false;
 	}
 
@@ -479,31 +538,40 @@ bool WHIPOutput::Connect()
 	auto response = std::string(read_buffer);
 	response.erase(0, response.find("v=0"));
 
+	// If we are sending multiple layers assert that the remote accepted them all
+	if (videoLayerStates.size() != 1) {
+		auto layersAccepted = simulcast_layers_in_answer(response);
+		if (videoLayerStates.size() != layersAccepted) {
+			do_log(LOG_ERROR, "WHIP only accepted %lu layers", layersAccepted);
+			displayError(std::to_string(layersAccepted).c_str(), "Error.SimulcastLayersRejected");
+			doCleanup(true);
+			return false;
+		}
+	}
+
 	rtc::Description answer(response, "answer");
 	try {
 		peer_connection->setRemoteDescription(answer);
 	} catch (const std::invalid_argument &err) {
 		do_log(LOG_ERROR, "WHIP server responded with invalid SDP: %s", err.what());
-		cleanup();
+		doCleanup(true);
 		struct dstr error_message;
 		dstr_init_copy(&error_message, obs_module_text("Error.InvalidSDP"));
 		dstr_replace(&error_message, "%1", err.what());
 		obs_output_set_last_error(output, error_message.array);
 		dstr_free(&error_message);
-		obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED);
 		return false;
 	} catch (const std::exception &err) {
 		do_log(LOG_ERROR, "Failed to set remote description: %s", err.what());
-		cleanup();
+		doCleanup(true);
 		struct dstr error_message;
 		dstr_init_copy(&error_message, obs_module_text("Error.NoRemoteDescription"));
 		dstr_replace(&error_message, "%1", err.what());
 		obs_output_set_last_error(output, error_message.array);
 		dstr_free(&error_message);
-		obs_output_signal_stop(output, OBS_OUTPUT_CONNECT_FAILED);
 		return false;
 	}
-	cleanup();
+	doCleanup(false);
 
 #if RTC_VERSION_MAJOR == 0 && RTC_VERSION_MINOR > 20 || RTC_VERSION_MAJOR > 0
 	peer_connection->gatherLocalCandidates(iceServers);
@@ -557,7 +625,7 @@ void WHIPOutput::SendDelete()
 	curl_easy_setopt(c, CURLOPT_TIMEOUT, 8L);
 	curl_easy_setopt(c, CURLOPT_ERRORBUFFER, error_buffer);
 
-	auto cleanup = [&]() {
+	auto doCleanup = [&]() {
 		curl_easy_cleanup(c);
 		curl_slist_free_all(headers);
 	};
@@ -566,7 +634,7 @@ void WHIPOutput::SendDelete()
 	if (res != CURLE_OK) {
 		do_log(LOG_WARNING, "DELETE request for resource URL failed: %s",
 		       error_buffer[0] ? error_buffer : curl_easy_strerror(res));
-		cleanup();
+		doCleanup();
 		return;
 	}
 
@@ -574,13 +642,13 @@ void WHIPOutput::SendDelete()
 	curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &response_code);
 	if (response_code != 200) {
 		do_log(LOG_WARNING, "DELETE request for resource URL failed. HTTP Code: %ld", response_code);
-		cleanup();
+		doCleanup();
 		return;
 	}
 
 	do_log(LOG_DEBUG, "Successfully performed DELETE request for resource URL");
 	resource_url.clear();
-	cleanup();
+	doCleanup();
 }
 
 void WHIPOutput::StopThread(bool signal)
@@ -611,7 +679,7 @@ void WHIPOutput::StopThread(bool signal)
 	connect_time_ms = 0;
 	start_time_ns = 0;
 	last_audio_timestamp = 0;
-	last_video_timestamp = 0;
+	videoLayerStates.clear();
 }
 
 void WHIPOutput::Send(void *data, uintptr_t size, uint64_t duration, std::shared_ptr<rtc::Track> track,
@@ -652,7 +720,7 @@ void WHIPOutput::Send(void *data, uintptr_t size, uint64_t duration, std::shared
 
 void register_whip_output()
 {
-	const uint32_t base_flags = OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE;
+	const uint32_t base_flags = OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE | OBS_OUTPUT_MULTI_TRACK_AV;
 
 	const char *audio_codecs = "opus";
 #ifdef ENABLE_HEVC

+ 11 - 2
plugins/obs-webrtc/whip-output.h

@@ -10,9 +10,18 @@
 #include <atomic>
 #include <mutex>
 #include <thread>
+#include <algorithm>
 
 #include <rtc/rtc.hpp>
 
+struct videoLayerState {
+	uint16_t sequenceNumber;
+	uint32_t rtpTimestamp;
+	int64_t lastVideoTimestamp;
+	uint32_t ssrc;
+	std::string rid;
+};
+
 class WHIPOutput {
 public:
 	WHIPOutput(obs_data_t *settings, obs_output_t *output);
@@ -36,7 +45,6 @@ private:
 	void SendDelete();
 	void StopThread(bool signal);
 	void ParseLinkHeader(std::string linkHeader, std::vector<rtc::IceServer> &iceServers);
-
 	void Send(void *data, uintptr_t size, uint64_t duration, std::shared_ptr<rtc::Track> track,
 		  std::shared_ptr<rtc::RtcpSrReporter> rtcp_sr_reporter);
 
@@ -58,11 +66,12 @@ private:
 	std::shared_ptr<rtc::RtcpSrReporter> audio_sr_reporter;
 	std::shared_ptr<rtc::RtcpSrReporter> video_sr_reporter;
 
+	std::map<obs_encoder_t *, std::shared_ptr<videoLayerState>> videoLayerStates;
+
 	std::atomic<size_t> total_bytes_sent;
 	std::atomic<int> connect_time_ms;
 	int64_t start_time_ns;
 	int64_t last_audio_timestamp;
-	int64_t last_video_timestamp;
 };
 
 void register_whip_output();

+ 22 - 0
plugins/obs-webrtc/whip-utils.h

@@ -83,3 +83,25 @@ static inline std::string generate_user_agent()
 
 	return ua.str();
 }
+
+static size_t simulcast_layers_in_answer(std::string answer)
+{
+	auto layersStart = answer.find("a=simulcast");
+	if (layersStart == std::string::npos) {
+		return 0;
+	}
+
+	auto layersEnd = answer.find("\r\n", layersStart);
+	if (layersEnd == std::string::npos) {
+		return 0;
+	}
+
+	size_t layersAccepted = 1;
+	for (auto i = layersStart; i < layersEnd; i++) {
+		if (answer[i] == ';') {
+			layersAccepted++;
+		}
+	}
+
+	return layersAccepted;
+}