Browse Source

UI: Add eRTMP Multitrack Video Output

Ruwen Hahn 1 năm trước cách đây
mục cha
commit
c8950900c3

+ 18 - 0
UI/CMakeLists.txt

@@ -84,6 +84,24 @@ target_sources(
           ui-validation.cpp
           ui-validation.hpp)
 
+target_sources(
+  obs-studio
+  PRIVATE # cmake-format: sortable
+          goliveapi-censoredjson.cpp
+          goliveapi-censoredjson.hpp
+          goliveapi-network.cpp
+          goliveapi-network.hpp
+          goliveapi-postdata.cpp
+          goliveapi-postdata.hpp
+          models/multitrack-video.hpp
+          multitrack-video-error.cpp
+          multitrack-video-error.hpp
+          multitrack-video-output.cpp
+          multitrack-video-output.hpp
+          qt-helpers.cpp
+          qt-helpers.hpp
+          system-info.hpp)
+
 if(OS_WINDOWS)
   include(cmake/os-windows.cmake)
 elseif(OS_MACOS)

+ 10 - 0
UI/api-interface.cpp

@@ -482,6 +482,16 @@ struct OBSStudioAPI : obs_frontend_callbacks {
 
 	obs_output_t *obs_frontend_get_streaming_output(void) override
 	{
+		auto multitrackVideo =
+			main->outputHandler->multitrackVideo.get();
+		auto mtvOutput =
+			multitrackVideo
+				? obs_output_get_ref(
+					  multitrackVideo->StreamingOutput())
+				: nullptr;
+		if (mtvOutput)
+			return mtvOutput;
+
 		OBSOutput output = main->outputHandler->streamOutput.Get();
 		return obs_output_get_ref(output);
 	}

+ 23 - 0
UI/cmake/legacy.cmake

@@ -280,6 +280,23 @@ target_sources(
           window-remux.cpp
           window-remux.hpp)
 
+target_sources(
+  obs
+  PRIVATE # cmake-format: sortable
+          goliveapi-censoredjson.cpp
+          goliveapi-censoredjson.hpp
+          goliveapi-network.cpp
+          goliveapi-network.hpp
+          goliveapi-postdata.cpp
+          goliveapi-postdata.hpp
+          multitrack-video-error.cpp
+          multitrack-video-error.hpp
+          multitrack-video-output.cpp
+          multitrack-video-output.hpp
+          qt-helpers.cpp
+          qt-helpers.hpp
+          system-info.hpp)
+
 target_sources(obs PRIVATE importers/importers.cpp importers/importers.hpp importers/classic.cpp importers/sl.cpp
                            importers/studio.cpp importers/xsplit.cpp)
 
@@ -366,6 +383,8 @@ if(OS_WINDOWS)
             win-update/updater/manifest.hpp
             ${CMAKE_BINARY_DIR}/obs.rc)
 
+  target_sources(obs PRIVATE system-info-windows.cpp)
+
   find_package(MbedTLS)
   target_link_libraries(obs PRIVATE Mbedtls::Mbedtls nlohmann_json::nlohmann_json OBS::blake2 Detours::Detours)
 
@@ -426,6 +445,8 @@ elseif(OS_MACOS)
   target_sources(obs PRIVATE platform-osx.mm)
   target_sources(obs PRIVATE forms/OBSPermissions.ui window-permissions.cpp window-permissions.hpp)
 
+  target_sources(obs PRIVATE system-info-macos.mm)
+
   if(ENABLE_WHATSNEW)
     find_library(SECURITY Security)
     find_package(nlohmann_json REQUIRED)
@@ -462,6 +483,8 @@ elseif(OS_POSIX)
   target_sources(obs PRIVATE platform-x11.cpp)
   target_link_libraries(obs PRIVATE Qt::GuiPrivate Qt::DBus)
 
+  target_sources(obs PRIVATE system-info-posix.cpp)
+
   target_compile_definitions(obs PRIVATE OBS_INSTALL_PREFIX="${OBS_INSTALL_PREFIX}"
                                          "$<$<BOOL:${LINUX_PORTABLE}>:LINUX_PORTABLE>")
   if(TARGET obspython)

+ 2 - 0
UI/cmake/os-freebsd.cmake

@@ -2,6 +2,8 @@ target_sources(obs-studio PRIVATE platform-x11.cpp)
 target_compile_definitions(obs-studio PRIVATE OBS_INSTALL_PREFIX="${OBS_INSTALL_PREFIX}")
 target_link_libraries(obs-studio PRIVATE Qt::GuiPrivate procstat)
 
+target_sources(obs-studio PRIVATE system-info-posix.cpp)
+
 if(TARGET OBS::python)
   find_package(Python REQUIRED COMPONENTS Interpreter Development)
   target_link_libraries(obs-studio PRIVATE Python::Python)

+ 2 - 0
UI/cmake/os-linux.cmake

@@ -2,6 +2,8 @@ target_sources(obs-studio PRIVATE platform-x11.cpp)
 target_compile_definitions(obs-studio PRIVATE OBS_INSTALL_PREFIX="${OBS_INSTALL_PREFIX}")
 target_link_libraries(obs-studio PRIVATE Qt::GuiPrivate Qt::DBus)
 
+target_sources(obs-studio PRIVATE system-info-posix.cpp)
+
 if(TARGET OBS::python)
   find_package(Python REQUIRED COMPONENTS Interpreter Development)
   target_link_libraries(obs-studio PRIVATE Python::Python)

+ 2 - 0
UI/cmake/os-macos.cmake

@@ -3,6 +3,8 @@ include(cmake/feature-sparkle.cmake)
 target_sources(obs-studio PRIVATE platform-osx.mm forms/OBSPermissions.ui window-permissions.cpp window-permissions.hpp)
 target_compile_options(obs-studio PRIVATE -Wno-quoted-include-in-framework-header -Wno-comma)
 
+target_sources(obs-studio PRIVATE system-info-macos.mm)
+
 set_source_files_properties(platform-osx.mm PROPERTIES COMPILE_FLAGS -fobjc-arc)
 
 if(CMAKE_C_COMPILER_VERSION VERSION_GREATER_EQUAL 14.0.3)

+ 2 - 0
UI/cmake/os-windows.cmake

@@ -33,6 +33,8 @@ target_sources(
           win-dll-blocklist.c
           win-update/updater/manifest.hpp)
 
+target_sources(obs-studio PRIVATE system-info-windows.cpp)
+
 target_link_libraries(obs-studio PRIVATE crypt32 OBS::blake2 OBS::w32-pthreads MbedTLS::MbedTLS
                                          nlohmann_json::nlohmann_json Detours::Detours)
 

+ 27 - 0
UI/data/locale/en-US.ini

@@ -721,6 +721,7 @@ Basic.Main.Scenes="Scenes"
 Basic.Main.Sources="Sources"
 Basic.Main.Source="Source"
 Basic.Main.Controls="Controls"
+Basic.Main.PreparingStream="Preparing..."
 Basic.Main.Connecting="Connecting..."
 Basic.Main.StartRecording="Start Recording"
 Basic.Main.StartReplayBuffer="Start Replay Buffer"
@@ -1543,3 +1544,29 @@ YouTube.Errors.rateLimitExceeded="You are sending messages too quickly."
 # Browser Dock
 YouTube.DocksRemoval.Title="Clear Legacy YouTube Browser Docks"
 YouTube.DocksRemoval.Text="These browser docks will be removed as deprecated:\n\n%1\nUse \"Docks/YouTube Live Control Room\" instead."
+
+# MultitrackVideo
+ConfigDownload.WarningMessageTitle="Warning"
+FailedToStartStream.MissingConfigURL="No config URL available for the current service"
+FailedToStartStream.NoCustomRTMPURLInSettings="Custom RTMP URL not specified"
+FailedToStartStream.InvalidCustomConfig="Invalid custom config"
+FailedToStartStream.FailedToCreateMultitrackVideoService="Failed to create multitrack video service"
+FailedToStartStream.FailedToCreateMultitrackVideoOutput="Failed to create multitrack video rtmp output"
+FailedToStartStream.EncoderNotAvailable="NVENC not available.\n\nFailed to find encoder type '%1'"
+FailedToStartStream.FailedToCreateVideoEncoder="Failed to create video encoder '%1' (type: '%2')"
+FailedToStartStream.FailedToGetOBSVideoInfo="Failed to get obs video info while creating encoder '%1' (type: '%2')"
+FailedToStartStream.FailedToCreateAudioEncoder="Failed to create audio encoder"
+FailedToStartStream.NoRTMPURLInConfig="Config does not contain stream target RTMP(S) URL"
+FailedToStartStream.FallbackToDefault="Starting the stream using %1 failed; do you want to retry using single encode settings?"
+FailedToStartStream.ConfigRequestFailed="Could not fetch config from %1<br><br>HTTP error: %2"
+FailedToStartStream.WarningUnknownStatus="Received unknown status value '%1'"
+FailedToStartStream.WarningRetryNonMultitrackVideo="\n<br><br>\nDo you want to continue streaming without %1?"
+FailedToStartStream.WarningRetry="\n<br><br>\nDo you want to continue streaming?"
+FailedToStartStream.MissingEncoderConfigs="Go live config did not include encoder configurations"
+FailedToStartStream.StatusMissingHTML="Go live request returned an unspecified error"
+FailedToStartStream.NoConfigSupplied="Missing config"
+MultitrackVideo.Info="%1 automatically optimizes your settings to encode and send multiple video qualities. Selecting this option will send %2 information about your computer and software setup."
+MultitrackVideo.IncompatibleSettings.Title="Incompatible Settings"
+MultitrackVideo.IncompatibleSettings.Text="%1 is not currently compatible with:\n\n%2\nTo continue streaming with %1, disable incompatible settings:\n\n%3\nand Start Streaming again."
+MultitrackVideo.IncompatibleSettings.DisableAndStartStreaming="Disable for this stream and Start Streaming"
+MultitrackVideo.IncompatibleSettings.UpdateAndStartStreaming="Update Settings and Start Streaming"

+ 89 - 0
UI/goliveapi-censoredjson.cpp

@@ -0,0 +1,89 @@
+#include "goliveapi-censoredjson.hpp"
+#include <unordered_map>
+#include <nlohmann/json.hpp>
+
+void censorRecurse(obs_data_t *);
+void censorRecurseArray(obs_data_array_t *);
+
+void censorRecurse(obs_data_t *data)
+{
+	// if we found what we came to censor, censor it
+	const char *a = obs_data_get_string(data, "authentication");
+	if (a && *a) {
+		obs_data_set_string(data, "authentication", "CENSORED");
+	}
+
+	// recurse to child objects and arrays
+	obs_data_item_t *item = obs_data_first(data);
+	for (; item != NULL; obs_data_item_next(&item)) {
+		enum obs_data_type typ = obs_data_item_gettype(item);
+
+		if (typ == OBS_DATA_OBJECT) {
+			obs_data_t *child_data = obs_data_item_get_obj(item);
+			censorRecurse(child_data);
+			obs_data_release(child_data);
+		} else if (typ == OBS_DATA_ARRAY) {
+			obs_data_array_t *child_array =
+				obs_data_item_get_array(item);
+			censorRecurseArray(child_array);
+			obs_data_array_release(child_array);
+		}
+	}
+}
+
+void censorRecurseArray(obs_data_array_t *array)
+{
+	const size_t sz = obs_data_array_count(array);
+	for (size_t i = 0; i < sz; i++) {
+		obs_data_t *item = obs_data_array_item(array, i);
+		censorRecurse(item);
+		obs_data_release(item);
+	}
+}
+
+QString censoredJson(obs_data_t *data, bool pretty)
+{
+	if (!data) {
+		return "";
+	}
+
+	// Ugly clone via JSON write/read
+	const char *j = obs_data_get_json(data);
+	obs_data_t *clone = obs_data_create_from_json(j);
+
+	// Censor our copy
+	censorRecurse(clone);
+
+	// Turn our copy into JSON
+	QString s = pretty ? obs_data_get_json_pretty(clone)
+			   : obs_data_get_json(clone);
+
+	// Eliminate our copy
+	obs_data_release(clone);
+
+	return s;
+}
+
+using json = nlohmann::json;
+
+void censorRecurse(json &data)
+{
+	if (!data.is_structured())
+		return;
+
+	auto it = data.find("authentication");
+	if (it != data.end() && it->is_string()) {
+		*it = "CENSORED";
+	}
+
+	for (auto &child : data) {
+		censorRecurse(child);
+	}
+}
+
+QString censoredJson(json data, bool pretty)
+{
+	censorRecurse(data);
+
+	return QString::fromStdString(data.dump(pretty ? 4 : -1));
+}

+ 12 - 0
UI/goliveapi-censoredjson.hpp

@@ -0,0 +1,12 @@
+#pragma once
+
+#include <obs.hpp>
+#include <QString>
+#include <nlohmann/json_fwd.hpp>
+
+/**
+ * Returns the input serialized to JSON, but any non-empty "authorization"
+ * properties have their values replaced by "CENSORED".
+ */
+QString censoredJson(obs_data_t *data, bool pretty = false);
+QString censoredJson(nlohmann::json data, bool pretty = false);

+ 146 - 0
UI/goliveapi-network.cpp

@@ -0,0 +1,146 @@
+#include "goliveapi-network.hpp"
+#include "goliveapi-censoredjson.hpp"
+
+#include <obs.hpp>
+#include <obs-app.hpp>
+#include <remote-text.hpp>
+#include "multitrack-video-error.hpp"
+
+#include <qstring.h>
+#include <string>
+#include <QMessageBox>
+#include <QThreadPool>
+
+#include <nlohmann/json.hpp>
+
+using json = nlohmann::json;
+
+Qt::ConnectionType BlockingConnectionTypeFor(QObject *object);
+
+void HandleGoLiveApiErrors(QWidget *parent, const json &raw_json,
+			   const GoLiveApi::Config &config)
+{
+	using GoLiveApi::StatusResult;
+
+	if (!config.status)
+		return;
+
+	auto &status = *config.status;
+	if (status.result == StatusResult::Success)
+		return;
+
+	auto warn_continue = [&](QString message) {
+		bool ret = false;
+		QMetaObject::invokeMethod(
+			parent,
+			[=] {
+				QMessageBox mb(parent);
+				mb.setIcon(QMessageBox::Warning);
+				mb.setWindowTitle(QTStr(
+					"ConfigDownload.WarningMessageTitle"));
+				mb.setTextFormat(Qt::RichText);
+				mb.setText(
+					message +
+					QTStr("FailedToStartStream.WarningRetry"));
+				mb.setStandardButtons(
+					QMessageBox::StandardButton::Yes |
+					QMessageBox::StandardButton::No);
+				return mb.exec() ==
+				       QMessageBox::StandardButton::No;
+			},
+			BlockingConnectionTypeFor(parent), &ret);
+		if (ret)
+			throw MultitrackVideoError::cancel();
+	};
+
+	auto missing_html = [] {
+		return QTStr("FailedToStartStream.StatusMissingHTML")
+			.toStdString();
+	};
+
+	if (status.result == StatusResult::Unknown) {
+		return warn_continue(
+			QTStr("FailedToStartStream.WarningUnknownStatus")
+				.arg(raw_json["status"]["result"]
+					     .dump()
+					     .c_str()));
+
+	} else if (status.result == StatusResult::Warning) {
+		if (config.encoder_configurations.empty()) {
+			throw MultitrackVideoError::warning(
+				status.html_en_us.value_or(missing_html())
+					.c_str());
+		}
+
+		return warn_continue(
+			status.html_en_us.value_or(missing_html()).c_str());
+	} else if (status.result == StatusResult::Error) {
+		throw MultitrackVideoError::critical(
+			status.html_en_us.value_or(missing_html()).c_str());
+	}
+}
+
+GoLiveApi::Config DownloadGoLiveConfig(QWidget *parent, QString url,
+				       const GoLiveApi::PostData &post_data,
+				       const QString &multitrack_video_name)
+{
+	json post_data_json = post_data;
+	blog(LOG_INFO, "Go live POST data: %s",
+	     censoredJson(post_data_json).toUtf8().constData());
+
+	if (url.isEmpty())
+		throw MultitrackVideoError::critical(
+			QTStr("FailedToStartStream.MissingConfigURL"));
+
+	std::string encodeConfigText;
+	std::string libraryError;
+
+	std::vector<std::string> headers;
+	headers.push_back("Content-Type: application/json");
+	bool encodeConfigDownloadedOk = GetRemoteFile(
+		url.toLocal8Bit(), encodeConfigText,
+		libraryError, // out params
+		nullptr,
+		nullptr, // out params (response code and content type)
+		"POST", post_data_json.dump().c_str(), headers,
+		nullptr, // signature
+		5);      // timeout in seconds
+
+	if (!encodeConfigDownloadedOk)
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.ConfigRequestFailed")
+				.arg(url, libraryError.c_str()));
+	try {
+		auto data = json::parse(encodeConfigText);
+		blog(LOG_INFO, "Go live response data: %s",
+		     censoredJson(data, true).toUtf8().constData());
+		GoLiveApi::Config config = data;
+		HandleGoLiveApiErrors(parent, data, config);
+		return config;
+
+	} catch (const json::exception &e) {
+		blog(LOG_INFO, "Failed to parse go live config: %s", e.what());
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.FallbackToDefault")
+				.arg(multitrack_video_name));
+	}
+}
+
+QString MultitrackVideoAutoConfigURL(obs_service_t *service)
+{
+	static const QString url = [service]() -> QString {
+		auto args = qApp->arguments();
+		for (int i = 0; i < args.length() - 1; i++) {
+			if (args[i] == "--config-url" &&
+			    args.length() > (i + 1)) {
+				return args[i + 1];
+			}
+		}
+		OBSDataAutoRelease settings = obs_service_get_settings(service);
+		return obs_data_get_string(
+			settings, "multitrack_video_configuration_url");
+	}();
+
+	blog(LOG_INFO, "Go live URL: %s", url.toUtf8().constData());
+	return url;
+}

+ 16 - 0
UI/goliveapi-network.hpp

@@ -0,0 +1,16 @@
+#pragma once
+
+#include <obs.hpp>
+#include <QFuture>
+#include <QString>
+
+#include "models/multitrack-video.hpp"
+
+/** Returns either GO_LIVE_API_PRODUCTION_URL or a command line override. */
+QString MultitrackVideoAutoConfigURL(obs_service_t *service);
+
+class QWidget;
+
+GoLiveApi::Config DownloadGoLiveConfig(QWidget *parent, QString url,
+				       const GoLiveApi::PostData &post_data,
+				       const QString &multitrack_video_name);

+ 47 - 0
UI/goliveapi-postdata.cpp

@@ -0,0 +1,47 @@
+#include "goliveapi-postdata.hpp"
+
+#include <nlohmann/json.hpp>
+
+#include "system-info.hpp"
+
+#include "models/multitrack-video.hpp"
+
+GoLiveApi::PostData
+constructGoLivePost(QString streamKey,
+		    const std::optional<uint64_t> &maximum_aggregate_bitrate,
+		    const std::optional<uint32_t> &maximum_video_tracks,
+		    bool vod_track_enabled)
+{
+	GoLiveApi::PostData post_data{};
+	post_data.service = "IVS";
+	post_data.schema_version = "2023-05-10";
+	post_data.authentication = streamKey.toStdString();
+
+	system_info(post_data.capabilities);
+
+	auto &client = post_data.capabilities.client;
+
+	client.name = "obs-studio";
+	client.version = obs_get_version_string();
+	client.vod_track_audio = vod_track_enabled;
+
+	obs_video_info ovi;
+	if (obs_get_video_info(&ovi)) {
+		client.width = ovi.output_width;
+		client.height = ovi.output_height;
+		client.fps_numerator = ovi.fps_num;
+		client.fps_denominator = ovi.fps_den;
+
+		client.canvas_width = ovi.base_width;
+		client.canvas_height = ovi.base_height;
+	}
+
+	auto &preferences = post_data.preferences;
+	if (maximum_aggregate_bitrate.has_value())
+		preferences.maximum_aggregate_bitrate =
+			maximum_aggregate_bitrate.value();
+	if (maximum_video_tracks.has_value())
+		preferences.maximum_video_tracks = maximum_video_tracks.value();
+
+	return post_data;
+}

+ 12 - 0
UI/goliveapi-postdata.hpp

@@ -0,0 +1,12 @@
+#pragma once
+
+#include <obs.hpp>
+#include <optional>
+#include <QString>
+#include "models/multitrack-video.hpp"
+
+GoLiveApi::PostData
+constructGoLivePost(QString streamKey,
+		    const std::optional<uint64_t> &maximum_aggregate_bitrate,
+		    const std::optional<uint32_t> &maximum_video_tracks,
+		    bool vod_track_enabled);

+ 323 - 0
UI/models/multitrack-video.hpp

@@ -0,0 +1,323 @@
+/*
+ * Copyright (c) 2024 Ruwen Hahn <[email protected]>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#pragma once
+
+#include <string>
+#include <optional>
+
+#include <obs.h>
+
+#include <nlohmann/json.hpp>
+
+/* From whatsnew.hpp */
+#ifndef NLOHMANN_DEFINE_TYPE_INTRUSIVE
+#define NLOHMANN_DEFINE_TYPE_INTRUSIVE(Type, ...)                             \
+	friend void to_json(nlohmann::json &nlohmann_json_j,                  \
+			    const Type &nlohmann_json_t)                      \
+	{                                                                     \
+		NLOHMANN_JSON_EXPAND(                                         \
+			NLOHMANN_JSON_PASTE(NLOHMANN_JSON_TO, __VA_ARGS__))   \
+	}                                                                     \
+	friend void from_json(const nlohmann::json &nlohmann_json_j,          \
+			      Type &nlohmann_json_t)                          \
+	{                                                                     \
+		NLOHMANN_JSON_EXPAND(                                         \
+			NLOHMANN_JSON_PASTE(NLOHMANN_JSON_FROM, __VA_ARGS__)) \
+	}
+#endif
+
+#ifndef NLOHMANN_JSON_FROM_WITH_DEFAULT
+#define NLOHMANN_JSON_FROM_WITH_DEFAULT(v1) \
+	nlohmann_json_t.v1 =                \
+		nlohmann_json_j.value(#v1, nlohmann_json_default_obj.v1);
+#endif
+
+#ifndef NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT
+#define NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(Type, ...)              \
+	friend void to_json(nlohmann::json &nlohmann_json_j,                \
+			    const Type &nlohmann_json_t)                    \
+	{                                                                   \
+		NLOHMANN_JSON_EXPAND(                                       \
+			NLOHMANN_JSON_PASTE(NLOHMANN_JSON_TO, __VA_ARGS__)) \
+	}                                                                   \
+	friend void from_json(const nlohmann::json &nlohmann_json_j,        \
+			      Type &nlohmann_json_t)                        \
+	{                                                                   \
+		Type nlohmann_json_default_obj;                             \
+		NLOHMANN_JSON_EXPAND(NLOHMANN_JSON_PASTE(                   \
+			NLOHMANN_JSON_FROM_WITH_DEFAULT, __VA_ARGS__))      \
+	}
+
+#endif
+
+/*
+ * Support for (de-)serialising std::optional
+ * From https://github.com/nlohmann/json/issues/1749#issuecomment-1731266676
+ * whatsnew.hpp's version doesn't seem to work here
+ */
+template<typename T> struct nlohmann::adl_serializer<std::optional<T>> {
+	static void from_json(const json &j, std::optional<T> &opt)
+	{
+		if (j.is_null()) {
+			opt = std::nullopt;
+		} else {
+			opt = j.get<T>();
+		}
+	}
+	static void to_json(json &json, std::optional<T> t)
+	{
+		if (t) {
+			json = *t;
+		} else {
+			json = nullptr;
+		}
+	}
+};
+
+NLOHMANN_JSON_SERIALIZE_ENUM(obs_scale_type,
+			     {
+				     {OBS_SCALE_DISABLE, "OBS_SCALE_DISABLE"},
+				     {OBS_SCALE_POINT, "OBS_SCALE_POINT"},
+				     {OBS_SCALE_BICUBIC, "OBS_SCALE_BICUBIC"},
+				     {OBS_SCALE_BILINEAR, "OBS_SCALE_BILINEAR"},
+				     {OBS_SCALE_LANCZOS, "OBS_SCALE_LANCZOS"},
+				     {OBS_SCALE_AREA, "OBS_SCALE_AREA"},
+			     })
+
+NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(media_frames_per_second, numerator,
+				   denominator)
+
+namespace GoLiveApi {
+using std::string;
+using std::optional;
+using json = nlohmann::json;
+
+struct Client {
+	string name = "obs-studio";
+	string version;
+	bool vod_track_audio;
+	uint32_t width;
+	uint32_t height;
+	uint32_t fps_numerator;
+	uint32_t fps_denominator;
+	uint32_t canvas_width;
+	uint32_t canvas_height;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(Client, name, version, vod_track_audio,
+				       width, height, fps_numerator,
+				       fps_denominator, canvas_width,
+				       canvas_height)
+};
+
+struct Cpu {
+	int32_t physical_cores;
+	int32_t logical_cores;
+	optional<uint32_t> speed;
+	optional<string> name;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(Cpu, physical_cores, logical_cores,
+				       speed, name)
+};
+
+struct Memory {
+	uint64_t total;
+	uint64_t free;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(Memory, total, free)
+};
+
+struct Gpu {
+	string model;
+	uint32_t vendor_id;
+	uint32_t device_id;
+	uint64_t dedicated_video_memory;
+	uint64_t shared_system_memory;
+	string luid;
+	optional<string> driver_version;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(Gpu, model, vendor_id, device_id,
+				       dedicated_video_memory,
+				       shared_system_memory, luid,
+				       driver_version)
+};
+
+struct GamingFeatures {
+	optional<bool> game_bar_enabled;
+	optional<bool> game_dvr_allowed;
+	optional<bool> game_dvr_enabled;
+	optional<bool> game_dvr_bg_recording;
+	optional<bool> game_mode_enabled;
+	optional<bool> hags_enabled;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(GamingFeatures, game_bar_enabled,
+				       game_dvr_allowed, game_dvr_enabled,
+				       game_dvr_bg_recording, game_mode_enabled,
+				       hags_enabled)
+};
+
+struct System {
+	string version;
+	string name;
+	int build;
+	string release;
+	int revision;
+	int bits;
+	bool arm;
+	bool armEmulation;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(System, version, name, build, release,
+				       revision, bits, arm, armEmulation)
+};
+
+struct Capabilities {
+	Client client;
+	Cpu cpu;
+	Memory memory;
+	optional<GamingFeatures> gaming_features;
+	System system;
+	optional<std::vector<Gpu>> gpu;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(Capabilities, client, cpu, memory,
+				       gaming_features, system, gpu)
+};
+
+struct Preferences {
+	optional<uint64_t> maximum_aggregate_bitrate;
+	optional<uint32_t> maximum_video_tracks;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(Preferences, maximum_aggregate_bitrate,
+				       maximum_video_tracks)
+};
+
+struct PostData {
+	string service = "IVS";
+	string schema_version = "2023-05-10";
+	string authentication;
+
+	Capabilities capabilities;
+	Preferences preferences;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(PostData, service, schema_version,
+				       authentication, capabilities,
+				       preferences)
+};
+
+// Config Response
+
+struct Meta {
+	string service;
+	string schema_version;
+	string config_id;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(Meta, service, schema_version, config_id)
+};
+
+enum struct StatusResult {
+	Unknown,
+	Success,
+	Warning,
+	Error,
+};
+
+NLOHMANN_JSON_SERIALIZE_ENUM(StatusResult,
+			     {
+				     {StatusResult::Unknown, nullptr},
+				     {StatusResult::Success, "success"},
+				     {StatusResult::Warning, "warning"},
+				     {StatusResult::Error, "error"},
+			     })
+
+struct Status {
+	StatusResult result = StatusResult::Unknown;
+	optional<string> html_en_us;
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(Status, result, html_en_us)
+};
+
+struct IngestEndpoint {
+	string protocol;
+	string url_template;
+	optional<string> authentication;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(IngestEndpoint, protocol,
+						    url_template,
+						    authentication)
+};
+
+struct VideoEncoderConfiguration {
+	string type;
+	uint32_t width;
+	uint32_t height;
+	uint32_t bitrate;
+	optional<media_frames_per_second> framerate;
+	optional<obs_scale_type> gpu_scale_type;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(VideoEncoderConfiguration,
+						    type, width, height,
+						    bitrate, framerate,
+						    gpu_scale_type)
+};
+
+struct AudioEncoderConfiguration {
+	string codec;
+	uint32_t track_id;
+	uint32_t channels;
+	uint32_t bitrate;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE(AudioEncoderConfiguration, codec,
+				       track_id, channels, bitrate)
+};
+
+template<typename T> struct EncoderConfiguration {
+	T config;
+	json data;
+
+	friend void to_json(nlohmann::json &nlohmann_json_j,
+			    const EncoderConfiguration<T> &nlohmann_json_t)
+	{
+		nlohmann_json_j = nlohmann_json_t.data;
+		to_json(nlohmann_json_j, nlohmann_json_t.config);
+	}
+	friend void from_json(const nlohmann::json &nlohmann_json_j,
+			      EncoderConfiguration<T> &nlohmann_json_t)
+	{
+		nlohmann_json_t.data = nlohmann_json_j;
+		nlohmann_json_j.get_to(nlohmann_json_t.config);
+	}
+};
+
+struct AudioConfigurations {
+	std::vector<EncoderConfiguration<AudioEncoderConfiguration>> live;
+	std::vector<EncoderConfiguration<AudioEncoderConfiguration>> vod;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(AudioConfigurations, live,
+						    vod)
+};
+
+struct Config {
+	Meta meta;
+	optional<Status> status;
+	std::vector<IngestEndpoint> ingest_endpoints;
+	std::vector<EncoderConfiguration<VideoEncoderConfiguration>>
+		encoder_configurations;
+	AudioConfigurations audio_configurations;
+
+	NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(Config, meta, status,
+						    ingest_endpoints,
+						    encoder_configurations,
+						    audio_configurations)
+};
+} // namespace GoLiveApi

+ 46 - 0
UI/multitrack-video-error.cpp

@@ -0,0 +1,46 @@
+#include "multitrack-video-error.hpp"
+
+#include <QMessageBox>
+#include "obs-app.hpp"
+
+MultitrackVideoError MultitrackVideoError::critical(QString error)
+{
+	return {Type::Critical, error};
+}
+
+MultitrackVideoError MultitrackVideoError::warning(QString error)
+{
+	return {Type::Warning, error};
+}
+
+MultitrackVideoError MultitrackVideoError::cancel()
+{
+	return {Type::Cancel, {}};
+}
+
+bool MultitrackVideoError::ShowDialog(
+	QWidget *parent, const QString &multitrack_video_name) const
+{
+	QMessageBox mb(parent);
+	mb.setTextFormat(Qt::RichText);
+	mb.setWindowTitle(QTStr("Output.StartStreamFailed"));
+
+	if (type == Type::Warning) {
+		mb.setText(
+			error +
+			QTStr("FailedToStartStream.WarningRetryNonMultitrackVideo")
+				.arg(multitrack_video_name));
+		mb.setIcon(QMessageBox::Warning);
+		mb.setStandardButtons(QMessageBox::StandardButton::Yes |
+				      QMessageBox::StandardButton::No);
+		return mb.exec() == QMessageBox::StandardButton::Yes;
+	} else if (type == Type::Critical) {
+		mb.setText(error);
+		mb.setIcon(QMessageBox::Critical);
+		mb.setStandardButtons(
+			QMessageBox::StandardButton::Ok); // cannot continue
+		mb.exec();
+	}
+
+	return false;
+}

+ 22 - 0
UI/multitrack-video-error.hpp

@@ -0,0 +1,22 @@
+#pragma once
+#include <QString>
+
+class QWidget;
+
+struct MultitrackVideoError {
+	static MultitrackVideoError critical(QString error);
+	static MultitrackVideoError warning(QString error);
+	static MultitrackVideoError cancel();
+
+	bool ShowDialog(QWidget *parent,
+			const QString &multitrack_video_name) const;
+
+	enum struct Type {
+		Critical,
+		Warning,
+		Cancel,
+	};
+
+	const Type type;
+	const QString error;
+};

+ 950 - 0
UI/multitrack-video-output.cpp

@@ -0,0 +1,950 @@
+#include "multitrack-video-output.hpp"
+
+#include <util/dstr.hpp>
+#include <util/platform.h>
+#include <util/profiler.hpp>
+#include <util/util.hpp>
+#include <obs-frontend-api.h>
+#include <obs-app.hpp>
+#include <obs.hpp>
+#include <remote-text.hpp>
+
+#include <algorithm>
+#include <cinttypes>
+#include <cmath>
+#include <numeric>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <QAbstractButton>
+#include <QMessageBox>
+#include <QObject>
+#include <QPushButton>
+#include <QScopeGuard>
+#include <QString>
+#include <QThreadPool>
+#include <QUrl>
+#include <QUrlQuery>
+#include <QUuid>
+
+#include <nlohmann/json.hpp>
+
+#include "system-info.hpp"
+#include "goliveapi-postdata.hpp"
+#include "goliveapi-network.hpp"
+#include "multitrack-video-error.hpp"
+#include "qt-helpers.hpp"
+#include "models/multitrack-video.hpp"
+
+Qt::ConnectionType BlockingConnectionTypeFor(QObject *object)
+{
+	return object->thread() == QThread::currentThread()
+		       ? Qt::DirectConnection
+		       : Qt::BlockingQueuedConnection;
+}
+
+bool MultitrackVideoDeveloperModeEnabled()
+{
+	static bool developer_mode = [] {
+		auto args = qApp->arguments();
+		for (const auto &arg : args) {
+			if (arg == "--enable-multitrack-video-dev") {
+				return true;
+			}
+		}
+		return false;
+	}();
+	return developer_mode;
+}
+
+static OBSServiceAutoRelease
+create_service(const GoLiveApi::Config &go_live_config,
+	       const std::optional<std::string> &rtmp_url,
+	       const QString &in_stream_key)
+{
+	const char *url = nullptr;
+	QString stream_key = in_stream_key;
+
+	const auto &ingest_endpoints = go_live_config.ingest_endpoints;
+
+	for (auto &endpoint : ingest_endpoints) {
+		if (qstrnicmp("RTMP", endpoint.protocol.c_str(), 4))
+			continue;
+
+		url = endpoint.url_template.c_str();
+		if (endpoint.authentication &&
+		    !endpoint.authentication->empty()) {
+			blog(LOG_INFO,
+			     "Using stream key supplied by autoconfig");
+			stream_key = QString::fromStdString(
+				*endpoint.authentication);
+		}
+		break;
+	}
+
+	if (rtmp_url.has_value()) {
+		// Despite being set by user, it was set to a ""
+		if (rtmp_url->empty()) {
+			throw MultitrackVideoError::warning(QTStr(
+				"FailedToStartStream.NoCustomRTMPURLInSettings"));
+		}
+
+		url = rtmp_url->c_str();
+		blog(LOG_INFO, "Using custom RTMP URL: '%s'", url);
+	} else {
+		if (!url) {
+			blog(LOG_ERROR, "No RTMP URL in go live config");
+			throw MultitrackVideoError::warning(
+				QTStr("FailedToStartStream.NoRTMPURLInConfig"));
+		}
+
+		blog(LOG_INFO, "Using URL template: '%s'", url);
+	}
+
+	DStr str;
+	dstr_cat(str, url);
+
+	// dstr_find does not protect against null, and dstr_cat will
+	// not initialize str if cat'ing with a null url
+	if (!dstr_is_empty(str)) {
+		auto found = dstr_find(str, "/{stream_key}");
+		if (found)
+			dstr_remove(str, found - str->array,
+				    str->len - (found - str->array));
+	}
+
+	QUrl parsed_url{url};
+	QUrlQuery parsed_query{parsed_url};
+
+	if (!go_live_config.meta.config_id.empty()) {
+		parsed_query.addQueryItem(
+			"obsConfigId",
+			QString::fromStdString(go_live_config.meta.config_id));
+	}
+
+	auto key_with_param = stream_key;
+	if (!parsed_query.isEmpty())
+		key_with_param += "?" + parsed_query.toString();
+
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_string(settings, "server", str->array);
+	obs_data_set_string(settings, "key",
+			    key_with_param.toUtf8().constData());
+
+	auto service = obs_service_create(
+		"rtmp_custom", "multitrack video service", settings, nullptr);
+
+	if (!service) {
+		blog(LOG_WARNING, "Failed to create multitrack video service");
+		throw MultitrackVideoError::warning(QTStr(
+			"FailedToStartStream.FailedToCreateMultitrackVideoService"));
+	}
+
+	return service;
+}
+
+static void ensure_directory_exists(std::string &path)
+{
+	replace(path.begin(), path.end(), '\\', '/');
+
+	size_t last = path.rfind('/');
+	if (last == std::string::npos)
+		return;
+
+	std::string directory = path.substr(0, last);
+	os_mkdirs(directory.c_str());
+}
+
+std::string GetOutputFilename(const std::string &path, const char *format)
+{
+	std::string strPath;
+	strPath += path;
+
+	char lastChar = strPath.back();
+	if (lastChar != '/' && lastChar != '\\')
+		strPath += "/";
+
+	strPath += BPtr<char>{
+		os_generate_formatted_filename("flv", false, format)};
+	ensure_directory_exists(strPath);
+
+	return strPath;
+}
+
+static OBSOutputAutoRelease create_output()
+{
+	OBSOutputAutoRelease output = obs_output_create(
+		"rtmp_output", "rtmp multitrack video", nullptr, nullptr);
+
+	if (!output) {
+		blog(LOG_ERROR,
+		     "Failed to create multitrack video rtmp output");
+		throw MultitrackVideoError::warning(QTStr(
+			"FailedToStartStream.FailedToCreateMultitrackVideoOutput"));
+	}
+
+	return output;
+}
+
+static OBSOutputAutoRelease create_recording_output(obs_data_t *settings)
+{
+	OBSOutputAutoRelease output = obs_output_create(
+		"flv_output", "flv multitrack video", settings, nullptr);
+
+	if (!output)
+		blog(LOG_ERROR, "Failed to create multitrack video flv output");
+
+	return output;
+}
+
+static void adjust_video_encoder_scaling(
+	const obs_video_info &ovi, obs_encoder_t *video_encoder,
+	const GoLiveApi::VideoEncoderConfiguration &encoder_config,
+	size_t encoder_index)
+{
+	auto requested_width = encoder_config.width;
+	auto requested_height = encoder_config.height;
+
+	if (ovi.output_width == requested_width ||
+	    ovi.output_height == requested_height)
+		return;
+
+	if (ovi.base_width < requested_width ||
+	    ovi.base_height < requested_height) {
+		blog(LOG_WARNING,
+		     "Requested resolution exceeds canvas/available resolution for encoder %zu: %" PRIu32
+		     "x%" PRIu32 " > %" PRIu32 "x%" PRIu32,
+		     encoder_index, requested_width, requested_height,
+		     ovi.base_width, ovi.base_height);
+	}
+
+	obs_encoder_set_scaled_size(video_encoder, requested_width,
+				    requested_height);
+	obs_encoder_set_gpu_scale_type(
+		video_encoder,
+		encoder_config.gpu_scale_type.value_or(OBS_SCALE_BICUBIC));
+}
+
+static uint32_t closest_divisor(const obs_video_info &ovi,
+				const media_frames_per_second &target_fps)
+{
+	auto target = (uint64_t)target_fps.numerator * ovi.fps_den;
+	auto source = (uint64_t)ovi.fps_num * target_fps.denominator;
+	return std::max(1u, static_cast<uint32_t>(source / target));
+}
+
+static void adjust_encoder_frame_rate_divisor(
+	const obs_video_info &ovi, obs_encoder_t *video_encoder,
+	const GoLiveApi::VideoEncoderConfiguration &encoder_config,
+	const size_t encoder_index)
+{
+	if (!encoder_config.framerate) {
+		blog(LOG_WARNING, "`framerate` not specified for encoder %zu",
+		     encoder_index);
+		return;
+	}
+	media_frames_per_second requested_fps = *encoder_config.framerate;
+
+	if (ovi.fps_num == requested_fps.numerator &&
+	    ovi.fps_den == requested_fps.denominator)
+		return;
+
+	auto divisor = closest_divisor(ovi, requested_fps);
+	if (divisor <= 1)
+		return;
+
+	blog(LOG_INFO, "Setting frame rate divisor to %u for encoder %zu",
+	     divisor, encoder_index);
+	obs_encoder_set_frame_rate_divisor(video_encoder, divisor);
+}
+
+static const std::vector<const char *> &get_available_encoders()
+{
+	// encoders are currently only registered during startup, so keeping
+	// a static vector around shouldn't be a problem
+	static std::vector<const char *> available_encoders = [] {
+		std::vector<const char *> available_encoders;
+		for (size_t i = 0;; i++) {
+			const char *id = nullptr;
+			if (!obs_enum_encoder_types(i, &id))
+				break;
+			available_encoders.push_back(id);
+		}
+		return available_encoders;
+	}();
+	return available_encoders;
+}
+
+static bool encoder_available(const char *type)
+{
+	auto &encoders = get_available_encoders();
+	return std::find_if(std::begin(encoders), std::end(encoders),
+			    [=](const char *encoder) {
+				    return strcmp(type, encoder) == 0;
+			    }) != std::end(encoders);
+}
+
+static OBSEncoderAutoRelease create_video_encoder(
+	DStr &name_buffer, size_t encoder_index,
+	const GoLiveApi::EncoderConfiguration<
+		GoLiveApi::VideoEncoderConfiguration> &encoder_config)
+{
+	auto encoder_type = encoder_config.config.type.c_str();
+	if (!encoder_available(encoder_type)) {
+		blog(LOG_ERROR, "Encoder type '%s' not available",
+		     encoder_type);
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.EncoderNotAvailable")
+				.arg(encoder_type));
+	}
+
+	dstr_printf(name_buffer, "multitrack video video encoder %zu",
+		    encoder_index);
+
+	OBSDataAutoRelease encoder_settings =
+		obs_data_create_from_json(encoder_config.data.dump().c_str());
+	obs_data_set_bool(encoder_settings, "disable_scenecut", true);
+
+	OBSEncoderAutoRelease video_encoder = obs_video_encoder_create(
+		encoder_type, name_buffer, encoder_settings, nullptr);
+	if (!video_encoder) {
+		blog(LOG_ERROR, "Failed to create video encoder '%s'",
+		     name_buffer->array);
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.FailedToCreateVideoEncoder")
+				.arg(name_buffer->array, encoder_type));
+	}
+	obs_encoder_set_video(video_encoder, obs_get_video());
+
+	obs_video_info ovi;
+	if (!obs_get_video_info(&ovi)) {
+		blog(LOG_WARNING,
+		     "Failed to get obs_video_info while creating encoder %zu",
+		     encoder_index);
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.FailedToGetOBSVideoInfo")
+				.arg(name_buffer->array, encoder_type));
+	}
+
+	adjust_video_encoder_scaling(ovi, video_encoder, encoder_config.config,
+				     encoder_index);
+	adjust_encoder_frame_rate_divisor(ovi, video_encoder,
+					  encoder_config.config, encoder_index);
+
+	return video_encoder;
+}
+
+static OBSEncoderAutoRelease create_audio_encoder(const char *name,
+						  const char *audio_encoder_id,
+						  uint32_t audio_bitrate,
+						  size_t mixer_idx)
+{
+	OBSDataAutoRelease settings = obs_data_create();
+	obs_data_set_int(settings, "bitrate", audio_bitrate);
+
+	OBSEncoderAutoRelease audio_encoder = obs_audio_encoder_create(
+		audio_encoder_id, name, settings, mixer_idx, nullptr);
+	if (!audio_encoder) {
+		blog(LOG_ERROR, "Failed to create audio encoder");
+		throw MultitrackVideoError::warning(QTStr(
+			"FailedToStartStream.FailedToCreateAudioEncoder"));
+	}
+	obs_encoder_set_audio(audio_encoder, obs_get_audio());
+	return audio_encoder;
+}
+
+struct OBSOutputs {
+	OBSOutputAutoRelease output, recording_output;
+};
+
+static OBSOutputs
+SetupOBSOutput(obs_data_t *dump_stream_to_file_config,
+	       const GoLiveApi::Config &go_live_config,
+	       std::vector<OBSEncoderAutoRelease> &audio_encoders,
+	       std::vector<OBSEncoderAutoRelease> &video_encoders,
+	       const char *audio_encoder_id,
+	       std::optional<size_t> vod_track_mixer);
+static void SetupSignalHandlers(bool recording, MultitrackVideoOutput *self,
+				obs_output_t *output, OBSSignal &start,
+				OBSSignal &stop, OBSSignal &deactivate);
+
+void MultitrackVideoOutput::PrepareStreaming(
+	QWidget *parent, const char *service_name, obs_service_t *service,
+	const std::optional<std::string> &rtmp_url, const QString &stream_key,
+	const char *audio_encoder_id,
+	std::optional<uint32_t> maximum_aggregate_bitrate,
+	std::optional<uint32_t> maximum_video_tracks,
+	std::optional<std::string> custom_config,
+	obs_data_t *dump_stream_to_file_config,
+	std::optional<size_t> vod_track_mixer)
+{
+	{
+		const std::lock_guard<std::mutex> current_lock{current_mutex};
+		const std::lock_guard<std::mutex> current_stream_dump_lock{
+			current_stream_dump_mutex};
+		if (current || current_stream_dump) {
+			blog(LOG_WARNING,
+			     "Tried to prepare multitrack video output while it's already active");
+			return;
+		}
+	}
+	std::optional<GoLiveApi::Config> go_live_config;
+	std::optional<GoLiveApi::Config> custom;
+	bool is_custom_config = custom_config.has_value();
+	auto auto_config_url = MultitrackVideoAutoConfigURL(service);
+
+	OBSDataAutoRelease service_settings = obs_service_get_settings(service);
+	auto multitrack_video_name =
+		QTStr("Basic.Settings.Stream.MultitrackVideoLabel");
+	if (obs_data_has_user_value(service_settings,
+				    "ertmp_multitrack_video_name")) {
+		multitrack_video_name = obs_data_get_string(
+			service_settings, "ertmp_multitrack_video_name");
+	}
+
+	auto auto_config_url_data = auto_config_url.toUtf8();
+
+	DStr vod_track_info_storage;
+	if (vod_track_mixer.has_value())
+		dstr_printf(vod_track_info_storage, "Yes (mixer: %zu)",
+			    vod_track_mixer.value());
+
+	blog(LOG_INFO,
+	     "Preparing enhanced broadcasting stream for:\n"
+	     "    custom config:  %s\n"
+	     "    config url:     %s\n"
+	     "  settings:\n"
+	     "    service:               %s\n"
+	     "    max aggregate bitrate: %s (%" PRIu32 ")\n"
+	     "    max video tracks:      %s (%" PRIu32 ")\n"
+	     "    custom rtmp url:       %s ('%s')\n"
+	     "    vod track:             %s",
+	     is_custom_config ? "Yes" : "No",
+	     !auto_config_url.isEmpty() ? auto_config_url_data.constData()
+					: "(null)",
+	     service_name,
+	     maximum_aggregate_bitrate.has_value() ? "Set" : "Auto",
+	     maximum_aggregate_bitrate.value_or(0),
+	     maximum_video_tracks.has_value() ? "Set" : "Auto",
+	     maximum_video_tracks.value_or(0),
+	     rtmp_url.has_value() ? "Yes" : "No",
+	     rtmp_url.has_value() ? rtmp_url->c_str() : "",
+	     vod_track_info_storage->array ? vod_track_info_storage->array
+					   : "No");
+
+	const bool custom_config_only =
+		auto_config_url.isEmpty() &&
+		MultitrackVideoDeveloperModeEnabled() &&
+		custom_config.has_value() &&
+		strcmp(obs_service_get_id(service), "rtmp_custom") == 0;
+
+	if (!custom_config_only) {
+		auto go_live_post = constructGoLivePost(
+			stream_key, maximum_aggregate_bitrate,
+			maximum_video_tracks, vod_track_mixer.has_value());
+
+		go_live_config = DownloadGoLiveConfig(parent, auto_config_url,
+						      go_live_post,
+						      multitrack_video_name);
+	}
+
+	if (custom_config.has_value()) {
+		GoLiveApi::Config parsed_custom;
+		try {
+			parsed_custom = nlohmann::json::parse(*custom_config);
+		} catch (const nlohmann::json::exception &exception) {
+			blog(LOG_WARNING, "Failed to parse custom config: %s",
+			     exception.what());
+			throw MultitrackVideoError::critical(QTStr(
+				"FailedToStartStream.InvalidCustomConfig"));
+		}
+
+		// copy unique ID from go live request
+		if (go_live_config.has_value()) {
+			parsed_custom.meta.config_id =
+				go_live_config->meta.config_id;
+			blog(LOG_INFO,
+			     "Using config_id from go live config with custom config: %s",
+			     parsed_custom.meta.config_id.c_str());
+		}
+
+		nlohmann::json custom_data = parsed_custom;
+		blog(LOG_INFO, "Using custom go live config: %s",
+		     custom_data.dump(4).c_str());
+
+		custom.emplace(std::move(parsed_custom));
+	}
+
+	if (go_live_config.has_value()) {
+		blog(LOG_INFO, "Enhanced broadcasting config_id: '%s'",
+		     go_live_config->meta.config_id.c_str());
+	}
+
+	if (!go_live_config && !custom) {
+		blog(LOG_ERROR,
+		     "MultitrackVideoOutput: no config set, this should never happen");
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.NoConfig"));
+	}
+
+	const auto &output_config = custom ? *custom : *go_live_config;
+	const auto &service_config = go_live_config ? *go_live_config : *custom;
+
+	auto audio_encoders = std::vector<OBSEncoderAutoRelease>();
+	auto video_encoders = std::vector<OBSEncoderAutoRelease>();
+	auto outputs = SetupOBSOutput(dump_stream_to_file_config, output_config,
+				      audio_encoders, video_encoders,
+				      audio_encoder_id, vod_track_mixer);
+	auto output = std::move(outputs.output);
+	auto recording_output = std::move(outputs.recording_output);
+	if (!output)
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.FallbackToDefault")
+				.arg(multitrack_video_name));
+
+	auto multitrack_video_service =
+		create_service(service_config, rtmp_url, stream_key);
+	if (!multitrack_video_service)
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.FallbackToDefault")
+				.arg(multitrack_video_name));
+
+	obs_output_set_service(output, multitrack_video_service);
+
+	OBSSignal start_streaming;
+	OBSSignal stop_streaming;
+	OBSSignal deactivate_stream;
+	SetupSignalHandlers(false, this, output, start_streaming,
+			    stop_streaming, deactivate_stream);
+
+	if (dump_stream_to_file_config && recording_output) {
+		OBSSignal start_recording;
+		OBSSignal stop_recording;
+		OBSSignal deactivate_recording;
+		SetupSignalHandlers(true, this, recording_output,
+				    start_recording, stop_recording,
+				    deactivate_recording);
+
+		decltype(video_encoders) recording_video_encoders;
+		recording_video_encoders.reserve(video_encoders.size());
+		for (auto &encoder : video_encoders) {
+			recording_video_encoders.emplace_back(
+				obs_encoder_get_ref(encoder));
+		}
+
+		decltype(audio_encoders) recording_audio_encoders;
+		recording_audio_encoders.reserve(audio_encoders.size());
+		for (auto &encoder : audio_encoders) {
+			recording_audio_encoders.emplace_back(
+				obs_encoder_get_ref(encoder));
+		}
+
+		{
+			const std::lock_guard current_stream_dump_lock{
+				current_stream_dump_mutex};
+			current_stream_dump.emplace(OBSOutputObjects{
+				std::move(recording_output),
+				std::move(recording_video_encoders),
+				std::move(recording_audio_encoders),
+				nullptr,
+				std::move(start_recording),
+				std::move(stop_recording),
+				std::move(deactivate_recording),
+			});
+		}
+	}
+
+	const std::lock_guard current_lock{current_mutex};
+	current.emplace(OBSOutputObjects{
+		std::move(output),
+		std::move(video_encoders),
+		std::move(audio_encoders),
+		std::move(multitrack_video_service),
+		std::move(start_streaming),
+		std::move(stop_streaming),
+		std::move(deactivate_stream),
+	});
+}
+
+signal_handler_t *MultitrackVideoOutput::StreamingSignalHandler()
+{
+	const std::lock_guard current_lock{current_mutex};
+	return current.has_value()
+		       ? obs_output_get_signal_handler(current->output_)
+		       : nullptr;
+}
+
+void MultitrackVideoOutput::StartedStreaming()
+{
+	OBSOutputAutoRelease dump_output;
+	{
+		const std::lock_guard current_stream_dump_lock{
+			current_stream_dump_mutex};
+		if (current_stream_dump && current_stream_dump->output_) {
+			dump_output = obs_output_get_ref(
+				current_stream_dump->output_);
+		}
+	}
+
+	if (!dump_output)
+		return;
+
+	auto result = obs_output_start(dump_output);
+	blog(LOG_INFO, "MultitrackVideoOutput: starting recording%s",
+	     result ? "" : " failed");
+}
+
+void MultitrackVideoOutput::StopStreaming()
+{
+	OBSOutputAutoRelease current_output;
+	{
+		const std::lock_guard current_lock{current_mutex};
+		if (current && current->output_)
+			current_output = obs_output_get_ref(current->output_);
+	}
+	if (current_output)
+		obs_output_stop(current_output);
+
+	OBSOutputAutoRelease dump_output;
+	{
+		const std::lock_guard current_stream_dump_lock{
+			current_stream_dump_mutex};
+		if (current_stream_dump && current_stream_dump->output_)
+			dump_output = obs_output_get_ref(
+				current_stream_dump->output_);
+	}
+	if (dump_output)
+		obs_output_stop(dump_output);
+}
+
+bool MultitrackVideoOutput::HandleIncompatibleSettings(
+	QWidget *parent, config_t *config, obs_service_t *service,
+	bool &useDelay, bool &enableNewSocketLoop, bool &enableDynBitrate)
+{
+	QString incompatible_settings;
+	QString where_to_disable;
+	QString incompatible_settings_list;
+
+	size_t num = 1;
+
+	auto check_setting = [&](bool setting, const char *name,
+				 const char *section) {
+		if (!setting)
+			return;
+
+		incompatible_settings +=
+			QString(" %1. %2\n").arg(num).arg(QTStr(name));
+
+		where_to_disable +=
+			QString(" %1. [%2 > %3 > %4]\n")
+				.arg(num)
+				.arg(QTStr("Settings"))
+				.arg(QTStr("Basic.Settings.Advanced"))
+				.arg(QTStr(section));
+
+		incompatible_settings_list += QString("%1, ").arg(name);
+
+		num += 1;
+	};
+
+	check_setting(useDelay, "Basic.Settings.Advanced.StreamDelay",
+		      "Basic.Settings.Advanced.StreamDelay");
+#ifdef _WIN32
+	check_setting(enableNewSocketLoop,
+		      "Basic.Settings.Advanced.Network.EnableNewSocketLoop",
+		      "Basic.Settings.Advanced.Network");
+#endif
+	check_setting(enableDynBitrate,
+		      "Basic.Settings.Output.DynamicBitrate.Beta",
+		      "Basic.Settings.Advanced.Network");
+
+	if (incompatible_settings.isEmpty())
+		return true;
+
+	OBSDataAutoRelease service_settings = obs_service_get_settings(service);
+
+	QMessageBox mb(parent);
+	mb.setIcon(QMessageBox::Critical);
+	mb.setWindowTitle(QTStr("MultitrackVideo.IncompatibleSettings.Title"));
+	mb.setText(
+		QString(QTStr("MultitrackVideo.IncompatibleSettings.Text"))
+			.arg(obs_data_get_string(service_settings,
+						 "ertmp_multitrack_video_name"))
+			.arg(incompatible_settings)
+			.arg(where_to_disable));
+	auto this_stream = mb.addButton(
+		QTStr("MultitrackVideo.IncompatibleSettings.DisableAndStartStreaming"),
+		QMessageBox::AcceptRole);
+	auto all_streams = mb.addButton(
+		QString(QTStr(
+			"MultitrackVideo.IncompatibleSettings.UpdateAndStartStreaming")),
+		QMessageBox::AcceptRole);
+	mb.setStandardButtons(QMessageBox::StandardButton::Cancel);
+
+	mb.exec();
+
+	const char *action = "cancel";
+	if (mb.clickedButton() == this_stream) {
+		action = "DisableAndStartStreaming";
+	} else if (mb.clickedButton() == all_streams) {
+		action = "UpdateAndStartStreaming";
+	}
+
+	blog(LOG_INFO,
+	     "MultitrackVideoOutput: attempted to start stream with incompatible"
+	     "settings (%s); action taken: %s",
+	     incompatible_settings_list.toUtf8().constData(), action);
+
+	if (mb.clickedButton() == this_stream ||
+	    mb.clickedButton() == all_streams) {
+		useDelay = false;
+		enableNewSocketLoop = false;
+		enableDynBitrate = false;
+		useDelay = false;
+		enableNewSocketLoop = false;
+		enableDynBitrate = false;
+
+		if (mb.clickedButton() == all_streams) {
+			config_set_bool(config, "Output", "DelayEnable", false);
+#ifdef _WIN32
+			config_set_bool(config, "Output", "NewSocketLoopEnable",
+					false);
+#endif
+			config_set_bool(config, "Output", "DynamicBitrate",
+					false);
+		}
+
+		return true;
+	}
+
+	return false;
+}
+
+static bool
+create_video_encoders(const GoLiveApi::Config &go_live_config,
+		      std::vector<OBSEncoderAutoRelease> &video_encoders,
+		      obs_output_t *output, obs_output_t *recording_output)
+{
+	DStr video_encoder_name_buffer;
+	obs_encoder_t *first_encoder = nullptr;
+	if (go_live_config.encoder_configurations.empty()) {
+		blog(LOG_WARNING,
+		     "MultitrackVideoOutput: Missing video encoder configurations");
+		throw MultitrackVideoError::warning(
+			QTStr("FailedToStartStream.MissingEncoderConfigs"));
+	}
+
+	for (size_t i = 0; i < go_live_config.encoder_configurations.size();
+	     i++) {
+		auto encoder = create_video_encoder(
+			video_encoder_name_buffer, i,
+			go_live_config.encoder_configurations[i]);
+		if (!encoder)
+			return false;
+
+		if (!first_encoder)
+			first_encoder = encoder;
+		else
+			obs_encoder_group_keyframe_aligned_encoders(
+				first_encoder, encoder);
+
+		obs_output_set_video_encoder2(output, encoder, i);
+		if (recording_output)
+			obs_output_set_video_encoder2(recording_output, encoder,
+						      i);
+		video_encoders.emplace_back(std::move(encoder));
+	}
+
+	return true;
+}
+
+static void
+create_audio_encoders(const GoLiveApi::Config &go_live_config,
+		      std::vector<OBSEncoderAutoRelease> &audio_encoders,
+		      obs_output_t *output, obs_output_t *recording_output,
+		      const char *audio_encoder_id,
+		      std::optional<size_t> vod_track_mixer)
+{
+	using encoder_configs_type =
+		decltype(go_live_config.audio_configurations.live);
+	DStr encoder_name_buffer;
+	size_t output_encoder_index = 0;
+
+	auto create_encoders = [&](const char *name_prefix,
+				   const encoder_configs_type &configs,
+				   size_t mixer_idx) {
+		if (configs.empty()) {
+			blog(LOG_WARNING,
+			     "MultitrackVideoOutput: Missing audio encoder configurations (for '%s')",
+			     name_prefix);
+			throw MultitrackVideoError::warning(QTStr(
+				"FailedToStartStream.MissingEncoderConfigs"));
+		}
+
+		for (size_t i = 0; i < configs.size(); i++) {
+			dstr_printf(encoder_name_buffer, "%s %zu", name_prefix,
+				    i);
+			OBSEncoderAutoRelease audio_encoder =
+				create_audio_encoder(encoder_name_buffer->array,
+						     audio_encoder_id,
+						     configs[i].config.bitrate,
+						     mixer_idx);
+			obs_output_set_audio_encoder(output, audio_encoder,
+						     output_encoder_index);
+			if (recording_output)
+				obs_output_set_audio_encoder(
+					recording_output, audio_encoder,
+					output_encoder_index);
+			output_encoder_index += 1;
+			audio_encoders.emplace_back(std::move(audio_encoder));
+		}
+	};
+
+	create_encoders("multitrack video live audio",
+			go_live_config.audio_configurations.live, 0);
+
+	if (!vod_track_mixer.has_value())
+		return;
+
+	create_encoders("multitrack video vod audio",
+			go_live_config.audio_configurations.vod,
+			*vod_track_mixer);
+
+	return;
+}
+
+static OBSOutputs
+SetupOBSOutput(obs_data_t *dump_stream_to_file_config,
+	       const GoLiveApi::Config &go_live_config,
+	       std::vector<OBSEncoderAutoRelease> &audio_encoders,
+	       std::vector<OBSEncoderAutoRelease> &video_encoders,
+	       const char *audio_encoder_id,
+	       std::optional<size_t> vod_track_mixer)
+{
+
+	auto output = create_output();
+	OBSOutputAutoRelease recording_output;
+	if (dump_stream_to_file_config)
+		recording_output =
+			create_recording_output(dump_stream_to_file_config);
+
+	if (!create_video_encoders(go_live_config, video_encoders, output,
+				   recording_output))
+		return {nullptr, nullptr};
+
+	create_audio_encoders(go_live_config, audio_encoders, output,
+			      recording_output, audio_encoder_id,
+			      vod_track_mixer);
+
+	return {std::move(output), std::move(recording_output)};
+}
+
+void SetupSignalHandlers(bool recording, MultitrackVideoOutput *self,
+			 obs_output_t *output, OBSSignal &start,
+			 OBSSignal &stop, OBSSignal &deactivate)
+{
+	auto handler = obs_output_get_signal_handler(output);
+
+	if (recording)
+		start.Connect(handler, "start", RecordingStartHandler, self);
+
+	stop.Connect(handler, "stop",
+		     !recording ? StreamStopHandler : RecordingStopHandler,
+		     self);
+
+	deactivate.Connect(handler, "deactivate",
+			   !recording ? StreamDeactivateHandler
+				      : RecordingDeactivateHandler,
+			   self);
+}
+
+std::optional<MultitrackVideoOutput::OBSOutputObjects>
+MultitrackVideoOutput::take_current()
+{
+	const std::lock_guard<std::mutex> current_lock{current_mutex};
+	auto val = std::move(current);
+	current.reset();
+	return val;
+}
+
+std::optional<MultitrackVideoOutput::OBSOutputObjects>
+MultitrackVideoOutput::take_current_stream_dump()
+{
+	const std::lock_guard<std::mutex> current_stream_dump_lock{
+		current_stream_dump_mutex};
+	auto val = std::move(current_stream_dump);
+	current_stream_dump.reset();
+	return val;
+}
+
+void MultitrackVideoOutput::ReleaseOnMainThread(
+	std::optional<OBSOutputObjects> objects)
+{
+
+	if (!objects.has_value())
+		return;
+
+	QMetaObject::invokeMethod(
+		QApplication::instance()->thread(),
+		[objects = std::move(objects)] {}, Qt::QueuedConnection);
+}
+
+void StreamStopHandler(void *arg, calldata_t *params)
+{
+	auto self = static_cast<MultitrackVideoOutput *>(arg);
+
+	OBSOutputAutoRelease stream_dump_output;
+	{
+		const std::lock_guard<std::mutex> current_stream_dump_lock{
+			self->current_stream_dump_mutex};
+		if (self->current_stream_dump &&
+		    self->current_stream_dump->output_)
+			stream_dump_output = obs_output_get_ref(
+				self->current_stream_dump->output_);
+	}
+	if (stream_dump_output)
+		obs_output_stop(stream_dump_output);
+
+	if (obs_output_active(static_cast<obs_output_t *>(
+		    calldata_ptr(params, "output"))))
+		return;
+
+	MultitrackVideoOutput::ReleaseOnMainThread(self->take_current());
+}
+
+void StreamDeactivateHandler(void *arg, calldata_t *params)
+{
+	auto self = static_cast<MultitrackVideoOutput *>(arg);
+
+	if (obs_output_reconnecting(static_cast<obs_output_t *>(
+		    calldata_ptr(params, "output"))))
+		return;
+
+	MultitrackVideoOutput::ReleaseOnMainThread(self->take_current());
+}
+
+void RecordingStartHandler(void * /* arg */, calldata_t * /* data */)
+{
+	blog(LOG_INFO, "MultitrackVideoOutput: recording started");
+}
+
+void RecordingStopHandler(void *arg, calldata_t *params)
+{
+	auto self = static_cast<MultitrackVideoOutput *>(arg);
+	blog(LOG_INFO, "MultitrackVideoOutput: recording stopped");
+
+	if (obs_output_active(static_cast<obs_output_t *>(
+		    calldata_ptr(params, "output"))))
+		return;
+
+	MultitrackVideoOutput::ReleaseOnMainThread(
+		self->take_current_stream_dump());
+}
+
+void RecordingDeactivateHandler(void *arg, calldata_t * /*data*/)
+{
+	auto self = static_cast<MultitrackVideoOutput *>(arg);
+	MultitrackVideoOutput::ReleaseOnMainThread(
+		self->take_current_stream_dump());
+}

+ 79 - 0
UI/multitrack-video-output.hpp

@@ -0,0 +1,79 @@
+#pragma once
+
+#include <obs.hpp>
+#include <util/config-file.h>
+
+#include <atomic>
+#include <optional>
+#include <vector>
+
+#include <qobject.h>
+#include <QFuture>
+#include <QFutureSynchronizer>
+
+#define NOMINMAX
+
+class QString;
+
+void StreamStopHandler(void *arg, calldata_t *data);
+void StreamDeactivateHandler(void *arg, calldata_t *data);
+
+void RecordingStartHandler(void *arg, calldata_t *data);
+void RecordingStopHandler(void *arg, calldata_t *data);
+void RecordingDeactivateHandler(void *arg, calldata_t *data);
+
+bool MultitrackVideoDeveloperModeEnabled();
+
+struct MultitrackVideoOutput {
+public:
+	void PrepareStreaming(QWidget *parent, const char *service_name,
+			      obs_service_t *service,
+			      const std::optional<std::string> &rtmp_url,
+			      const QString &stream_key,
+			      const char *audio_encoder_id,
+			      std::optional<uint32_t> maximum_aggregate_bitrate,
+			      std::optional<uint32_t> maximum_video_tracks,
+			      std::optional<std::string> custom_config,
+			      obs_data_t *dump_stream_to_file_config,
+			      std::optional<size_t> vod_track_mixer);
+	signal_handler_t *StreamingSignalHandler();
+	void StartedStreaming();
+	void StopStreaming();
+	bool HandleIncompatibleSettings(QWidget *parent, config_t *config,
+					obs_service_t *service, bool &useDelay,
+					bool &enableNewSocketLoop,
+					bool &enableDynBitrate);
+
+	OBSOutputAutoRelease StreamingOutput()
+	{
+		const std::lock_guard current_lock{current_mutex};
+		return current ? obs_output_get_ref(current->output_) : nullptr;
+	}
+
+private:
+	struct OBSOutputObjects {
+		OBSOutputAutoRelease output_;
+		std::vector<OBSEncoderAutoRelease> video_encoders_;
+		std::vector<OBSEncoderAutoRelease> audio_encoders_;
+		OBSServiceAutoRelease multitrack_video_service_;
+		OBSSignal start_signal, stop_signal, deactivate_signal;
+	};
+
+	std::optional<OBSOutputObjects> take_current();
+	std::optional<OBSOutputObjects> take_current_stream_dump();
+
+	static void
+	ReleaseOnMainThread(std::optional<OBSOutputObjects> objects);
+
+	std::mutex current_mutex;
+	std::optional<OBSOutputObjects> current;
+
+	std::mutex current_stream_dump_mutex;
+	std::optional<OBSOutputObjects> current_stream_dump;
+
+	friend void StreamStopHandler(void *arg, calldata_t *data);
+	friend void StreamDeactivateHandler(void *arg, calldata_t *data);
+	friend void RecordingStartHandler(void *arg, calldata_t *data);
+	friend void RecordingStopHandler(void *arg, calldata_t *data);
+	friend void RecordingDeactivateHandler(void *arg, calldata_t *data);
+};

+ 10 - 0
UI/qt-helpers.cpp

@@ -0,0 +1,10 @@
+#include "qt-helpers.hpp"
+
+QFuture<void> CreateFuture()
+{
+	QPromise<void> promise;
+	auto future = promise.future();
+	promise.start();
+	promise.finish();
+	return future;
+}

+ 46 - 0
UI/qt-helpers.hpp

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <functional>
+#include <QFuture>
+#include <QtGlobal>
+
+template<typename T> struct FutureHolder {
+	std::function<void()> cancelAll;
+	QFuture<T> future;
+};
+
+QFuture<void> CreateFuture();
+
+template<typename T> inline QFuture<T> PreventFutureDeadlock(QFuture<T> future)
+{
+	/*
+	* QFutures deadlock if there are continuations on the same thread that
+	* need to wait for the previous continuation to finish, see
+	* https://github.com/qt/qtbase/commit/59e21a536f7f81625216dc7a621e7be59919da33
+	*
+	* related bugs:
+	* https://bugreports.qt.io/browse/QTBUG-119406
+	* https://bugreports.qt.io/browse/QTBUG-119103
+	* https://bugreports.qt.io/browse/QTBUG-117918
+	* https://bugreports.qt.io/browse/QTBUG-119579
+	* https://bugreports.qt.io/browse/QTBUG-119810
+	* @RytoEX's summary:
+	* QTBUG-119406 and QTBUG-119103 affect Qt 6.6.0 and are fixed in Qt 6.6.2 and 6.7.0+.
+	* QTBUG-119579 and QTBUG-119810 affect Qt 6.6.1 and are fixed in Qt 6.6.2 and 6.7.0+.
+	* QTBUG-117918 is the only strange one that seems to possibly affect all Qt 6.x versions
+	* until 6.6.2, but only in Debug builds.
+	*
+	* To fix this, move relevant QFutures to another thread before resuming
+	* on main thread for affected Qt versions
+	*/
+#if (QT_VERSION >= QT_VERSION_CHECK(6, 6, 0)) && \
+	(QT_VERSION < QT_VERSION_CHECK(6, 6, 2))
+	if (future.isFinished()) {
+		return future;
+	}
+
+	return future.then(QtFuture::Launch::Async, [](T val) { return val; });
+#else
+	return future;
+#endif
+}

+ 6 - 0
UI/system-info-macos.mm

@@ -0,0 +1,6 @@
+#include "system-info.hpp"
+
+void system_info(GoLiveApi::Capabilities &capabilities)
+{
+    UNUSED_PARAMETER(capabilities);
+}

+ 6 - 0
UI/system-info-posix.cpp

@@ -0,0 +1,6 @@
+#include "system-info.hpp"
+
+void system_info(GoLiveApi::Capabilities &capabilities)
+{
+	UNUSED_PARAMETER(capabilities);
+}

+ 278 - 0
UI/system-info-windows.cpp

@@ -0,0 +1,278 @@
+#include "system-info.hpp"
+
+#include <dxgi.h>
+#include <cinttypes>
+#include <shlobj.h>
+
+#include <util/dstr.hpp>
+#include <util/platform.h>
+#include <util/windows/ComPtr.hpp>
+#include <util/windows/win-registry.h>
+#include <util/windows/win-version.h>
+
+static std::optional<std::vector<GoLiveApi::Gpu>> system_gpu_data()
+{
+	ComPtr<IDXGIFactory1> factory;
+	ComPtr<IDXGIAdapter1> adapter;
+	HRESULT hr;
+	UINT i;
+
+	hr = CreateDXGIFactory1(IID_PPV_ARGS(&factory));
+	if (FAILED(hr))
+		return std::nullopt;
+
+	std::vector<GoLiveApi::Gpu> adapter_info;
+
+	DStr luid_buffer;
+	for (i = 0; factory->EnumAdapters1(i, adapter.Assign()) == S_OK; ++i) {
+		DXGI_ADAPTER_DESC desc;
+		char name[512] = "";
+		char driver_version[512] = "";
+
+		hr = adapter->GetDesc(&desc);
+		if (FAILED(hr))
+			continue;
+
+		/* ignore Microsoft's 'basic' renderer' */
+		if (desc.VendorId == 0x1414 && desc.DeviceId == 0x8c)
+			continue;
+
+		os_wcs_to_utf8(desc.Description, 0, name, sizeof(name));
+
+		GoLiveApi::Gpu data;
+		data.model = name;
+
+		data.vendor_id = desc.VendorId;
+		data.device_id = desc.DeviceId;
+
+		data.dedicated_video_memory = desc.DedicatedVideoMemory;
+		data.shared_system_memory = desc.SharedSystemMemory;
+
+		dstr_printf(luid_buffer, "luid_0x%08X_0x%08X",
+			    desc.AdapterLuid.HighPart,
+			    desc.AdapterLuid.LowPart);
+		data.luid = luid_buffer->array;
+
+		/* driver version */
+		LARGE_INTEGER umd;
+		hr = adapter->CheckInterfaceSupport(__uuidof(IDXGIDevice),
+						    &umd);
+		if (SUCCEEDED(hr)) {
+			const uint64_t version = umd.QuadPart;
+			const uint16_t aa = (version >> 48) & 0xffff;
+			const uint16_t bb = (version >> 32) & 0xffff;
+			const uint16_t ccccc = (version >> 16) & 0xffff;
+			const uint16_t ddddd = version & 0xffff;
+			snprintf(driver_version, sizeof(driver_version),
+				 "%" PRIu16 ".%" PRIu16 ".%" PRIu16 ".%" PRIu16,
+				 aa, bb, ccccc, ddddd);
+			data.driver_version = driver_version;
+		}
+
+		adapter_info.push_back(data);
+	}
+
+	return adapter_info;
+}
+
+static void get_processor_info(char **name, DWORD *speed)
+{
+	HKEY key;
+	wchar_t data[1024];
+	DWORD size;
+	LSTATUS status;
+
+	memset(data, 0, sizeof(data));
+
+	status = RegOpenKeyW(
+		HKEY_LOCAL_MACHINE,
+		L"HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0", &key);
+	if (status != ERROR_SUCCESS)
+		return;
+
+	size = sizeof(data);
+	status = RegQueryValueExW(key, L"ProcessorNameString", NULL, NULL,
+				  (LPBYTE)data, &size);
+	if (status == ERROR_SUCCESS) {
+		os_wcs_to_utf8_ptr(data, 0, name);
+	} else {
+		*name = 0;
+	}
+
+	size = sizeof(*speed);
+	status = RegQueryValueExW(key, L"~MHz", NULL, NULL, (LPBYTE)speed,
+				  &size);
+	if (status != ERROR_SUCCESS)
+		*speed = 0;
+
+	RegCloseKey(key);
+}
+
+#define WIN10_GAME_BAR_REG_KEY \
+	L"Software\\Microsoft\\Windows\\CurrentVersion\\GameDVR"
+#define WIN10_GAME_DVR_POLICY_REG_KEY \
+	L"SOFTWARE\\Policies\\Microsoft\\Windows\\GameDVR"
+#define WIN10_GAME_DVR_REG_KEY L"System\\GameConfigStore"
+#define WIN10_GAME_MODE_REG_KEY L"Software\\Microsoft\\GameBar"
+#define WIN10_HAGS_REG_KEY \
+	L"SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers"
+
+static std::optional<GoLiveApi::GamingFeatures>
+get_gaming_features_data(const win_version_info &ver)
+{
+	uint32_t win_ver = (ver.major << 8) | ver.minor;
+	if (win_ver < 0xA00)
+		return std::nullopt;
+
+	GoLiveApi::GamingFeatures gaming_features;
+
+	struct feature_mapping_s {
+		std::optional<bool> *field;
+		HKEY hkey;
+		LPCWSTR sub_key;
+		LPCWSTR value_name;
+		LPCWSTR backup_value_name;
+		bool non_existence_is_false;
+		DWORD disabled_value;
+	};
+	struct feature_mapping_s features[] = {
+		{&gaming_features.game_bar_enabled, HKEY_CURRENT_USER,
+		 WIN10_GAME_BAR_REG_KEY, L"AppCaptureEnabled", 0, false, 0},
+		{&gaming_features.game_dvr_allowed, HKEY_CURRENT_USER,
+		 WIN10_GAME_DVR_POLICY_REG_KEY, L"AllowGameDVR", 0, false, 0},
+		{&gaming_features.game_dvr_enabled, HKEY_CURRENT_USER,
+		 WIN10_GAME_DVR_REG_KEY, L"GameDVR_Enabled", 0, false, 0},
+		{&gaming_features.game_dvr_bg_recording, HKEY_CURRENT_USER,
+		 WIN10_GAME_BAR_REG_KEY, L"HistoricalCaptureEnabled", 0, false,
+		 0},
+		{&gaming_features.game_mode_enabled, HKEY_CURRENT_USER,
+		 WIN10_GAME_MODE_REG_KEY, L"AutoGameModeEnabled",
+		 L"AllowAutoGameMode", false, 0},
+		{&gaming_features.hags_enabled, HKEY_LOCAL_MACHINE,
+		 WIN10_HAGS_REG_KEY, L"HwSchMode", 0, true, 1}};
+
+	for (int i = 0; i < sizeof(features) / sizeof(*features); ++i) {
+		struct reg_dword info;
+
+		get_reg_dword(features[i].hkey, features[i].sub_key,
+			      features[i].value_name, &info);
+
+		if (info.status != ERROR_SUCCESS &&
+		    features[i].backup_value_name) {
+			get_reg_dword(features[i].hkey, features[i].sub_key,
+				      features[i].backup_value_name, &info);
+		}
+
+		if (info.status == ERROR_SUCCESS) {
+			*features[i].field = info.return_value !=
+					     features[i].disabled_value;
+		} else if (features[i].non_existence_is_false) {
+			*features[i].field = false;
+		}
+	}
+
+	return gaming_features;
+}
+
+static inline bool get_reg_sz(HKEY key, const wchar_t *val, wchar_t *buf,
+			      DWORD size)
+{
+	const LSTATUS status =
+		RegGetValueW(key, NULL, val, RRF_RT_REG_SZ, NULL, buf, &size);
+	return status == ERROR_SUCCESS;
+}
+
+#define MAX_SZ_LEN 256
+#define WINVER_REG_KEY L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion"
+
+static char win_release_id[MAX_SZ_LEN] = "unavailable";
+
+static inline void get_reg_ver(struct win_version_info *ver)
+{
+	HKEY key;
+	DWORD size, dw_val;
+	LSTATUS status;
+	wchar_t str[MAX_SZ_LEN];
+
+	status = RegOpenKeyW(HKEY_LOCAL_MACHINE, WINVER_REG_KEY, &key);
+	if (status != ERROR_SUCCESS)
+		return;
+
+	size = sizeof(dw_val);
+
+	status = RegQueryValueExW(key, L"CurrentMajorVersionNumber", NULL, NULL,
+				  (LPBYTE)&dw_val, &size);
+	if (status == ERROR_SUCCESS)
+		ver->major = (int)dw_val;
+
+	status = RegQueryValueExW(key, L"CurrentMinorVersionNumber", NULL, NULL,
+				  (LPBYTE)&dw_val, &size);
+	if (status == ERROR_SUCCESS)
+		ver->minor = (int)dw_val;
+
+	status = RegQueryValueExW(key, L"UBR", NULL, NULL, (LPBYTE)&dw_val,
+				  &size);
+	if (status == ERROR_SUCCESS)
+		ver->revis = (int)dw_val;
+
+	if (get_reg_sz(key, L"CurrentBuildNumber", str, sizeof(str))) {
+		ver->build = wcstol(str, NULL, 10);
+	}
+
+	const wchar_t *release_key = ver->build > 19041 ? L"DisplayVersion"
+							: L"ReleaseId";
+	if (get_reg_sz(key, release_key, str, sizeof(str))) {
+		os_wcs_to_utf8(str, 0, win_release_id, MAX_SZ_LEN);
+	}
+
+	RegCloseKey(key);
+}
+
+void system_info(GoLiveApi::Capabilities &capabilities)
+{
+	char tmpstr[1024];
+
+	capabilities.gpu = system_gpu_data();
+
+	{
+		auto &cpu_data = capabilities.cpu;
+		cpu_data.physical_cores = os_get_physical_cores();
+		cpu_data.logical_cores = os_get_logical_cores();
+		DWORD processorSpeed;
+		char *processorName;
+		get_processor_info(&processorName, &processorSpeed);
+		if (processorSpeed)
+			cpu_data.speed = processorSpeed;
+		if (processorName)
+			cpu_data.name = processorName;
+		bfree(processorName);
+	}
+
+	{
+		auto &memory_data = capabilities.memory;
+		memory_data.total = os_get_sys_total_size();
+		memory_data.free = os_get_sys_free_size();
+	}
+
+	struct win_version_info ver;
+	get_win_ver(&ver);
+	get_reg_ver(&ver);
+
+	// Gaming features
+	capabilities.gaming_features = get_gaming_features_data(ver);
+
+	{
+		auto &system_data = capabilities.system;
+
+		snprintf(tmpstr, sizeof(tmpstr), "%d.%d", ver.major, ver.minor);
+
+		system_data.version = tmpstr;
+		system_data.name = "Windows";
+		system_data.build = ver.build;
+		system_data.release = win_release_id;
+		system_data.revision = ver.revis;
+		system_data.bits = is_64_bit_windows() ? 64 : 32;
+		system_data.arm = is_arm64_windows();
+		system_data.armEmulation = os_get_emulation_status();
+	}
+}

+ 5 - 0
UI/system-info.hpp

@@ -0,0 +1,5 @@
+#pragma once
+
+#include "models/multitrack-video.hpp"
+
+void system_info(GoLiveApi::Capabilities &capabilities);

+ 2 - 0
UI/window-basic-auto-config.cpp

@@ -3,6 +3,8 @@
 
 #include <obs.hpp>
 
+#include <nlohmann/json.hpp>
+
 #include "window-basic-auto-config.hpp"
 #include "window-basic-main.hpp"
 #include "qt-wrappers.hpp"

+ 405 - 101
UI/window-basic-main-outputs.cpp

@@ -1,8 +1,11 @@
 #include <string>
 #include <algorithm>
+#include <cinttypes>
 #include <QMessageBox>
+#include <QPromise>
 #include "qt-wrappers.hpp"
 #include "audio-encoders.hpp"
+#include "multitrack-video-error.hpp"
 #include "window-basic-main.hpp"
 #include "window-basic-main-outputs.hpp"
 #include "window-basic-vcam.hpp"
@@ -67,6 +70,7 @@ static void OBSStopStreaming(void *data, calldata_t *params)
 
 	output->streamingActive = false;
 	output->delayActive = false;
+	output->multitrackVideoActive = false;
 	os_atomic_set_bool(&streaming_active, false);
 	QMetaObject::invokeMethod(output->main, "StreamingStop",
 				  Q_ARG(int, code),
@@ -300,6 +304,18 @@ inline BasicOutputHandler::BasicOutputHandler(OBSBasic *main_) : main(main_)
 		deactivateVirtualCam.Connect(signal, "deactivate",
 					     OBSDeactivateVirtualCam, this);
 	}
+
+	auto multitrack_enabled = config_get_bool(main->Config(), "Stream1",
+						  "EnableMultitrackVideo");
+	if (!config_has_user_value(main->Config(), "Stream1",
+				   "EnableMultitrackVideo")) {
+		auto service = main_->GetService();
+		OBSDataAutoRelease settings = obs_service_get_settings(service);
+		multitrack_enabled = obs_data_has_user_value(
+			settings, "multitrack_video_configuration_url");
+	}
+	if (multitrack_enabled)
+		multitrackVideo = make_unique<MultitrackVideoOutput>();
 }
 
 extern void log_vcam_changed(const VCamConfig &config, bool starting);
@@ -501,9 +517,11 @@ struct SimpleOutput : BasicOutputHandler {
 	void UpdateRecording();
 	bool ConfigureRecording(bool useReplayBuffer);
 
+	bool IsVodTrackEnabled(obs_service_t *service);
 	void SetupVodTrack(obs_service_t *service);
 
-	virtual bool SetupStreaming(obs_service_t *service) override;
+	virtual FutureHolder<bool>
+	SetupStreaming(obs_service_t *service) override;
 	virtual bool StartStreaming(obs_service_t *service) override;
 	virtual bool StartRecording() override;
 	virtual bool StartReplayBuffer() override;
@@ -1100,7 +1118,7 @@ const char *FindAudioEncoderFromCodec(const char *type)
 	return nullptr;
 }
 
-bool SimpleOutput::SetupStreaming(obs_service_t *service)
+FutureHolder<bool> SimpleOutput::SetupStreaming(obs_service_t *service)
 {
 	if (!Active())
 		SetupOutputs();
@@ -1113,46 +1131,72 @@ bool SimpleOutput::SetupStreaming(obs_service_t *service)
 
 	const char *type = GetStreamOutputType(service);
 	if (!type)
-		return false;
-
-	/* XXX: this is messy and disgusting and should be refactored */
-	if (outputType != type) {
-		streamDelayStarting.Disconnect();
-		streamStopping.Disconnect();
-		startStreaming.Disconnect();
-		stopStreaming.Disconnect();
-
-		streamOutput = obs_output_create(type, "simple_stream", nullptr,
-						 nullptr);
-		if (!streamOutput) {
-			blog(LOG_WARNING,
-			     "Creation of stream output type '%s' "
-			     "failed!",
-			     type);
-			return false;
-		}
-
-		streamDelayStarting.Connect(
-			obs_output_get_signal_handler(streamOutput), "starting",
-			OBSStreamStarting, this);
-		streamStopping.Connect(
-			obs_output_get_signal_handler(streamOutput), "stopping",
-			OBSStreamStopping, this);
-
-		startStreaming.Connect(
-			obs_output_get_signal_handler(streamOutput), "start",
-			OBSStartStreaming, this);
-		stopStreaming.Connect(
-			obs_output_get_signal_handler(streamOutput), "stop",
-			OBSStopStreaming, this);
+		return {[] {}, CreateFuture().then([] { return false; })};
+
+	auto audio_bitrate = GetAudioBitrate();
+	auto vod_track_mixer = IsVodTrackEnabled(service) ? std::optional{1}
+							  : std::nullopt;
+
+	auto holder = SetupMultitrackVideo(
+		service, GetSimpleAACEncoderForBitrate(audio_bitrate),
+		vod_track_mixer);
+	auto future =
+		PreventFutureDeadlock(holder.future)
+			.then(main, [&](std::optional<bool>
+						multitrackVideoResult) {
+				if (multitrackVideoResult.has_value())
+					return multitrackVideoResult.value();
+
+				/* XXX: this is messy and disgusting and should be refactored */
+				if (outputType != type) {
+					streamDelayStarting.Disconnect();
+					streamStopping.Disconnect();
+					startStreaming.Disconnect();
+					stopStreaming.Disconnect();
+
+					streamOutput = obs_output_create(
+						type, "simple_stream", nullptr,
+						nullptr);
+					if (!streamOutput) {
+						blog(LOG_WARNING,
+						     "Creation of stream output type '%s' "
+						     "failed!",
+						     type);
+						return false;
+					}
 
-		outputType = type;
-	}
+					streamDelayStarting.Connect(
+						obs_output_get_signal_handler(
+							streamOutput),
+						"starting", OBSStreamStarting,
+						this);
+					streamStopping.Connect(
+						obs_output_get_signal_handler(
+							streamOutput),
+						"stopping", OBSStreamStopping,
+						this);
+
+					startStreaming.Connect(
+						obs_output_get_signal_handler(
+							streamOutput),
+						"start", OBSStartStreaming,
+						this);
+					stopStreaming.Connect(
+						obs_output_get_signal_handler(
+							streamOutput),
+						"stop", OBSStopStreaming, this);
+
+					outputType = type;
+				}
 
-	obs_output_set_video_encoder(streamOutput, videoStreaming);
-	obs_output_set_audio_encoder(streamOutput, audioStreaming, 0);
-	obs_output_set_service(streamOutput, service);
-	return true;
+				obs_output_set_video_encoder(streamOutput,
+							     videoStreaming);
+				obs_output_set_audio_encoder(streamOutput,
+							     audioStreaming, 0);
+				obs_output_set_service(streamOutput, service);
+				return true;
+			});
+	return {holder.cancelAll, future};
 }
 
 static inline bool ServiceSupportsVodTrack(const char *service);
@@ -1174,7 +1218,7 @@ static void clear_archive_encoder(obs_output_t *output,
 		obs_output_set_audio_encoder(output, nullptr, 1);
 }
 
-void SimpleOutput::SetupVodTrack(obs_service_t *service)
+bool SimpleOutput::IsVodTrackEnabled(obs_service_t *service)
 {
 	bool advanced =
 		config_get_bool(main->Config(), "SimpleOutput", "UseAdvanced");
@@ -1188,11 +1232,14 @@ void SimpleOutput::SetupVodTrack(obs_service_t *service)
 
 	const char *id = obs_service_get_id(service);
 	if (strcmp(id, "rtmp_custom") == 0)
-		enable = enableForCustomServer ? enable : false;
+		return enableForCustomServer ? enable : false;
 	else
-		enable = advanced && enable && ServiceSupportsVodTrack(name);
+		return advanced && enable && ServiceSupportsVodTrack(name);
+}
 
-	if (enable)
+void SimpleOutput::SetupVodTrack(obs_service_t *service)
+{
+	if (IsVodTrackEnabled(service))
 		obs_output_set_audio_encoder(streamOutput, audioArchive, 1);
 	else
 		clear_archive_encoder(streamOutput, SIMPLE_ARCHIVE_NAME);
@@ -1219,10 +1266,20 @@ bool SimpleOutput::StartStreaming(obs_service_t *service)
 						   "NewSocketLoopEnable");
 	bool enableLowLatencyMode =
 		config_get_bool(main->Config(), "Output", "LowLatencyEnable");
+#else
+	bool enableNewSocketLoop = false;
 #endif
 	bool enableDynBitrate =
 		config_get_bool(main->Config(), "Output", "DynamicBitrate");
 
+	if (multitrackVideo && multitrackVideoActive &&
+	    !multitrackVideo->HandleIncompatibleSettings(
+		    main, main->Config(), service, useDelay,
+		    enableNewSocketLoop, enableDynBitrate)) {
+		multitrackVideoActive = false;
+		return false;
+	}
+
 	OBSDataAutoRelease settings = obs_data_create();
 	obs_data_set_string(settings, "bind_ip", bindIP);
 	obs_data_set_string(settings, "ip_family", ipFamily);
@@ -1233,6 +1290,10 @@ bool SimpleOutput::StartStreaming(obs_service_t *service)
 			  enableLowLatencyMode);
 #endif
 	obs_data_set_bool(settings, "dyn_bitrate", enableDynBitrate);
+
+	auto streamOutput =
+		StreamingOutput(); // shadowing is sort of bad, but also convenient
+
 	obs_output_update(streamOutput, settings);
 
 	if (!reconnect)
@@ -1243,12 +1304,18 @@ bool SimpleOutput::StartStreaming(obs_service_t *service)
 
 	obs_output_set_reconnect_settings(streamOutput, maxRetries, retryDelay);
 
-	SetupVodTrack(service);
+	if (!multitrackVideo || !multitrackVideoActive)
+		SetupVodTrack(service);
 
 	if (obs_output_start(streamOutput)) {
+		if (multitrackVideo && multitrackVideoActive)
+			multitrackVideo->StartedStreaming();
 		return true;
 	}
 
+	if (multitrackVideo && multitrackVideoActive)
+		multitrackVideoActive = false;
+
 	const char *error = obs_output_get_last_error(streamOutput);
 	bool hasLastError = error && *error;
 	if (hasLastError)
@@ -1437,10 +1504,13 @@ bool SimpleOutput::StartReplayBuffer()
 
 void SimpleOutput::StopStreaming(bool force)
 {
-	if (force)
-		obs_output_force_stop(streamOutput);
+	auto output = StreamingOutput();
+	if (force && output)
+		obs_output_force_stop(output);
+	else if (multitrackVideo && multitrackVideoActive)
+		multitrackVideo->StopStreaming();
 	else
-		obs_output_stop(streamOutput);
+		obs_output_stop(output);
 }
 
 void SimpleOutput::StopRecording(bool force)
@@ -1461,7 +1531,7 @@ void SimpleOutput::StopReplayBuffer(bool force)
 
 bool SimpleOutput::StreamingActive() const
 {
-	return obs_output_active(streamOutput);
+	return obs_output_active(StreamingOutput());
 }
 
 bool SimpleOutput::RecordingActive() const
@@ -1497,6 +1567,7 @@ struct AdvancedOutput : BasicOutputHandler {
 	inline void UpdateAudioSettings();
 	virtual void Update() override;
 
+	inline std::optional<size_t> VodTrackMixerIdx(obs_service_t *service);
 	inline void SetupVodTrack(obs_service_t *service);
 
 	inline void SetupStreaming();
@@ -1505,7 +1576,8 @@ struct AdvancedOutput : BasicOutputHandler {
 	void SetupOutputs() override;
 	int GetAudioBitrate(size_t i, const char *id) const;
 
-	virtual bool SetupStreaming(obs_service_t *service) override;
+	virtual FutureHolder<bool>
+	SetupStreaming(obs_service_t *service) override;
 	virtual bool StartStreaming(obs_service_t *service) override;
 	virtual bool StartRecording() override;
 	virtual bool StartReplayBuffer() override;
@@ -2148,7 +2220,8 @@ int AdvancedOutput::GetAudioBitrate(size_t i, const char *id) const
 	return FindClosestAvailableAudioBitrate(id, bitrate);
 }
 
-inline void AdvancedOutput::SetupVodTrack(obs_service_t *service)
+inline std::optional<size_t>
+AdvancedOutput::VodTrackMixerIdx(obs_service_t *service)
 {
 	int streamTrackIndex =
 		config_get_int(main->Config(), "AdvOut", "TrackIndex");
@@ -2169,13 +2242,21 @@ inline void AdvancedOutput::SetupVodTrack(obs_service_t *service)
 		if (!ServiceSupportsVodTrack(service))
 			vodTrackEnabled = false;
 	}
+
 	if (vodTrackEnabled && streamTrackIndex != vodTrackIndex)
+		return {vodTrackIndex - 1};
+	return std::nullopt;
+}
+
+inline void AdvancedOutput::SetupVodTrack(obs_service_t *service)
+{
+	if (VodTrackMixerIdx(service).has_value())
 		obs_output_set_audio_encoder(streamOutput, streamArchiveEnc, 1);
 	else
 		clear_archive_encoder(streamOutput, ADV_ARCHIVE_NAME);
 }
 
-bool AdvancedOutput::SetupStreaming(obs_service_t *service)
+FutureHolder<bool> AdvancedOutput::SetupStreaming(obs_service_t *service)
 {
 	int multiTrackAudioMixes = config_get_int(main->Config(), "AdvOut",
 						  "StreamMultiTrackAudioMixes");
@@ -2201,55 +2282,88 @@ bool AdvancedOutput::SetupStreaming(obs_service_t *service)
 
 	const char *type = GetStreamOutputType(service);
 	if (!type)
-		return false;
+		return {[] {}, CreateFuture().then(main, [] { return false; })};
 
-	/* XXX: this is messy and disgusting and should be refactored */
-	if (outputType != type) {
-		streamDelayStarting.Disconnect();
-		streamStopping.Disconnect();
-		startStreaming.Disconnect();
-		stopStreaming.Disconnect();
+	const char *audio_encoder_id =
+		config_get_string(main->Config(), "AdvOut", "AudioEncoder");
 
-		streamOutput =
-			obs_output_create(type, "adv_stream", nullptr, nullptr);
-		if (!streamOutput) {
-			blog(LOG_WARNING,
-			     "Creation of stream output type '%s' "
-			     "failed!",
-			     type);
-			return false;
-		}
+	auto holder = SetupMultitrackVideo(service, audio_encoder_id,
+					   VodTrackMixerIdx(service));
+	auto future =
+		PreventFutureDeadlock(holder.future)
+			.then(main, [&](std::optional<bool>
+						multitrackVideoResult) {
+				if (multitrackVideoResult.has_value())
+					return multitrackVideoResult.value();
+
+				/* XXX: this is messy and disgusting and should be refactored */
+				if (outputType != type) {
+					streamDelayStarting.Disconnect();
+					streamStopping.Disconnect();
+					startStreaming.Disconnect();
+					stopStreaming.Disconnect();
+
+					streamOutput = obs_output_create(
+						type, "adv_stream", nullptr,
+						nullptr);
+					if (!streamOutput) {
+						blog(LOG_WARNING,
+						     "Creation of stream output type '%s' "
+						     "failed!",
+						     type);
+						return false;
+					}
 
-		streamDelayStarting.Connect(
-			obs_output_get_signal_handler(streamOutput), "starting",
-			OBSStreamStarting, this);
-		streamStopping.Connect(
-			obs_output_get_signal_handler(streamOutput), "stopping",
-			OBSStreamStopping, this);
+					streamDelayStarting.Connect(
+						obs_output_get_signal_handler(
+							streamOutput),
+						"starting", OBSStreamStarting,
+						this);
+					streamStopping.Connect(
+						obs_output_get_signal_handler(
+							streamOutput),
+						"stopping", OBSStreamStopping,
+						this);
+
+					startStreaming.Connect(
+						obs_output_get_signal_handler(
+							streamOutput),
+						"start", OBSStartStreaming,
+						this);
+					stopStreaming.Connect(
+						obs_output_get_signal_handler(
+							streamOutput),
+						"stop", OBSStopStreaming, this);
+
+					outputType = type;
+				}
 
-		startStreaming.Connect(
-			obs_output_get_signal_handler(streamOutput), "start",
-			OBSStartStreaming, this);
-		stopStreaming.Connect(
-			obs_output_get_signal_handler(streamOutput), "stop",
-			OBSStopStreaming, this);
+				obs_output_set_video_encoder(streamOutput,
+							     videoStreaming);
+				obs_output_set_audio_encoder(streamOutput,
+							     streamAudioEnc, 0);
 
-		outputType = type;
-	}
+				if (!is_multitrack_output) {
+					obs_output_set_audio_encoder(
+						streamOutput, streamAudioEnc,
+						0);
+				} else {
+					for (int i = 0; i < MAX_AUDIO_MIXES;
+					     i++) {
+						if ((multiTrackAudioMixes &
+						     (1 << i)) != 0) {
+							obs_output_set_audio_encoder(
+								streamOutput,
+								streamTrack[i],
+								idx);
+							idx++;
+						}
+					}
+				}
 
-	obs_output_set_video_encoder(streamOutput, videoStreaming);
-	if (!is_multitrack_output) {
-		obs_output_set_audio_encoder(streamOutput, streamAudioEnc, 0);
-	} else {
-		for (int i = 0; i < MAX_AUDIO_MIXES; i++) {
-			if ((multiTrackAudioMixes & (1 << i)) != 0) {
-				obs_output_set_audio_encoder(
-					streamOutput, streamTrack[i], idx);
-				idx++;
-			}
-		}
-	}
-	return true;
+				return true;
+			});
+	return {holder.cancelAll, future};
 }
 
 bool AdvancedOutput::StartStreaming(obs_service_t *service)
@@ -2273,10 +2387,20 @@ bool AdvancedOutput::StartStreaming(obs_service_t *service)
 						   "NewSocketLoopEnable");
 	bool enableLowLatencyMode =
 		config_get_bool(main->Config(), "Output", "LowLatencyEnable");
+#else
+	bool enableNewSocketLoop = false;
 #endif
 	bool enableDynBitrate =
 		config_get_bool(main->Config(), "Output", "DynamicBitrate");
 
+	if (multitrackVideo && multitrackVideoActive &&
+	    !multitrackVideo->HandleIncompatibleSettings(
+		    main, main->Config(), service, useDelay,
+		    enableNewSocketLoop, enableDynBitrate)) {
+		multitrackVideoActive = false;
+		return false;
+	}
+
 	bool is_rtmp = false;
 	obs_service_t *service_obj = main->GetService();
 	const char *protocol = obs_service_get_protocol(service_obj);
@@ -2296,6 +2420,10 @@ bool AdvancedOutput::StartStreaming(obs_service_t *service)
 			  enableLowLatencyMode);
 #endif
 	obs_data_set_bool(settings, "dyn_bitrate", enableDynBitrate);
+
+	auto streamOutput =
+		StreamingOutput(); // shadowing is sort of bad, but also convenient
+
 	obs_output_update(streamOutput, settings);
 
 	if (!reconnect)
@@ -2309,9 +2437,14 @@ bool AdvancedOutput::StartStreaming(obs_service_t *service)
 		SetupVodTrack(service);
 	}
 	if (obs_output_start(streamOutput)) {
+		if (multitrackVideo && multitrackVideoActive)
+			multitrackVideo->StartedStreaming();
 		return true;
 	}
 
+	if (multitrackVideo && multitrackVideoActive)
+		multitrackVideoActive = false;
+
 	const char *error = obs_output_get_last_error(streamOutput);
 	bool hasLastError = error && *error;
 	if (hasLastError)
@@ -2341,7 +2474,7 @@ bool AdvancedOutput::StartRecording()
 		if (!ffmpegOutput) {
 			UpdateRecordingSettings();
 		}
-	} else if (!obs_output_active(streamOutput)) {
+	} else if (!obs_output_active(StreamingOutput())) {
 		UpdateStreamSettings();
 	}
 
@@ -2440,7 +2573,7 @@ bool AdvancedOutput::StartReplayBuffer()
 	if (!useStreamEncoder) {
 		if (!ffmpegOutput)
 			UpdateRecordingSettings();
-	} else if (!obs_output_active(streamOutput)) {
+	} else if (!obs_output_active(StreamingOutput())) {
 		UpdateStreamSettings();
 	}
 
@@ -2504,10 +2637,13 @@ bool AdvancedOutput::StartReplayBuffer()
 
 void AdvancedOutput::StopStreaming(bool force)
 {
-	if (force)
-		obs_output_force_stop(streamOutput);
+	auto output = StreamingOutput();
+	if (force && output)
+		obs_output_force_stop(output);
+	else if (multitrackVideo && multitrackVideoActive)
+		multitrackVideo->StopStreaming();
 	else
-		obs_output_stop(streamOutput);
+		obs_output_stop(output);
 }
 
 void AdvancedOutput::StopRecording(bool force)
@@ -2528,7 +2664,7 @@ void AdvancedOutput::StopReplayBuffer(bool force)
 
 bool AdvancedOutput::StreamingActive() const
 {
-	return obs_output_active(streamOutput);
+	return obs_output_active(StreamingOutput());
 }
 
 bool AdvancedOutput::RecordingActive() const
@@ -2563,6 +2699,174 @@ std::string BasicOutputHandler::GetRecordingFilename(
 	return dst;
 }
 
+extern std::string DeserializeConfigText(const char *text);
+
+FutureHolder<std::optional<bool>>
+BasicOutputHandler::SetupMultitrackVideo(obs_service_t *service,
+					 std::string audio_encoder_id,
+					 std::optional<size_t> vod_track_mixer)
+{
+	if (!multitrackVideo)
+		return {[] {}, CreateFuture().then([] {
+				return std::optional<bool>{std::nullopt};
+			})};
+
+	multitrackVideoActive = false;
+
+	streamDelayStarting.Disconnect();
+	streamStopping.Disconnect();
+	startStreaming.Disconnect();
+	stopStreaming.Disconnect();
+
+	bool is_custom =
+		strncmp("rtmp_custom", obs_service_get_type(service), 11) == 0;
+
+	std::optional<std::string> custom_config = std::nullopt;
+	if (config_get_bool(main->Config(), "Stream1",
+			    "MultitrackVideoConfigOverrideEnabled"))
+		custom_config = DeserializeConfigText(
+			config_get_string(main->Config(), "Stream1",
+					  "MultitrackVideoConfigOverride"));
+
+	OBSDataAutoRelease settings = obs_service_get_settings(service);
+	QString key = obs_data_get_string(settings, "key");
+
+	const char *service_name = "<unknown>";
+	if (is_custom && obs_data_has_user_value(settings, "service_name")) {
+		service_name = obs_data_get_string(settings, "service_name");
+	} else if (!is_custom) {
+		service_name = obs_data_get_string(settings, "service");
+	}
+
+	std::optional<std::string> custom_rtmp_url;
+	auto server = obs_data_get_string(settings, "server");
+	if (strcmp(server, "auto") != 0) {
+		custom_rtmp_url = server;
+	}
+
+	auto service_custom_server =
+		obs_data_get_bool(settings, "using_custom_server");
+	if (custom_rtmp_url.has_value()) {
+		blog(LOG_INFO, "Using %sserver '%s'",
+		     service_custom_server ? "custom " : "",
+		     custom_rtmp_url->c_str());
+	}
+
+	auto maximum_aggregate_bitrate =
+		config_get_bool(main->Config(), "Stream1",
+				"MultitrackVideoMaximumAggregateBitrateAuto")
+			? std::nullopt
+			: std::make_optional<uint32_t>(config_get_int(
+				  main->Config(), "Stream1",
+				  "MultitrackVideoMaximumAggregateBitrate"));
+
+	auto maximum_video_tracks =
+		config_get_bool(main->Config(), "Stream1",
+				"MultitrackVideoMaximumVideoTracksAuto")
+			? std::nullopt
+			: std::make_optional<uint32_t>(config_get_int(
+				  main->Config(), "Stream1",
+				  "MultitrackVideoMaximumVideoTracks"));
+
+	auto stream_dump_config = GenerateMultitrackVideoStreamDumpConfig();
+
+	auto firstFuture = CreateFuture().then(
+		QThreadPool::globalInstance(),
+		[=, multitrackVideo = multitrackVideo.get(),
+		 service_name = std::string{service_name},
+		 service = OBSService{service},
+		 stream_dump_config = std::move(stream_dump_config)]()
+			-> std::optional<MultitrackVideoError> {
+			try {
+				multitrackVideo->PrepareStreaming(
+					main, service_name.c_str(), service,
+					custom_rtmp_url, key,
+					audio_encoder_id.c_str(),
+					maximum_aggregate_bitrate,
+					maximum_video_tracks, custom_config,
+					stream_dump_config, vod_track_mixer);
+			} catch (const MultitrackVideoError &error) {
+				return error;
+			}
+			return std::nullopt;
+		});
+
+	auto secondFuture = firstFuture.then(
+		main,
+		[&, service = OBSService{service}](
+			std::optional<MultitrackVideoError> error)
+			-> std::optional<bool> {
+			if (error) {
+				OBSDataAutoRelease service_settings =
+					obs_service_get_settings(service);
+				auto multitrack_video_name = QTStr(
+					"Basic.Settings.Stream.MultitrackVideoLabel");
+				if (obs_data_has_user_value(
+					    service_settings,
+					    "multitrack_video_name")) {
+					multitrack_video_name =
+						obs_data_get_string(
+							service_settings,
+							"multitrack_video_name");
+				}
+
+				multitrackVideoActive = false;
+				if (!error->ShowDialog(main,
+						       multitrack_video_name))
+					return false;
+				return std::nullopt;
+			}
+
+			multitrackVideoActive = true;
+
+			auto signal_handler =
+				multitrackVideo->StreamingSignalHandler();
+
+			streamDelayStarting.Connect(signal_handler, "starting",
+						    OBSStreamStarting, this);
+			streamStopping.Connect(signal_handler, "stopping",
+					       OBSStreamStopping, this);
+
+			startStreaming.Connect(signal_handler, "start",
+					       OBSStartStreaming, this);
+			stopStreaming.Connect(signal_handler, "stop",
+					      OBSStopStreaming, this);
+			return true;
+		});
+
+	return {[=]() mutable { firstFuture.cancel(); },
+		PreventFutureDeadlock(secondFuture)};
+}
+
+OBSDataAutoRelease BasicOutputHandler::GenerateMultitrackVideoStreamDumpConfig()
+{
+	auto stream_dump_enabled = config_get_bool(
+		main->Config(), "Stream1", "MultitrackVideoStreamDumpEnabled");
+
+	if (!stream_dump_enabled)
+		return nullptr;
+
+	const char *path =
+		config_get_string(main->Config(), "SimpleOutput", "FilePath");
+	bool noSpace = config_get_bool(main->Config(), "SimpleOutput",
+				       "FileNameWithoutSpace");
+	const char *filenameFormat = config_get_string(main->Config(), "Output",
+						       "FilenameFormatting");
+	bool overwriteIfExists =
+		config_get_bool(main->Config(), "Output", "OverwriteIfExists");
+
+	string f;
+
+	OBSDataAutoRelease settings = obs_data_create();
+	f = GetFormatString(filenameFormat, nullptr, nullptr);
+	string strPath = GetRecordingFilename(path, "flv", noSpace,
+					      overwriteIfExists, f.c_str(),
+					      // never remux stream dump
+					      false);
+	obs_data_set_string(settings, "path", strPath.c_str());
+	return settings;
+}
+
 BasicOutputHandler *CreateSimpleOutputHandler(OBSBasic *main)
 {
 	return new SimpleOutput(main);

+ 26 - 2
UI/window-basic-main-outputs.hpp

@@ -1,7 +1,13 @@
 #pragma once
 
+#include <memory>
 #include <string>
 
+#include <QFuture>
+
+#include "qt-helpers.hpp"
+#include "multitrack-video-output.hpp"
+
 class OBSBasic;
 
 struct BasicOutputHandler {
@@ -16,6 +22,17 @@ struct BasicOutputHandler {
 	bool virtualCamActive = false;
 	OBSBasic *main;
 
+	std::unique_ptr<MultitrackVideoOutput> multitrackVideo;
+	bool multitrackVideoActive = false;
+
+	OBSOutputAutoRelease StreamingOutput() const
+	{
+		return (multitrackVideo && multitrackVideoActive)
+			       ? multitrackVideo->StreamingOutput()
+			       : OBSOutputAutoRelease{
+					 obs_output_get_ref(streamOutput)};
+	}
+
 	obs_view_t *virtualCamView = nullptr;
 	video_t *virtualCamVideo = nullptr;
 	obs_scene_t *vCamSourceScene = nullptr;
@@ -46,7 +63,7 @@ struct BasicOutputHandler {
 
 	virtual ~BasicOutputHandler(){};
 
-	virtual bool SetupStreaming(obs_service_t *service) = 0;
+	virtual FutureHolder<bool> SetupStreaming(obs_service_t *service) = 0;
 	virtual bool StartStreaming(obs_service_t *service) = 0;
 	virtual bool StartRecording() = 0;
 	virtual bool StartReplayBuffer() { return false; }
@@ -70,7 +87,8 @@ struct BasicOutputHandler {
 	inline bool Active() const
 	{
 		return streamingActive || recordingActive || delayActive ||
-		       replayBufferActive || virtualCamActive;
+		       replayBufferActive || virtualCamActive ||
+		       multitrackVideoActive;
 	}
 
 protected:
@@ -79,6 +97,12 @@ protected:
 					 const char *container, bool noSpace,
 					 bool overwrite, const char *format,
 					 bool ffmpeg);
+
+	FutureHolder<std::optional<bool>>
+	SetupMultitrackVideo(obs_service_t *service,
+			     std::string audio_encoder_id,
+			     std::optional<size_t> vod_track_mixer);
+	OBSDataAutoRelease GenerateMultitrackVideoStreamDumpConfig();
 };
 
 BasicOutputHandler *CreateSimpleOutputHandler(OBSBasic *main);

+ 98 - 52
UI/window-basic-main.cpp

@@ -1615,6 +1615,13 @@ bool OBSBasic::InitBasicConfigDefaults()
 
 	config_set_default_bool(basicConfig, "Stream1", "IgnoreRecommended",
 				false);
+	config_set_default_bool(basicConfig, "Stream1", "EnableMultitrackVideo",
+				true);
+	config_set_default_bool(basicConfig, "Stream1",
+				"MultitrackVideoMaximumAggregateBitrateAuto",
+				true);
+	config_set_default_bool(basicConfig, "Stream1",
+				"MultitrackVideoMaximumVideoTracksAuto", true);
 
 	config_set_default_string(basicConfig, "SimpleOutput", "FilePath",
 				  GetDefaultVideoSavePath().c_str());
@@ -1953,7 +1960,8 @@ void OBSBasic::ResetOutputs()
 	const char *mode = config_get_string(basicConfig, "Output", "Mode");
 	bool advOut = astrcmpi(mode, "Advanced") == 0;
 
-	if (!outputHandler || !outputHandler->Active()) {
+	if ((!outputHandler || !outputHandler->Active()) &&
+	    startStreamingFuture.future.isFinished()) {
 		outputHandler.reset();
 		outputHandler.reset(advOut ? CreateAdvancedOutputHandler(this)
 					   : CreateSimpleOutputHandler(this));
@@ -5097,6 +5105,22 @@ void OBSBasic::ClearSceneData()
 
 void OBSBasic::closeEvent(QCloseEvent *event)
 {
+	if (!startStreamingFuture.future.isFinished() &&
+	    !startStreamingFuture.future.isCanceled()) {
+		startStreamingFuture.future.onCanceled(
+			this, [basic = QPointer{this}] {
+				if (basic)
+					basic->close();
+			});
+		startStreamingFuture.cancelAll();
+		event->ignore();
+		return;
+	} else if (startStreamingFuture.future.isCanceled() &&
+		   !startStreamingFuture.future.isFinished()) {
+		event->ignore();
+		return;
+	}
+
 	/* Do not close window if inside of a temporary event loop because we
 	 * could be inside of an Auth::LoadUI call.  Keep trying once per
 	 * second until we've exit any known sub-loops. */
@@ -7016,68 +7040,88 @@ void OBSBasic::StartStreaming()
 		}
 	}
 
-	if (!outputHandler->SetupStreaming(service)) {
-		DisplayStreamStartError();
-		return;
-	}
-
-	if (api)
-		api->on_event(OBS_FRONTEND_EVENT_STREAMING_STARTING);
-
-	SaveProject();
+	auto setStreamText = [&](const QString &text) {
+		ui->streamButton->setText(text);
+		if (sysTrayStream)
+			sysTrayStream->setText(text);
+	};
 
 	ui->streamButton->setEnabled(false);
 	ui->streamButton->setChecked(false);
-	ui->streamButton->setText(QTStr("Basic.Main.Connecting"));
 	ui->broadcastButton->setChecked(false);
-
-	if (sysTrayStream) {
+	if (sysTrayStream)
 		sysTrayStream->setEnabled(false);
-		sysTrayStream->setText(ui->streamButton->text());
-	}
 
-	if (!outputHandler->StartStreaming(service)) {
-		DisplayStreamStartError();
-		return;
-	}
+	setStreamText(QTStr("Basic.Main.PreparingStream"));
 
-	if (!autoStartBroadcast) {
-		ui->broadcastButton->setText(
-			QTStr("Basic.Main.StartBroadcast"));
-		ui->broadcastButton->setProperty("broadcastState", "ready");
-		ui->broadcastButton->style()->unpolish(ui->broadcastButton);
-		ui->broadcastButton->style()->polish(ui->broadcastButton);
-		// well, we need to disable button while stream is not active
-		ui->broadcastButton->setEnabled(false);
-	} else {
-		if (!autoStopBroadcast) {
-			ui->broadcastButton->setText(
-				QTStr("Basic.Main.StopBroadcast"));
-		} else {
-			ui->broadcastButton->setText(
-				QTStr("Basic.Main.AutoStopEnabled"));
-			ui->broadcastButton->setEnabled(false);
-		}
-		ui->broadcastButton->setProperty("broadcastState", "active");
-		ui->broadcastButton->style()->unpolish(ui->broadcastButton);
-		ui->broadcastButton->style()->polish(ui->broadcastButton);
-		broadcastActive = true;
-	}
+	auto holder = outputHandler->SetupStreaming(service);
+	auto future = holder.future.then(
+		this, [&, setStreamText](bool setupStreamingResult) {
+			if (!setupStreamingResult) {
+				DisplayStreamStartError();
+				return;
+			}
 
-	bool recordWhenStreaming = config_get_bool(
-		GetGlobalConfig(), "BasicWindow", "RecordWhenStreaming");
-	if (recordWhenStreaming)
-		StartRecording();
+			if (api)
+				api->on_event(
+					OBS_FRONTEND_EVENT_STREAMING_STARTING);
 
-	bool replayBufferWhileStreaming = config_get_bool(
-		GetGlobalConfig(), "BasicWindow", "ReplayBufferWhileStreaming");
-	if (replayBufferWhileStreaming)
-		StartReplayBuffer();
+			SaveProject();
+
+			setStreamText(QTStr("Basic.Main.Connecting"));
+
+			if (!outputHandler->StartStreaming(service)) {
+				DisplayStreamStartError();
+				return;
+			}
+
+			if (!autoStartBroadcast) {
+				ui->broadcastButton->setText(
+					QTStr("Basic.Main.StartBroadcast"));
+				ui->broadcastButton->setProperty(
+					"broadcastState", "ready");
+				ui->broadcastButton->style()->unpolish(
+					ui->broadcastButton);
+				ui->broadcastButton->style()->polish(
+					ui->broadcastButton);
+				// well, we need to disable button while stream is not active
+				ui->broadcastButton->setEnabled(false);
+			} else {
+				if (!autoStopBroadcast) {
+					ui->broadcastButton->setText(QTStr(
+						"Basic.Main.StopBroadcast"));
+				} else {
+					ui->broadcastButton->setText(QTStr(
+						"Basic.Main.AutoStopEnabled"));
+					ui->broadcastButton->setEnabled(false);
+				}
+				ui->broadcastButton->setProperty(
+					"broadcastState", "active");
+				ui->broadcastButton->style()->unpolish(
+					ui->broadcastButton);
+				ui->broadcastButton->style()->polish(
+					ui->broadcastButton);
+				broadcastActive = true;
+			}
+
+			bool recordWhenStreaming = config_get_bool(
+				GetGlobalConfig(), "BasicWindow",
+				"RecordWhenStreaming");
+			if (recordWhenStreaming)
+				StartRecording();
+
+			bool replayBufferWhileStreaming = config_get_bool(
+				GetGlobalConfig(), "BasicWindow",
+				"ReplayBufferWhileStreaming");
+			if (replayBufferWhileStreaming)
+				StartReplayBuffer();
 
 #ifdef YOUTUBE_ENABLED
-	if (!autoStartBroadcast)
-		OBSBasic::ShowYouTubeAutoStartWarning();
+			if (!autoStartBroadcast)
+				OBSBasic::ShowYouTubeAutoStartWarning();
 #endif
+		});
+	startStreamingFuture = {holder.cancelAll, future};
 }
 
 void OBSBasic::BroadcastButtonClicked()
@@ -7447,10 +7491,12 @@ void OBSBasic::StreamDelayStopping(int sec)
 
 void OBSBasic::StreamingStart()
 {
+	OBSOutputAutoRelease output = obs_frontend_get_streaming_output();
+
 	ui->streamButton->setText(QTStr("Basic.Main.StopStreaming"));
 	ui->streamButton->setEnabled(true);
 	ui->streamButton->setChecked(true);
-	ui->statusbar->StreamStarted(outputHandler->streamOutput);
+	ui->statusbar->StreamStarted(output);
 
 	if (sysTrayStream) {
 		sysTrayStream->setText(ui->streamButton->text());

+ 3 - 0
UI/window-basic-main.hpp

@@ -23,6 +23,7 @@
 #include <QWidgetAction>
 #include <QSystemTrayIcon>
 #include <QStyledItemDelegate>
+#include <QFuture>
 #include <obs.hpp>
 #include <vector>
 #include <memory>
@@ -42,6 +43,7 @@
 #include "auth-base.hpp"
 #include "log-viewer.hpp"
 #include "undo-stack-obs.hpp"
+#include "qt-helpers.hpp"
 
 #include <obs-frontend-internal.hpp>
 
@@ -284,6 +286,7 @@ private:
 
 	OBSService service;
 	std::unique_ptr<BasicOutputHandler> outputHandler;
+	FutureHolder<void> startStreamingFuture;
 	bool streamingStopping = false;
 	bool recordingStopping = false;
 	bool replayBufferStopping = false;

+ 6 - 0
UI/window-basic-settings.cpp

@@ -1068,6 +1068,12 @@ void OBSBasicSettings::SaveSpinBox(QSpinBox *widget, const char *section,
 		config_set_int(main->Config(), section, value, widget->value());
 }
 
+std::string DeserializeConfigText(const char *value)
+{
+	OBSDataAutoRelease data = obs_data_create_from_json(value);
+	return obs_data_get_string(data, "text");
+}
+
 void OBSBasicSettings::SaveGroupBox(QGroupBox *widget, const char *section,
 				    const char *value)
 {

+ 2 - 0
UI/window-basic-settings.hpp

@@ -70,6 +70,8 @@ public slots:
 	}
 };
 
+std::string DeserializeConfigText(const char *value);
+
 class OBSBasicSettings : public QDialog {
 	Q_OBJECT
 	Q_PROPERTY(QIcon generalIcon READ GetGeneralIcon WRITE SetGeneralIcon