Просмотр исходного кода

Merge pull request #10633 from palana/ruwen/upstream-multitrack-video-ui

UI: Add (eRTMP) multitrack video output
Ryan Foster 1 год назад
Родитель
Сommit
0cc357f6dc
41 измененных файлов с 4142 добавлено и 571 удалено
  1. 18 0
      UI/CMakeLists.txt
  2. 10 0
      UI/api-interface.cpp
  3. 23 0
      UI/cmake/legacy.cmake
  4. 2 0
      UI/cmake/os-freebsd.cmake
  5. 2 0
      UI/cmake/os-linux.cmake
  6. 2 0
      UI/cmake/os-macos.cmake
  7. 2 0
      UI/cmake/os-windows.cmake
  8. 44 0
      UI/data/locale/en-US.ini
  9. 5 0
      UI/data/themes/Yami.obt
  10. 113 84
      UI/forms/AutoConfigStreamPage.ui
  11. 596 316
      UI/forms/OBSBasicSettings.ui
  12. 89 0
      UI/goliveapi-censoredjson.cpp
  13. 12 0
      UI/goliveapi-censoredjson.hpp
  14. 146 0
      UI/goliveapi-network.cpp
  15. 16 0
      UI/goliveapi-network.hpp
  16. 47 0
      UI/goliveapi-postdata.cpp
  17. 12 0
      UI/goliveapi-postdata.hpp
  18. 323 0
      UI/models/multitrack-video.hpp
  19. 46 0
      UI/multitrack-video-error.cpp
  20. 22 0
      UI/multitrack-video-error.hpp
  21. 950 0
      UI/multitrack-video-output.cpp
  22. 79 0
      UI/multitrack-video-output.hpp
  23. 1 1
      UI/obs-app.cpp
  24. 7 0
      UI/properties-view.cpp
  25. 2 0
      UI/properties-view.hpp
  26. 10 0
      UI/qt-helpers.cpp
  27. 46 0
      UI/qt-helpers.hpp
  28. 6 0
      UI/system-info-macos.mm
  29. 6 0
      UI/system-info-posix.cpp
  30. 278 0
      UI/system-info-windows.cpp
  31. 5 0
      UI/system-info.hpp
  32. 68 6
      UI/window-basic-auto-config-test.cpp
  33. 172 1
      UI/window-basic-auto-config.cpp
  34. 14 0
      UI/window-basic-auto-config.hpp
  35. 405 101
      UI/window-basic-main-outputs.cpp
  36. 26 2
      UI/window-basic-main-outputs.hpp
  37. 98 52
      UI/window-basic-main.cpp
  38. 3 0
      UI/window-basic-main.hpp
  39. 153 7
      UI/window-basic-settings-stream.cpp
  40. 274 0
      UI/window-basic-settings.cpp
  41. 9 1
      UI/window-basic-settings.hpp

+ 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)
 

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

@@ -115,6 +115,7 @@ MixerToolbarMenu="Audio Mixer Menu"
 SceneFilters="Open Scene Filters"
 List="List"
 Grid="Grid"
+Automatic="Automatic"
 
 # warning for plugin load failures
 PluginsFailedToLoad.Title="Plugin Load Error"
@@ -224,6 +225,7 @@ Basic.AutoConfig.StreamPage.PreferHardwareEncoding="Prefer hardware encoding"
 Basic.AutoConfig.StreamPage.PreferHardwareEncoding.ToolTip="Hardware Encoding eliminates most CPU usage, but may require more bitrate to obtain the same level of quality."
 Basic.AutoConfig.StreamPage.StreamWarning.Title="Stream warning"
 Basic.AutoConfig.StreamPage.StreamWarning.Text="The bandwidth test is about to stream randomized video data without audio to your channel. If you're able, it's recommended to temporarily turn off saving videos of streams and set the stream to private until after the test has completed. Continue?"
+Basic.AutoConfig.StreamPage.UseMultitrackVideo="Test %1"
 Basic.AutoConfig.TestPage="Final Results"
 Basic.AutoConfig.TestPage.SubTitle.Testing="The program is now executing a set of tests to estimate the ideal settings"
 Basic.AutoConfig.TestPage.SubTitle.Complete="Testing complete"
@@ -242,6 +244,7 @@ Basic.AutoConfig.TestPage.Result.Header="The program has determined that these e
 Basic.AutoConfig.TestPage.Result.Footer="To use these settings, click Apply Settings. To reconfigure the wizard and try again, click Back. To manually configure settings yourself, click Cancel and open Settings."
 Basic.AutoConfig.Info="The auto-configuration wizard will determine the best settings based on your computer specs and internet speed."
 Basic.AutoConfig.RunAnytime="This can be run at any time by going to the Tools menu."
+Basic.AutoConfig.TestPage.Result.StreamingResolution="Streaming (Scaled) Resolution"
 
 # stats
 Basic.Stats="Stats"
@@ -721,6 +724,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"
@@ -860,6 +864,7 @@ Basic.MainMenu.Help.About="&About"
 Basic.Settings.ProgramRestart="The program must be restarted for these settings to take effect."
 Basic.Settings.ConfirmTitle="Confirm Changes"
 Basic.Settings.Confirm="You have unsaved changes. Save changes?"
+Basic.Settings.MultitrackVideoDisabledSettings="%1 %2 is controlling some of your stream settings"
 
 # basic mode 'general' settings
 Basic.Settings.General="General"
@@ -934,6 +939,7 @@ Basic.Settings.Appearance.General.NoVariant="No Styles Available"
 
 # basic mode 'stream' settings
 Basic.Settings.Stream="Stream"
+Basic.Settings.Stream.Destination="Destination"
 Basic.Settings.Stream.Custom.UseAuthentication="Use authentication"
 Basic.Settings.Stream.Custom.Username="Username"
 Basic.Settings.Stream.Custom.Password="Password"
@@ -957,6 +963,18 @@ Basic.Settings.Stream.Recommended.MaxVideoBitrate="Maximum Video Bitrate: %1 kbp
 Basic.Settings.Stream.Recommended.MaxAudioBitrate="Maximum Audio Bitrate: %1 kbps"
 Basic.Settings.Stream.Recommended.MaxResolution="Maximum Resolution: %1"
 Basic.Settings.Stream.Recommended.MaxFPS="Maximum FPS: %1"
+Basic.Settings.Stream.SpecifyCustomServer="Specify Custom Server..."
+Basic.Settings.Stream.ServiceCustomServer="Custom Server"
+Basic.Settings.Stream.EnableMultitrackVideo="Enable %1"
+Basic.Settings.Stream.MultitrackVideoMaximumAggregateBitrate="Maximum Streaming Bandwidth"
+Basic.Settings.Stream.MultitrackVideoMaximumAggregateBitrateAuto="Auto"
+Basic.Settings.Stream.MultitrackVideoMaximumVideoTracks="Maximum Video Tracks"
+Basic.Settings.Stream.MultitrackVideoMaximumVideoTracksAuto="Auto"
+Basic.Settings.Stream.MultitrackVideoStreamDumpEnable="Enable stream dump to FLV (uses simple recording file settings)"
+Basic.Settings.Stream.MultitrackVideoConfigOverride="Config Override (JSON)"
+Basic.Settings.Stream.MultitrackVideoConfigOverrideEnable="Enable Config Override"
+Basic.Settings.Stream.MultitrackVideoLabel="Multitrack Video"
+Basic.Settings.Stream.AdvancedOptions="Advanced Options"
 
 # basic mode 'output' settings
 Basic.Settings.Output="Output"
@@ -1541,3 +1559,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"

+ 5 - 0
UI/data/themes/Yami.obt

@@ -1376,6 +1376,11 @@ QLabel#errorLabel {
     font-weight: bold;
 }
 
+QFrame [themeID="notice"] {
+    background: var(--bg_preview);
+    border-radius: var(--border_radius);
+}
+
 /* About dialog */
 
 * [themeID="aboutName"] {

+ 113 - 84
UI/forms/AutoConfigStreamPage.ui

@@ -330,7 +330,73 @@
          </layout>
         </widget>
        </item>
-       <item row="2" column="0">
+       <item row="2" column="1">
+        <widget class="QPushButton" name="connectAccount2">
+         <property name="cursor">
+          <cursorShape>PointingHandCursor</cursorShape>
+         </property>
+         <property name="text">
+          <string>Basic.AutoConfig.StreamPage.ConnectAccount</string>
+         </property>
+        </widget>
+       </item>
+       <item row="3" column="0">
+        <widget class="QLabel" name="connectedAccountLabel">
+         <property name="text">
+          <string>Basic.AutoConfig.StreamPage.ConnectedAccount</string>
+         </property>
+        </widget>
+       </item>
+       <item row="3" column="1">
+        <layout class="QHBoxLayout" name="horizontalLayout_6">
+         <property name="leftMargin">
+          <number>7</number>
+         </property>
+         <property name="rightMargin">
+          <number>7</number>
+         </property>
+         <item>
+          <widget class="QLabel" name="connectedAccountText">
+           <property name="font">
+            <font>
+             <bold>true</bold>
+            </font>
+           </property>
+           <property name="text">
+            <string>Auth.LoadingChannel.Title</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QPushButton" name="disconnectAccount">
+           <property name="text">
+            <string>Basic.AutoConfig.StreamPage.DisconnectAccount</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item row="4" column="0">
+        <spacer name="horizontalSpacer">
+         <property name="orientation">
+          <enum>Qt::Horizontal</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>40</width>
+           <height>0</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="4" column="1">
+        <widget class="QPushButton" name="useStreamKeyAdv">
+         <property name="text">
+          <string>Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced</string>
+         </property>
+        </widget>
+       </item>
+       <item row="5" column="0">
         <widget class="QLabel" name="bitrateLabel">
          <property name="text">
           <string>Basic.Settings.Output.VideoBitrate</string>
@@ -340,7 +406,7 @@
          </property>
         </widget>
        </item>
-       <item row="2" column="1">
+       <item row="5" column="1">
         <widget class="QSpinBox" name="bitrate">
          <property name="suffix">
           <string notr="true"/>
@@ -356,7 +422,7 @@
          </property>
         </widget>
        </item>
-       <item row="3" column="1">
+       <item row="6" column="1">
         <widget class="QCheckBox" name="preferHardware">
          <property name="toolTip">
           <string>Basic.AutoConfig.StreamPage.PreferHardwareEncoding.ToolTip</string>
@@ -369,7 +435,7 @@
          </property>
         </widget>
        </item>
-       <item row="4" column="1">
+       <item row="7" column="1">
         <widget class="QCheckBox" name="doBandwidthTest">
          <property name="text">
           <string>Basic.AutoConfig.StreamPage.PerformBandwidthTest</string>
@@ -379,7 +445,49 @@
          </property>
         </widget>
        </item>
-       <item row="6" column="1">
+       <item row="8" column="1">
+        <widget class="QCheckBox" name="useMultitrackVideo">
+         <property name="text">
+          <string>Basic.AutoConfig.StreamPage.UseMultitrackVideo</string>
+         </property>
+         <property name="checked">
+          <bool>true</bool>
+         </property>
+        </widget>
+       </item>
+       <item row="9" column="1">
+        <widget class="QLabel" name="multitrackVideoInfo">
+         <property name="text">
+          <string>MultitrackVideo.Info</string>
+         </property>
+         <property name="textFormat">
+          <enum>Qt::RichText</enum>
+         </property>
+         <property name="wordWrap">
+          <bool>true</bool>
+         </property>
+         <property name="openExternalLinks">
+          <bool>true</bool>
+         </property>
+         <property name="sizePolicy">
+          <enum>QSizePolicy::MinimumExpanding, QSizePolicy::Minimum</enum>
+         </property>
+        </widget>
+       </item>
+       <item row="10" column="1">
+        <spacer name="verticalSpacer_2">
+         <property name="orientation">
+          <enum>Qt::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>6</width>
+           <height>6</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+       <item row="11" column="1">
         <widget class="QGroupBox" name="region">
          <property name="title">
           <string>BandwidthTest.Region</string>
@@ -416,85 +524,6 @@
          </layout>
         </widget>
        </item>
-       <item row="7" column="1">
-        <widget class="QPushButton" name="connectAccount2">
-         <property name="cursor">
-          <cursorShape>PointingHandCursor</cursorShape>
-         </property>
-         <property name="text">
-          <string>Basic.AutoConfig.StreamPage.ConnectAccount</string>
-         </property>
-        </widget>
-       </item>
-       <item row="5" column="1">
-        <spacer name="verticalSpacer_2">
-         <property name="orientation">
-          <enum>Qt::Vertical</enum>
-         </property>
-         <property name="sizeHint" stdset="0">
-          <size>
-           <width>6</width>
-           <height>6</height>
-          </size>
-         </property>
-        </spacer>
-       </item>
-       <item row="8" column="1">
-        <layout class="QHBoxLayout" name="horizontalLayout_6">
-         <property name="leftMargin">
-          <number>7</number>
-         </property>
-         <property name="rightMargin">
-          <number>7</number>
-         </property>
-         <item>
-          <widget class="QLabel" name="connectedAccountText">
-           <property name="font">
-            <font>
-             <bold>true</bold>
-            </font>
-           </property>
-           <property name="text">
-            <string>Auth.LoadingChannel.Title</string>
-           </property>
-          </widget>
-         </item>
-         <item>
-          <widget class="QPushButton" name="disconnectAccount">
-           <property name="text">
-            <string>Basic.AutoConfig.StreamPage.DisconnectAccount</string>
-           </property>
-          </widget>
-         </item>
-        </layout>
-       </item>
-       <item row="8" column="0">
-        <widget class="QLabel" name="connectedAccountLabel">
-         <property name="text">
-          <string>Basic.AutoConfig.StreamPage.ConnectedAccount</string>
-         </property>
-        </widget>
-       </item>
-       <item row="9" column="0">
-        <spacer name="horizontalSpacer">
-         <property name="orientation">
-          <enum>Qt::Horizontal</enum>
-         </property>
-         <property name="sizeHint" stdset="0">
-          <size>
-           <width>40</width>
-           <height>20</height>
-          </size>
-         </property>
-        </spacer>
-       </item>
-       <item row="9" column="1">
-        <widget class="QPushButton" name="useStreamKeyAdv">
-         <property name="text">
-          <string>Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced</string>
-         </property>
-        </widget>
-       </item>
       </layout>
      </widget>
     </widget>

+ 596 - 316
UI/forms/OBSBasicSettings.ui

@@ -1204,353 +1204,614 @@
              </item>
             </layout>
            </widget>
-           <widget class="QWidget" name="streamKeyPage">
-            <layout class="QFormLayout" name="streamkeyPageLayout">
-             <property name="fieldGrowthPolicy">
-              <enum>QFormLayout::AllNonFixedFieldsGrow</enum>
-             </property>
-             <property name="labelAlignment">
-              <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+           <widget class="QScrollArea" name="streamKeyScrollArea">
+            <property name="frameShape">
+             <enum>QFrame::NoFrame</enum>
+            </property>
+            <property name="frameShadow">
+             <enum>QFrame::Plain</enum>
+            </property>
+            <property name="widgetResizable">
+             <bool>true</bool>
+            </property>
+            <widget class="QWidget" name="streamKeyPage">
+             <property name="sizePolicy">
+              <sizepolicy hsizetype="Preferred" vsizetype="Maximum">
+               <horstretch>0</horstretch>
+               <verstretch>0</verstretch>
+              </sizepolicy>
              </property>
-             <item row="0" column="0">
-              <widget class="QLabel" name="serverLabel">
-               <property name="text">
-                <string>Basic.AutoConfig.StreamPage.Server</string>
-               </property>
-              </widget>
-             </item>
-             <item row="0" column="1">
-              <widget class="QStackedWidget" name="serverStackedWidget">
-               <property name="sizePolicy">
-                <sizepolicy hsizetype="Preferred" vsizetype="Maximum">
-                 <horstretch>0</horstretch>
-                 <verstretch>0</verstretch>
-                </sizepolicy>
-               </property>
-               <property name="currentIndex">
-                <number>1</number>
-               </property>
-               <widget class="QWidget" name="servicePage">
-                <layout class="QHBoxLayout" name="horizontalLayout_21">
+             <layout class="QVBoxLayout" name="streamkeyPageLayout">
+              <item>
+               <widget class="QGroupBox" name="destinationGroupBox">
+                <property name="sizePolicy">
+                 <sizepolicy hsizetype="Preferred" vsizetype="Maximum">
+                  <horstretch>0</horstretch>
+                  <verstretch>0</verstretch>
+                 </sizepolicy>
+                </property>
+                <property name="title">
+                 <string>Basic.Settings.Stream.Destination</string>
+                </property>
+                <layout class="QFormLayout" name="destinationLayout">
+                 <property name="fieldGrowthPolicy">
+                  <enum>QFormLayout::AllNonFixedFieldsGrow</enum>
+                 </property>
+                 <property name="labelAlignment">
+                  <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+                 </property>
                  <property name="leftMargin">
-                  <number>0</number>
+                  <number>9</number>
                  </property>
                  <property name="topMargin">
-                  <number>0</number>
+                  <number>2</number>
                  </property>
                  <property name="rightMargin">
-                  <number>0</number>
+                  <number>9</number>
                  </property>
                  <property name="bottomMargin">
-                  <number>0</number>
+                  <number>9</number>
                  </property>
-                 <item>
-                  <widget class="QComboBox" name="server"/>
+                 <item row="0" column="0">
+                  <widget class="QLabel" name="serverLabel">
+                   <property name="text">
+                    <string>Basic.AutoConfig.StreamPage.Server</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="0" column="1">
+                  <widget class="QStackedWidget" name="serverStackedWidget">
+                   <property name="sizePolicy">
+                    <sizepolicy hsizetype="Preferred" vsizetype="Maximum">
+                     <horstretch>0</horstretch>
+                     <verstretch>0</verstretch>
+                    </sizepolicy>
+                   </property>
+                   <property name="currentIndex">
+                    <number>1</number>
+                   </property>
+                   <widget class="QWidget" name="servicePage">
+                    <layout class="QHBoxLayout" name="horizontalLayout_21">
+                     <property name="leftMargin">
+                      <number>0</number>
+                     </property>
+                     <property name="topMargin">
+                      <number>0</number>
+                     </property>
+                     <property name="rightMargin">
+                      <number>0</number>
+                     </property>
+                     <property name="bottomMargin">
+                      <number>0</number>
+                     </property>
+                     <item>
+                      <widget class="QComboBox" name="server"/>
+                     </item>
+                    </layout>
+                   </widget>
+                   <widget class="QWidget" name="customPage">
+                    <layout class="QHBoxLayout" name="horizontalLayout_22">
+                     <property name="leftMargin">
+                      <number>0</number>
+                     </property>
+                     <property name="topMargin">
+                      <number>0</number>
+                     </property>
+                     <property name="rightMargin">
+                      <number>0</number>
+                     </property>
+                     <property name="bottomMargin">
+                      <number>0</number>
+                     </property>
+                     <item>
+                      <widget class="QLineEdit" name="customServer"/>
+                     </item>
+                    </layout>
+                   </widget>
+                  </widget>
+                 </item>
+                 <item row="1" column="0">
+                  <widget class="QLabel" name="serviceCustomServerLabel">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.ServiceCustomServer</string>
+                   </property>
+                   <property name="buddy">
+                    <cstring>serviceCustomServer</cstring>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="1" column="1">
+                  <widget class="QLineEdit" name="serviceCustomServer"/>
+                 </item>
+                 <item row="2" column="0">
+                  <widget class="QLabel" name="streamKeyLabel">
+                   <property name="text">
+                    <string>Basic.AutoConfig.StreamPage.StreamKey</string>
+                   </property>
+                   <property name="openExternalLinks">
+                    <bool>true</bool>
+                   </property>
+                   <property name="buddy">
+                    <cstring>key</cstring>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="2" column="1">
+                  <widget class="QFrame" name="streamKeyWidget">
+                   <layout class="QHBoxLayout" name="horizontalLayout_11">
+                    <property name="leftMargin">
+                     <number>0</number>
+                    </property>
+                    <property name="topMargin">
+                     <number>0</number>
+                    </property>
+                    <property name="rightMargin">
+                     <number>0</number>
+                    </property>
+                    <property name="bottomMargin">
+                     <number>0</number>
+                    </property>
+                    <item>
+                     <widget class="QLineEdit" name="key">
+                      <property name="inputMask">
+                       <string notr="true"/>
+                      </property>
+                      <property name="text">
+                       <string notr="true"/>
+                      </property>
+                      <property name="echoMode">
+                       <enum>QLineEdit::Password</enum>
+                      </property>
+                     </widget>
+                    </item>
+                    <item>
+                     <widget class="QPushButton" name="show">
+                      <property name="text">
+                       <string>Show</string>
+                      </property>
+                     </widget>
+                    </item>
+                    <item>
+                     <widget class="UrlPushButton" name="getStreamKeyButton">
+                      <property name="toolTip">
+                       <string/>
+                      </property>
+                      <property name="toolTipDuration">
+                       <number>-4</number>
+                      </property>
+                      <property name="text">
+                       <string>Basic.AutoConfig.StreamPage.GetStreamKey</string>
+                      </property>
+                     </widget>
+                    </item>
+                   </layout>
+                  </widget>
+                 </item>
+                 <item row="3" column="0">
+                  <spacer name="horizontalSpacer_18">
+                   <property name="orientation">
+                    <enum>Qt::Horizontal</enum>
+                   </property>
+                   <property name="sizeHint" stdset="0">
+                    <size>
+                     <width>170</width>
+                     <height>0</height>
+                    </size>
+                   </property>
+                  </spacer>
+                 </item>
+                 <item row="3" column="1">
+                  <layout class="QHBoxLayout" name="horizontalLayout_15">
+                   <item>
+                    <widget class="QPushButton" name="connectAccount2">
+                     <property name="cursor">
+                      <cursorShape>PointingHandCursor</cursorShape>
+                     </property>
+                     <property name="text">
+                      <string>Basic.AutoConfig.StreamPage.ConnectAccount</string>
+                     </property>
+                    </widget>
+                   </item>
+                   <item>
+                    <spacer name="horizontalSpacer_19">
+                     <property name="orientation">
+                      <enum>Qt::Horizontal</enum>
+                     </property>
+                     <property name="sizeHint" stdset="0">
+                      <size>
+                       <width>40</width>
+                       <height>0</height>
+                      </size>
+                     </property>
+                    </spacer>
+                   </item>
+                  </layout>
+                 </item>
+                 <item row="4" column="0">
+                  <widget class="QLabel" name="connectedAccountLabel">
+                   <property name="text">
+                    <string>Basic.AutoConfig.StreamPage.ConnectedAccount</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="4" column="1">
+                  <layout class="QHBoxLayout" name="horizontalLayout_23">
+                   <property name="spacing">
+                    <number>8</number>
+                   </property>
+                   <property name="leftMargin">
+                    <number>7</number>
+                   </property>
+                   <property name="rightMargin">
+                    <number>7</number>
+                   </property>
+                   <item>
+                    <widget class="QLabel" name="connectedAccountText">
+                     <property name="styleSheet">
+                      <string notr="true">font-weight: bold</string>
+                     </property>
+                     <property name="text">
+                      <string>Auth.LoadingChannel.Title</string>
+                     </property>
+                    </widget>
+                   </item>
+                   <item>
+                    <widget class="QPushButton" name="disconnectAccount">
+                     <property name="text">
+                      <string>Basic.AutoConfig.StreamPage.DisconnectAccount</string>
+                     </property>
+                    </widget>
+                   </item>
+                   <item>
+                    <spacer name="horizontalSpacer_24">
+                     <property name="orientation">
+                      <enum>Qt::Horizontal</enum>
+                     </property>
+                     <property name="sizeHint" stdset="0">
+                      <size>
+                       <width>40</width>
+                       <height>20</height>
+                      </size>
+                     </property>
+                    </spacer>
+                   </item>
+                  </layout>
+                 </item>
+                 <item row="5" column="1">
+                  <layout class="QHBoxLayout" name="horizontalLayout_28">
+                   <item>
+                    <widget class="QPushButton" name="useStreamKeyAdv">
+                     <property name="text">
+                      <string>Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced</string>
+                     </property>
+                    </widget>
+                   </item>
+                   <item>
+                    <spacer name="horizontalSpacer_28">
+                     <property name="orientation">
+                      <enum>Qt::Horizontal</enum>
+                     </property>
+                     <property name="sizeHint" stdset="0">
+                      <size>
+                       <width>40</width>
+                       <height>0</height>
+                      </size>
+                     </property>
+                    </spacer>
+                   </item>
+                  </layout>
+                 </item>
+                 <item row="6" column="1">
+                  <widget class="QCheckBox" name="useAuth">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.Custom.UseAuthentication</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="7" column="0">
+                  <widget class="QLabel" name="authUsernameLabel">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.Custom.Username</string>
+                   </property>
+                   <property name="buddy">
+                    <cstring>authUsername</cstring>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="7" column="1">
+                  <widget class="QLineEdit" name="authUsername"/>
+                 </item>
+                 <item row="8" column="0">
+                  <widget class="QLabel" name="authPwLabel">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.Custom.Password</string>
+                   </property>
+                   <property name="buddy">
+                    <cstring>authPw</cstring>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="8" column="1">
+                  <widget class="QFrame" name="authPwWidget">
+                   <layout class="QHBoxLayout" name="horizontalLayout_25">
+                    <property name="leftMargin">
+                     <number>0</number>
+                    </property>
+                    <property name="topMargin">
+                     <number>0</number>
+                    </property>
+                    <property name="rightMargin">
+                     <number>0</number>
+                    </property>
+                    <property name="bottomMargin">
+                     <number>0</number>
+                    </property>
+                    <item>
+                     <widget class="QLineEdit" name="authPw">
+                      <property name="echoMode">
+                       <enum>QLineEdit::Password</enum>
+                      </property>
+                     </widget>
+                    </item>
+                    <item>
+                     <widget class="QPushButton" name="authPwShow">
+                      <property name="text">
+                       <string>Show</string>
+                      </property>
+                     </widget>
+                    </item>
+                   </layout>
+                  </widget>
                  </item>
                 </layout>
                </widget>
-               <widget class="QWidget" name="customPage">
-                <layout class="QHBoxLayout" name="horizontalLayout_22">
+              </item>
+              <item>
+               <widget class="QGroupBox" name="multitrackVideoGroupBox">
+                <property name="sizePolicy">
+                 <sizepolicy hsizetype="Preferred" vsizetype="Maximum">
+                  <horstretch>0</horstretch>
+                  <verstretch>0</verstretch>
+                 </sizepolicy>
+                </property>
+                <property name="title">
+                 <string>Basic.Settings.Stream.MultitrackVideoLabel</string>
+                </property>
+                <layout class="QFormLayout" name="multitrackVideoLayout">
+                 <property name="fieldGrowthPolicy">
+                  <enum>QFormLayout::AllNonFixedFieldsGrow</enum>
+                 </property>
+                 <property name="labelAlignment">
+                  <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+                 </property>
                  <property name="leftMargin">
-                  <number>0</number>
+                  <number>9</number>
                  </property>
                  <property name="topMargin">
-                  <number>0</number>
+                  <number>2</number>
                  </property>
                  <property name="rightMargin">
-                  <number>0</number>
+                  <number>9</number>
                  </property>
                  <property name="bottomMargin">
-                  <number>0</number>
+                  <number>9</number>
                  </property>
-                 <item>
-                  <widget class="QLineEdit" name="customServer"/>
+                 <item row="0" column="1">
+                  <widget class="QCheckBox" name="enableMultitrackVideo">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.EnableMultitrackVideo</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="1" column="1">
+                  <widget class="QLabel" name="multitrackVideoInfo">
+                   <property name="text">
+                    <string>MultitrackVideo.Info</string>
+                   </property>
+                   <property name="textFormat">
+                    <enum>Qt::RichText</enum>
+                   </property>
+                   <property name="wordWrap">
+                    <bool>true</bool>
+                   </property>
+                   <property name="openExternalLinks">
+                    <bool>true</bool>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="2" column="0">
+                  <widget class="QLabel" name="multitrackVideoMaximumAggregateBitrateLabel">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.MultitrackVideoMaximumAggregateBitrate</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="2" column="1">
+                  <layout class="QHBoxLayout">
+                   <item>
+                    <widget class="QCheckBox" name="multitrackVideoMaximumAggregateBitrateAuto">
+                     <property name="text">
+                      <string>Basic.Settings.Stream.MultitrackVideoMaximumAggregateBitrateAuto</string>
+                     </property>
+                     <property name="sizePolicy">
+                      <enum>QSizePolicy::Minimum, QSizePolicy::Minimum</enum>
+                     </property>
+                    </widget>
+                   </item>
+                   <item>
+                    <widget class="QSpinBox" name="multitrackVideoMaximumAggregateBitrate">
+                     <property name="sizePolicy">
+                      <enum>QSizePolicy::MinimumExpanding, QSizePolicy::Minimum</enum>
+                     </property>
+                     <property name="minimum">
+                      <number>500</number>
+                     </property>
+                     <property name="maximum">
+                      <number>1000000</number>
+                     </property>
+                     <property name="value">
+                      <number>8000</number>
+                     </property>
+                    </widget>
+                   </item>
+                  </layout>
+                 </item>
+                 <item row="3" column="0">
+                  <widget class="QLabel" name="multitrackVideoMaximumVideoTracksLabel">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.MultitrackVideoMaximumVideoTracks</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="3" column="1">
+                  <layout class="QHBoxLayout">
+                   <item>
+                    <widget class="QCheckBox" name="multitrackVideoMaximumVideoTracksAuto">
+                     <property name="text">
+                      <string>Basic.Settings.Stream.MultitrackVideoMaximumVideoTracksAuto</string>
+                     </property>
+                     <property name="sizePolicy">
+                      <enum>QSizePolicy::Minimum, QSizePolicy::Minimum</enum>
+                     </property>
+                    </widget>
+                   </item>
+                   <item>
+                    <widget class="QSpinBox" name="multitrackVideoMaximumVideoTracks">
+                     <property name="sizePolicy">
+                      <enum>QSizePolicy::MinimumExpanding, QSizePolicy::Minimum</enum>
+                     </property>
+                     <property name="minimum">
+                      <number>0</number>
+                     </property>
+                     <property name="maximum">
+                      <number>100</number>
+                     </property>
+                     <property name="value">
+                      <number>0</number>
+                     </property>
+                    </widget>
+                   </item>
+                  </layout>
+                 </item>
+                 <item row="4" column="1">
+                  <widget class="QCheckBox" name="multitrackVideoStreamDumpEnable">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.MultitrackVideoStreamDumpEnable</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="5" column="1">
+                  <widget class="QCheckBox" name="multitrackVideoConfigOverrideEnable">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.MultitrackVideoConfigOverrideEnable</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="6" column="0">
+                  <widget class="QLabel" name="multitrackVideoConfigOverrideLabel">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.MultitrackVideoConfigOverride</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="7" column="1">
+                  <widget class="QPlainTextEdit" name="multitrackVideoConfigOverride">
+                   <property name="sizePolicy">
+                    <enum>QSizePolicy::Preferred, QSizePolicy::MinimumExpanding</enum>
+                   </property>
+                  </widget>
                  </item>
                 </layout>
                </widget>
-              </widget>
-             </item>
-             <item row="1" column="0">
-              <widget class="QLabel" name="streamKeyLabel">
-               <property name="text">
-                <string>Basic.AutoConfig.StreamPage.StreamKey</string>
-               </property>
-               <property name="openExternalLinks">
-                <bool>true</bool>
-               </property>
-               <property name="buddy">
-                <cstring>key</cstring>
-               </property>
-              </widget>
-             </item>
-             <item row="1" column="1">
-              <widget class="QFrame" name="streamKeyWidget">
-               <layout class="QHBoxLayout" name="horizontalLayout_11">
-                <property name="leftMargin">
-                 <number>0</number>
-                </property>
-                <property name="topMargin">
-                 <number>0</number>
-                </property>
-                <property name="rightMargin">
-                 <number>0</number>
-                </property>
-                <property name="bottomMargin">
-                 <number>0</number>
-                </property>
-                <item>
-                 <widget class="QLineEdit" name="key">
-                  <property name="inputMask">
-                   <string notr="true"/>
-                  </property>
-                  <property name="text">
-                   <string notr="true"/>
-                  </property>
-                  <property name="echoMode">
-                   <enum>QLineEdit::Password</enum>
-                  </property>
-                 </widget>
-                </item>
-                <item>
-                 <widget class="QPushButton" name="show">
-                  <property name="text">
-                   <string>Show</string>
-                  </property>
-                 </widget>
-                </item>
-                <item>
-                 <widget class="UrlPushButton" name="getStreamKeyButton">
-                  <property name="toolTip">
-                   <string/>
-                  </property>
-                  <property name="toolTipDuration">
-                   <number>-4</number>
-                  </property>
-                  <property name="text">
-                   <string>Basic.AutoConfig.StreamPage.GetStreamKey</string>
-                  </property>
-                 </widget>
-                </item>
-               </layout>
-              </widget>
-             </item>
-             <item row="3" column="0">
-              <spacer name="horizontalSpacer_18">
-               <property name="orientation">
-                <enum>Qt::Horizontal</enum>
-               </property>
-               <property name="sizeHint" stdset="0">
-                <size>
-                 <width>170</width>
-                 <height>8</height>
-                </size>
-               </property>
-              </spacer>
-             </item>
-             <item row="4" column="1">
-              <layout class="QHBoxLayout" name="horizontalLayout_23">
-               <property name="spacing">
-                <number>8</number>
-               </property>
-               <property name="leftMargin">
-                <number>7</number>
-               </property>
-               <property name="rightMargin">
-                <number>7</number>
-               </property>
-               <item>
-                <widget class="QLabel" name="connectedAccountText">
-                 <property name="styleSheet">
-                  <string notr="true">font-weight: bold</string>
-                 </property>
-                 <property name="text">
-                  <string>Auth.LoadingChannel.Title</string>
-                 </property>
-                </widget>
-               </item>
-               <item>
-                <widget class="QPushButton" name="disconnectAccount">
-                 <property name="text">
-                  <string>Basic.AutoConfig.StreamPage.DisconnectAccount</string>
-                 </property>
-                </widget>
-               </item>
-               <item>
-                <spacer name="horizontalSpacer_24">
-                 <property name="orientation">
-                  <enum>Qt::Horizontal</enum>
-                 </property>
-                 <property name="sizeHint" stdset="0">
-                  <size>
-                   <width>40</width>
-                   <height>20</height>
-                  </size>
-                 </property>
-                </spacer>
-               </item>
-              </layout>
-             </item>
-             <item row="6" column="1">
-              <widget class="QCheckBox" name="bandwidthTestEnable">
-               <property name="text">
-                <string>Basic.Settings.Stream.BandwidthTestMode</string>
-               </property>
-              </widget>
-             </item>
-             <item row="7" column="1">
-              <widget class="QCheckBox" name="useAuth">
-               <property name="text">
-                <string>Basic.Settings.Stream.Custom.UseAuthentication</string>
-               </property>
-              </widget>
-             </item>
-             <item row="9" column="0">
-              <widget class="QLabel" name="authUsernameLabel">
-               <property name="text">
-                <string>Basic.Settings.Stream.Custom.Username</string>
-               </property>
-               <property name="buddy">
-                <cstring>authUsername</cstring>
-               </property>
-              </widget>
-             </item>
-             <item row="9" column="1">
-              <widget class="QLineEdit" name="authUsername"/>
-             </item>
-             <item row="10" column="0">
-              <widget class="QLabel" name="authPwLabel">
-               <property name="text">
-                <string>Basic.Settings.Stream.Custom.Password</string>
-               </property>
-               <property name="buddy">
-                <cstring>authPw</cstring>
-               </property>
-              </widget>
-             </item>
-             <item row="10" column="1">
-              <widget class="QFrame" name="authPwWidget">
-               <layout class="QHBoxLayout" name="horizontalLayout_25">
-                <property name="leftMargin">
-                 <number>0</number>
-                </property>
-                <property name="topMargin">
-                 <number>0</number>
+              </item>
+              <item>
+               <widget class="QGroupBox" name="serviceAdvancedOptionsGroupBox">
+                <property name="sizePolicy">
+                 <sizepolicy hsizetype="Preferred" vsizetype="Maximum">
+                  <horstretch>0</horstretch>
+                  <verstretch>0</verstretch>
+                 </sizepolicy>
                 </property>
-                <property name="rightMargin">
-                 <number>0</number>
-                </property>
-                <property name="bottomMargin">
-                 <number>0</number>
+                <property name="title">
+                 <string>Basic.Settings.Stream.AdvancedOptions</string>
                 </property>
-                <item>
-                 <widget class="QLineEdit" name="authPw">
-                  <property name="echoMode">
-                   <enum>QLineEdit::Password</enum>
-                  </property>
-                 </widget>
-                </item>
-                <item>
-                 <widget class="QPushButton" name="authPwShow">
-                  <property name="text">
-                   <string>Show</string>
-                  </property>
-                 </widget>
-                </item>
-               </layout>
-              </widget>
-             </item>
-             <item row="8" column="1">
-              <widget class="QComboBox" name="twitchAddonDropdown"/>
-             </item>
-             <item row="8" column="0">
-              <widget class="QLabel" name="twitchAddonLabel">
-               <property name="text">
-                <string>Basic.Settings.Stream.TTVAddon</string>
-               </property>
-               <property name="buddy">
-                <cstring>twitchAddonDropdown</cstring>
-               </property>
-              </widget>
-             </item>
-             <item row="11" column="1">
-              <widget class="QCheckBox" name="ignoreRecommended">
-               <property name="text">
-                <string>Basic.Settings.Stream.IgnoreRecommended</string>
-               </property>
-              </widget>
-             </item>
-             <item row="12" column="1">
-              <widget class="QLabel" name="enforceSettingsLabel">
-               <property name="text">
-                <string notr="true"/>
-               </property>
-               <property name="textFormat">
-                <enum>Qt::RichText</enum>
-               </property>
-               <property name="openExternalLinks">
-                <bool>true</bool>
-               </property>
-              </widget>
-             </item>
-             <item row="5" column="1">
-              <layout class="QHBoxLayout" name="horizontalLayout_28">
-               <item>
-                <widget class="QPushButton" name="useStreamKeyAdv">
-                 <property name="text">
-                  <string>Basic.AutoConfig.StreamPage.UseStreamKeyAdvanced</string>
-                 </property>
-                </widget>
-               </item>
-               <item>
-                <spacer name="horizontalSpacer_28">
-                 <property name="orientation">
-                  <enum>Qt::Horizontal</enum>
+                <layout class="QFormLayout" name="serviceAdvancedOptionsLayout">
+                 <property name="fieldGrowthPolicy">
+                  <enum>QFormLayout::AllNonFixedFieldsGrow</enum>
                  </property>
-                 <property name="sizeHint" stdset="0">
-                  <size>
-                   <width>40</width>
-                   <height>20</height>
-                  </size>
+                 <property name="labelAlignment">
+                  <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
                  </property>
-                </spacer>
-               </item>
-              </layout>
-             </item>
-             <item row="4" column="0">
-              <widget class="QLabel" name="connectedAccountLabel">
-               <property name="text">
-                <string>Basic.AutoConfig.StreamPage.ConnectedAccount</string>
-               </property>
-              </widget>
-             </item>
-             <item row="3" column="1">
-              <layout class="QHBoxLayout" name="horizontalLayout_15">
-               <item>
-                <widget class="QPushButton" name="connectAccount2">
-                 <property name="cursor">
-                  <cursorShape>PointingHandCursor</cursorShape>
+                 <property name="leftMargin">
+                  <number>9</number>
                  </property>
-                 <property name="text">
-                  <string>Basic.AutoConfig.StreamPage.ConnectAccount</string>
+                 <property name="topMargin">
+                  <number>2</number>
                  </property>
-                </widget>
-               </item>
-               <item>
-                <spacer name="horizontalSpacer_19">
-                 <property name="orientation">
-                  <enum>Qt::Horizontal</enum>
+                 <property name="rightMargin">
+                  <number>9</number>
                  </property>
-                 <property name="sizeHint" stdset="0">
-                  <size>
-                   <width>40</width>
-                   <height>20</height>
-                  </size>
+                 <property name="bottomMargin">
+                  <number>9</number>
                  </property>
-                </spacer>
-               </item>
-              </layout>
-             </item>
-            </layout>
+                 <item row="0" column="0">
+                  <widget class="QLabel" name="twitchAddonLabel">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.TTVAddon</string>
+                   </property>
+                   <property name="buddy">
+                    <cstring>twitchAddonDropdown</cstring>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="0" column="1">
+                  <widget class="QComboBox" name="twitchAddonDropdown"/>
+                 </item>
+                 <item row="1" column="0">
+                  <spacer name="horizontalSpacer_18_1">
+                   <property name="orientation">
+                    <enum>Qt::Horizontal</enum>
+                   </property>
+                   <property name="sizeHint" stdset="0">
+                    <size>
+                     <width>170</width>
+                     <height>0</height>
+                    </size>
+                   </property>
+                  </spacer>
+                 </item>
+                 <item row="1" column="1">
+                  <widget class="QCheckBox" name="bandwidthTestEnable">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.BandwidthTestMode</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="2" column="1">
+                  <widget class="QCheckBox" name="ignoreRecommended">
+                   <property name="text">
+                    <string>Basic.Settings.Stream.IgnoreRecommended</string>
+                   </property>
+                  </widget>
+                 </item>
+                 <item row="3" column="1">
+                  <widget class="QLabel" name="enforceSettingsLabel">
+                   <property name="text">
+                    <string notr="true"/>
+                   </property>
+                   <property name="textFormat">
+                    <enum>Qt::RichText</enum>
+                   </property>
+                   <property name="openExternalLinks">
+                    <bool>true</bool>
+                   </property>
+                  </widget>
+                 </item>
+                </layout>
+               </widget>
+              </item>
+             </layout>
+            </widget>
            </widget>
           </widget>
          </item>
@@ -1585,6 +1846,25 @@
             <property name="bottomMargin">
              <number>0</number>
             </property>
+            <item>
+             <widget class="QFrame" name="multitrackVideoNoticeBox">
+              <property name="themeID" stdset="0">
+               <string notr="true">notice</string>
+              </property>
+              <layout class="QVBoxLayout" name="multitrackVideoNoticeBoxLayout">
+               <item>
+                <widget class="QLabel" name="multitrackVideoNotice">
+                 <property name="text">
+                  <string>Basic.Settings.MultitrackVideoDisabledSettings</string>
+                 </property>
+                 <property name="alignment">
+                  <set>Qt::AlignCenter</set>
+                 </property>
+                </widget>
+               </item>
+              </layout>
+             </widget>
+            </item>
             <item>
              <widget class="QFrame" name="widget">
               <property name="sizePolicy">

+ 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);
+};

+ 1 - 1
UI/obs-app.cpp

@@ -386,7 +386,7 @@ static inline bool too_many_repeated_entries(fstream &logFile, const char *msg,
 static void do_log(int log_level, const char *msg, va_list args, void *param)
 {
 	fstream &logFile = *static_cast<fstream *>(param);
-	char str[4096];
+	char str[8192];
 
 #ifndef _WIN32
 	va_list args2;

+ 7 - 0
UI/properties-view.cpp

@@ -245,6 +245,13 @@ OBSPropertiesView::OBSPropertiesView(OBSData settings_, const char *type_,
 				  Qt::QueuedConnection);
 }
 
+void OBSPropertiesView::SetDisabled(bool disabled)
+{
+	for (auto child : findChildren<QWidget *>()) {
+		child->setDisabled(disabled);
+	}
+}
+
 void OBSPropertiesView::resizeEvent(QResizeEvent *event)
 {
 	emit PropertiesResized();

+ 2 - 0
UI/properties-view.hpp

@@ -208,6 +208,8 @@ public:
 		RefreshProperties();
 	}
 
+	void SetDisabled(bool disabled);
+
 #define Def_IsObject(type)                                \
 	inline bool IsObject(obs_##type##_t *type) const  \
 	{                                                 \

+ 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);

+ 68 - 6
UI/window-basic-auto-config-test.cpp

@@ -280,6 +280,34 @@ void AutoConfigTestPage::TestBandwidthThread()
 		servers.resize(2);
 	}
 
+	if (!wiz->serviceConfigServers.empty()) {
+		if (wiz->service == AutoConfig::Service::Twitch &&
+		    wiz->twitchAuto) {
+			// servers from Twitch service config replace the "auto" entry
+			servers.erase(servers.begin());
+		}
+
+		for (auto it = std::rbegin(wiz->serviceConfigServers);
+		     it != std::rend(wiz->serviceConfigServers); it++) {
+			auto same_server = std::find_if(
+				std::begin(servers), std::end(servers),
+				[&](const ServerInfo &si) {
+					return si.address == it->address;
+				});
+			if (same_server != std::end(servers))
+				servers.erase(same_server);
+			servers.emplace(std::begin(servers), it->name.c_str(),
+					it->address.c_str());
+		}
+
+		if (wiz->service == AutoConfig::Service::Twitch &&
+		    wiz->twitchAuto) {
+			// see above, only test 3 servers
+			// rtmps urls are currently counted as separate servers
+			servers.resize(3);
+		}
+	}
+
 	/* -----------------------------------*/
 	/* apply service settings             */
 
@@ -287,6 +315,11 @@ void AutoConfigTestPage::TestBandwidthThread()
 	obs_service_apply_encoder_settings(service, vencoder_settings,
 					   aencoder_settings);
 
+	if (wiz->multitrackVideo.testSuccessful) {
+		obs_data_set_int(vencoder_settings, "bitrate",
+				 wiz->startingBitrate);
+	}
+
 	/* -----------------------------------*/
 	/* create output                      */
 
@@ -823,6 +856,10 @@ bool AutoConfigTestPage::TestSoftwareEncoding()
 		upperBitrate /= 100;
 	}
 
+	if (wiz->testMultitrackVideo && wiz->multitrackVideo.testSuccessful &&
+	    !wiz->multitrackVideo.bitrate.has_value())
+		wiz->multitrackVideo.bitrate = wiz->idealBitrate;
+
 	if (wiz->idealBitrate > upperBitrate)
 		wiz->idealBitrate = upperBitrate;
 
@@ -1080,6 +1117,11 @@ void AutoConfigTestPage::FinalizeResults()
 		OBSDataAutoRelease service_settings = obs_data_create();
 		OBSDataAutoRelease vencoder_settings = obs_data_create();
 
+		if (wiz->testMultitrackVideo &&
+		    wiz->multitrackVideo.testSuccessful &&
+		    !wiz->multitrackVideo.bitrate.has_value())
+			wiz->multitrackVideo.bitrate = wiz->idealBitrate;
+
 		obs_data_set_int(vencoder_settings, "bitrate",
 				 wiz->idealBitrate);
 
@@ -1121,12 +1163,30 @@ void AutoConfigTestPage::FinalizeResults()
 		form->addRow(newLabel("Basic.AutoConfig.StreamPage.Server"),
 			     new QLabel(wiz->serverName.c_str(),
 					ui->finishPage));
-		form->addRow(newLabel("Basic.Settings.Output.VideoBitrate"),
-			     new QLabel(QString::number(wiz->idealBitrate),
-					ui->finishPage));
-		form->addRow(newLabel(TEST_RESULT_SE),
-			     new QLabel(encName(wiz->streamingEncoder),
-					ui->finishPage));
+		form->addRow(
+			newLabel("Basic.Settings.Stream.MultitrackVideoLabel"),
+			newLabel(wiz->multitrackVideo.testSuccessful ? "Yes"
+								     : "No"));
+
+		if (wiz->multitrackVideo.testSuccessful) {
+			form->addRow(
+				newLabel("Basic.Settings.Output.VideoBitrate"),
+				newLabel("Automatic"));
+			form->addRow(newLabel(TEST_RESULT_SE),
+				     newLabel("Automatic"));
+			form->addRow(
+				newLabel(
+					"Basic.AutoConfig.TestPage.Result.StreamingResolution"),
+				newLabel("Automatic"));
+		} else {
+			form->addRow(
+				newLabel("Basic.Settings.Output.VideoBitrate"),
+				new QLabel(QString::number(wiz->idealBitrate),
+					   ui->finishPage));
+			form->addRow(newLabel(TEST_RESULT_SE),
+				     new QLabel(encName(wiz->streamingEncoder),
+						ui->finishPage));
+		}
 	}
 
 	QString baseRes =
@@ -1168,6 +1228,8 @@ void AutoConfigTestPage::FinalizeResults()
 		     new QLabel(scaleRes, ui->finishPage));
 	form->addRow(newLabel("Basic.Settings.Video.FPS"),
 		     new QLabel(fpsStr, ui->finishPage));
+
+	// FIXME: form layout is super squished, probably need to set proper sizepolicy on all widgets?
 }
 
 #define STARTING_SEPARATOR \

+ 172 - 1
UI/window-basic-auto-config.cpp

@@ -3,12 +3,18 @@
 
 #include <obs.hpp>
 
+#include <nlohmann/json.hpp>
+
 #include "window-basic-auto-config.hpp"
 #include "window-basic-main.hpp"
 #include "qt-wrappers.hpp"
 #include "obs-app.hpp"
 #include "url-push-button.hpp"
 
+#include "goliveapi-postdata.hpp"
+#include "goliveapi-network.hpp"
+#include "multitrack-video-error.hpp"
+
 #include "ui_AutoConfigStartPage.h"
 #include "ui_AutoConfigVideoPage.h"
 #include "ui_AutoConfigStreamPage.h"
@@ -260,6 +266,7 @@ AutoConfigStreamPage::AutoConfigStreamPage(QWidget *parent)
 	ui->bitrate->setVisible(false);
 	ui->connectAccount2->setVisible(false);
 	ui->disconnectAccount->setVisible(false);
+	ui->useMultitrackVideo->setVisible(false);
 
 	ui->connectedAccountLabel->setVisible(false);
 	ui->connectedAccountText->setVisible(false);
@@ -410,6 +417,76 @@ bool AutoConfigStreamPage::validatePage()
 		wiz->service = AutoConfig::Service::Other;
 	}
 
+	if (wiz->service == AutoConfig::Service::Twitch) {
+		wiz->testMultitrackVideo = ui->useMultitrackVideo->isChecked();
+
+		auto postData =
+			constructGoLivePost(QString::fromStdString(wiz->key),
+					    std::nullopt, std::nullopt, false);
+
+		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");
+		}
+
+		try {
+			auto config = DownloadGoLiveConfig(
+				this, MultitrackVideoAutoConfigURL(service),
+				postData, multitrack_video_name);
+
+			for (const auto &endpoint : config.ingest_endpoints) {
+				if (qstrnicmp("RTMP", endpoint.protocol.c_str(),
+					      4) != 0)
+					continue;
+
+				std::string address = endpoint.url_template;
+				auto pos = address.find("/{stream_key}");
+				if (pos != address.npos)
+					address.erase(pos);
+
+				wiz->serviceConfigServers.push_back(
+					{address, address});
+			}
+
+			int multitrackVideoBitrate = 0;
+			for (auto &encoder_config :
+			     config.encoder_configurations) {
+				multitrackVideoBitrate +=
+					encoder_config.config.bitrate;
+			}
+
+			// grab a streamkey from the go live config if we can
+			for (auto &endpoint : config.ingest_endpoints) {
+				const char *p = endpoint.protocol.c_str();
+				const char *auth =
+					endpoint.authentication
+						? endpoint.authentication
+							  ->c_str()
+						: nullptr;
+				if (qstrnicmp("RTMP", p, 4) == 0 && auth &&
+				    *auth) {
+					wiz->key = auth;
+					break;
+				}
+			}
+
+			if (multitrackVideoBitrate > 0) {
+				wiz->startingBitrate = multitrackVideoBitrate;
+				wiz->idealBitrate = multitrackVideoBitrate;
+				wiz->multitrackVideo.targetBitrate =
+					multitrackVideoBitrate;
+				wiz->multitrackVideo.testSuccessful = true;
+			}
+		} catch (const MultitrackVideoError & /*err*/) {
+			// FIXME: do something sensible
+		}
+	}
+
 	if (wiz->service != AutoConfig::Service::Twitch &&
 	    wiz->service != AutoConfig::Service::YouTube &&
 	    wiz->bandwidthTest) {
@@ -561,6 +638,22 @@ void AutoConfigStreamPage::on_useStreamKey_clicked()
 	UpdateCompleted();
 }
 
+void AutoConfigStreamPage::on_preferHardware_clicked()
+{
+	auto *main = OBSBasic::Get();
+	bool multitrackVideoEnabled =
+		config_has_user_value(main->Config(), "Stream1",
+				      "EnableMultitrackVideo")
+			? config_get_bool(main->Config(), "Stream1",
+					  "EnableMultitrackVideo")
+			: true;
+
+	ui->useMultitrackVideo->setEnabled(ui->preferHardware->isChecked());
+	ui->multitrackVideoInfo->setEnabled(ui->preferHardware->isChecked());
+	ui->useMultitrackVideo->setChecked(ui->preferHardware->isChecked() &&
+					   multitrackVideoEnabled);
+}
+
 static inline bool is_auth_service(const std::string &service)
 {
 	return Auth::AuthType(service) != Auth::Type::None;
@@ -627,6 +720,48 @@ void AutoConfigStreamPage::ServiceChanged()
 	bool testBandwidth = ui->doBandwidthTest->isChecked();
 	bool custom = IsCustomService();
 
+	bool ertmp_multitrack_video_available = service == "Twitch";
+
+	bool custom_disclaimer = false;
+	auto multitrack_video_name =
+		QTStr("Basic.Settings.Stream.MultitrackVideoLabel");
+	if (!custom) {
+		OBSDataAutoRelease service_settings = obs_data_create();
+		obs_data_set_string(service_settings, "service",
+				    service.c_str());
+		OBSServiceAutoRelease obs_service =
+			obs_service_create("rtmp_common", "temp service",
+					   service_settings, nullptr);
+
+		if (obs_data_has_user_value(service_settings,
+					    "multitrack_video_name")) {
+			multitrack_video_name = obs_data_get_string(
+				service_settings, "multitrack_video_name");
+		}
+
+		if (obs_data_has_user_value(service_settings,
+					    "multitrack_video_disclaimer")) {
+			ui->multitrackVideoInfo->setText(obs_data_get_string(
+				service_settings,
+				"multitrack_video_disclaimer"));
+			custom_disclaimer = true;
+		}
+	}
+
+	if (!custom_disclaimer) {
+		ui->multitrackVideoInfo->setText(
+			QTStr("MultitrackVideo.Info")
+				.arg(multitrack_video_name, service.c_str()));
+	}
+
+	ui->multitrackVideoInfo->setVisible(ertmp_multitrack_video_available);
+	ui->useMultitrackVideo->setVisible(ertmp_multitrack_video_available);
+	ui->useMultitrackVideo->setText(
+		QTStr("Basic.AutoConfig.StreamPage.UseMultitrackVideo")
+			.arg(multitrack_video_name));
+	ui->multitrackVideoInfo->setEnabled(wiz->hardwareEncodingAvailable);
+	ui->useMultitrackVideo->setEnabled(wiz->hardwareEncodingAvailable);
+
 	reset_service_ui_fields(service);
 
 	/* Test three closest servers if "Auto" is available for Twitch */
@@ -977,12 +1112,21 @@ AutoConfig::AutoConfig(QWidget *parent) : QWizard(parent)
 	if (!key.empty())
 		streamPage->ui->key->setText(key.c_str());
 
+	TestHardwareEncoding();
+
 	int bitrate =
 		config_get_int(main->Config(), "SimpleOutput", "VBitrate");
+	bool multitrackVideoEnabled =
+		config_has_user_value(main->Config(), "Stream1",
+				      "EnableMultitrackVideo")
+			? config_get_bool(main->Config(), "Stream1",
+					  "EnableMultitrackVideo")
+			: true;
 	streamPage->ui->bitrate->setValue(bitrate);
+	streamPage->ui->useMultitrackVideo->setChecked(
+		hardwareEncodingAvailable && multitrackVideoEnabled);
 	streamPage->ServiceChanged();
 
-	TestHardwareEncoding();
 	if (!hardwareEncodingAvailable) {
 		delete streamPage->ui->preferHardware;
 		streamPage->ui->preferHardware = nullptr;
@@ -1142,6 +1286,33 @@ void AutoConfig::SaveStreamSettings()
 	config_set_string(main->Config(), "SimpleOutput", "StreamEncoder",
 			  GetEncoderId(streamingEncoder));
 	config_remove_value(main->Config(), "SimpleOutput", "UseAdvanced");
+
+	config_set_bool(main->Config(), "Stream1", "EnableMultitrackVideo",
+			multitrackVideo.testSuccessful);
+
+	if (multitrackVideo.targetBitrate.has_value())
+		config_set_int(main->Config(), "Stream1",
+			       "MultitrackVideoTargetBitrate",
+			       *multitrackVideo.targetBitrate);
+	else
+		config_remove_value(main->Config(), "Stream1",
+				    "MultitrackVideoTargetBitrate");
+
+	if (multitrackVideo.bitrate.has_value() &&
+	    multitrackVideo.targetBitrate.has_value() &&
+	    (static_cast<double>(*multitrackVideo.bitrate) /
+	     *multitrackVideo.targetBitrate) >= 0.90) {
+		config_set_bool(main->Config(), "Stream1",
+				"MultitrackVideoMaximumAggregateBitrateAuto",
+				true);
+	} else if (multitrackVideo.bitrate.has_value()) {
+		config_set_bool(main->Config(), "Stream1",
+				"MultitrackVideoMaximumAggregateBitrateAuto",
+				false);
+		config_set_int(main->Config(), "Stream1",
+			       "MultitrackVideoMaximumAggregateBitrate",
+			       *multitrackVideo.bitrate);
+	}
 }
 
 void AutoConfig::SaveSettings()

+ 14 - 0
UI/window-basic-auto-config.hpp

@@ -12,6 +12,7 @@
 #include <vector>
 #include <string>
 #include <mutex>
+#include <optional>
 
 class Ui_AutoConfigStartPage;
 class Ui_AutoConfigVideoPage;
@@ -64,6 +65,11 @@ class AutoConfig : public QWizard {
 		fps60,
 	};
 
+	struct StreamServer {
+		std::string name;
+		std::string address;
+	};
+
 	static inline const char *GetEncoderId(Encoder enc);
 
 	AutoConfigStreamPage *streamPage = nullptr;
@@ -75,6 +81,11 @@ class AutoConfig : public QWizard {
 	Type type = Type::Streaming;
 	FPSType fpsType = FPSType::PreferHighFPS;
 	int idealBitrate = 2500;
+	struct {
+		std::optional<int> targetBitrate;
+		std::optional<int> bitrate;
+		bool testSuccessful = false;
+	} multitrackVideo;
 	int baseResolutionCX = 1920;
 	int baseResolutionCY = 1080;
 	int idealResolutionCX = 1280;
@@ -84,6 +95,7 @@ class AutoConfig : public QWizard {
 	std::string serviceName;
 	std::string serverName;
 	std::string server;
+	std::vector<StreamServer> serviceConfigServers;
 	std::string key;
 
 	bool hardwareEncodingAvailable = false;
@@ -95,6 +107,7 @@ class AutoConfig : public QWizard {
 	int startingBitrate = 2500;
 	bool customServer = false;
 	bool bandwidthTest = false;
+	bool testMultitrackVideo = false;
 	bool testRegions = true;
 	bool twitchAuto = false;
 	bool regionUS = true;
@@ -195,6 +208,7 @@ public slots:
 	void on_connectAccount_clicked();
 	void on_disconnectAccount_clicked();
 	void on_useStreamKey_clicked();
+	void on_preferHardware_clicked();
 	void ServiceChanged();
 	void UpdateKeyLink();
 	void UpdateMoreInfoLink();

+ 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;

+ 153 - 7
UI/window-basic-settings-stream.cpp

@@ -1,5 +1,6 @@
 #include <QMessageBox>
 #include <QUrl>
+#include <QUuid>
 
 #include "window-basic-settings.hpp"
 #include "obs-frontend-api.h"
@@ -20,6 +21,13 @@
 #include "youtube-api-wrappers.hpp"
 #endif
 
+static const QUuid &CustomServerUUID()
+{
+	static const QUuid uuid = QUuid::fromString(
+		QT_UTF8("{241da255-70f2-4bbb-bef7-509695bf8e65}"));
+	return uuid;
+}
+
 struct QCef;
 struct QCefCookieManager;
 
@@ -37,7 +45,7 @@ enum class Section : int {
 	StreamKey,
 };
 
-inline bool OBSBasicSettings::IsCustomService() const
+bool OBSBasicSettings::IsCustomService() const
 {
 	return ui->service->currentData().toInt() == (int)ListOpt::Custom;
 }
@@ -88,6 +96,16 @@ void OBSBasicSettings::InitStreamPage()
 		&OBSBasicSettings::DisplayEnforceWarning);
 	connect(ui->ignoreRecommended, &QCheckBox::toggled, this,
 		&OBSBasicSettings::UpdateResFPSLimits);
+
+	connect(ui->enableMultitrackVideo, &QCheckBox::toggled, this,
+		&OBSBasicSettings::UpdateMultitrackVideo);
+	connect(ui->multitrackVideoMaximumAggregateBitrateAuto,
+		&QCheckBox::toggled, this,
+		&OBSBasicSettings::UpdateMultitrackVideo);
+	connect(ui->multitrackVideoMaximumVideoTracksAuto, &QCheckBox::toggled,
+		this, &OBSBasicSettings::UpdateMultitrackVideo);
+	connect(ui->multitrackVideoConfigOverrideEnable, &QCheckBox::toggled,
+		this, &OBSBasicSettings::UpdateMultitrackVideo);
 }
 
 void OBSBasicSettings::LoadStream1Settings()
@@ -108,6 +126,8 @@ void OBSBasicSettings::LoadStream1Settings()
 	const char *service = obs_data_get_string(settings, "service");
 	const char *server = obs_data_get_string(settings, "server");
 	const char *key = obs_data_get_string(settings, "key");
+	bool use_custom_server =
+		obs_data_get_bool(settings, "using_custom_server");
 	protocol = QT_UTF8(obs_service_get_protocol(service_obj));
 	const char *bearer_token =
 		obs_data_get_string(settings, "bearer_token");
@@ -145,10 +165,54 @@ void OBSBasicSettings::LoadStream1Settings()
 		ui->twitchAddonDropdown->setCurrentIndex(idx);
 	}
 
+	ui->enableMultitrackVideo->setChecked(config_get_bool(
+		main->Config(), "Stream1", "EnableMultitrackVideo"));
+
+	ui->multitrackVideoMaximumAggregateBitrateAuto->setChecked(
+		config_get_bool(main->Config(), "Stream1",
+				"MultitrackVideoMaximumAggregateBitrateAuto"));
+	if (config_has_user_value(main->Config(), "Stream1",
+				  "MultitrackVideoMaximumAggregateBitrate")) {
+		ui->multitrackVideoMaximumAggregateBitrate->setValue(
+			config_get_int(
+				main->Config(), "Stream1",
+				"MultitrackVideoMaximumAggregateBitrate"));
+	}
+
+	ui->multitrackVideoMaximumVideoTracksAuto->setChecked(
+		config_get_bool(main->Config(), "Stream1",
+				"MultitrackVideoMaximumVideoTracksAuto"));
+	if (config_has_user_value(main->Config(), "Stream1",
+				  "MultitrackVideoMaximumVideoTracks"))
+		ui->multitrackVideoMaximumVideoTracks->setValue(
+			config_get_int(main->Config(), "Stream1",
+				       "MultitrackVideoMaximumVideoTracks"));
+
+	ui->multitrackVideoStreamDumpEnable->setChecked(config_get_bool(
+		main->Config(), "Stream1", "MultitrackVideoStreamDumpEnabled"));
+
+	ui->multitrackVideoConfigOverrideEnable->setChecked(
+		config_get_bool(main->Config(), "Stream1",
+				"MultitrackVideoConfigOverrideEnabled"));
+	if (config_has_user_value(main->Config(), "Stream1",
+				  "MultitrackVideoConfigOverride"))
+		ui->multitrackVideoConfigOverride->setPlainText(
+			DeserializeConfigText(
+				config_get_string(
+					main->Config(), "Stream1",
+					"MultitrackVideoConfigOverride"))
+				.c_str());
+
 	UpdateServerList();
 
 	if (is_rtmp_common) {
-		int idx = ui->server->findData(server);
+		int idx = -1;
+		if (use_custom_server) {
+			idx = ui->server->findData(CustomServerUUID());
+		} else {
+			idx = ui->server->findData(QString::fromUtf8(server));
+		}
+
 		if (idx == -1) {
 			if (server && *server)
 				ui->server->insertItem(0, server, server);
@@ -157,6 +221,9 @@ void OBSBasicSettings::LoadStream1Settings()
 		ui->server->setCurrentIndex(idx);
 	}
 
+	if (use_custom_server)
+		ui->serviceCustomServer->setText(server);
+
 	if (is_whip)
 		ui->key->setText(bearer_token);
 	else
@@ -168,6 +235,7 @@ void OBSBasicSettings::LoadStream1Settings()
 	UpdateMoreInfoLink();
 	UpdateVodTrackSetting();
 	UpdateServiceRecommendations();
+	UpdateMultitrackVideo();
 
 	bool streamActive = obs_frontend_streaming_active();
 	ui->streamPage->setEnabled(!streamActive);
@@ -223,9 +291,19 @@ void OBSBasicSettings::SaveStream1Settings()
 		obs_data_set_string(settings, "service",
 				    QT_TO_UTF8(ui->service->currentText()));
 		obs_data_set_string(settings, "protocol", QT_TO_UTF8(protocol));
-		obs_data_set_string(
-			settings, "server",
-			QT_TO_UTF8(ui->server->currentData().toString()));
+		if (ui->server->currentData() == CustomServerUUID()) {
+			obs_data_set_bool(settings, "using_custom_server",
+					  true);
+
+			obs_data_set_string(
+				settings, "server",
+				QT_TO_UTF8(ui->serviceCustomServer->text()));
+		} else {
+			obs_data_set_string(
+				settings, "server",
+				QT_TO_UTF8(
+					ui->server->currentData().toString()));
+		}
 	} else {
 		obs_data_set_string(
 			settings, "server",
@@ -286,6 +364,49 @@ void OBSBasicSettings::SaveStream1Settings()
 	}
 
 	SaveCheckBox(ui->ignoreRecommended, "Stream1", "IgnoreRecommended");
+
+	auto oldMultitrackVideoSetting = config_get_bool(
+		main->Config(), "Stream1", "EnableMultitrackVideo");
+
+	if (!IsCustomService()) {
+		OBSDataAutoRelease settings = obs_data_create();
+		obs_data_set_string(settings, "service",
+				    QT_TO_UTF8(ui->service->currentText()));
+		OBSServiceAutoRelease temp_service = obs_service_create_private(
+			"rtmp_common", "auto config query service", settings);
+		settings = obs_service_get_settings(temp_service);
+		auto available = obs_data_has_user_value(
+			settings, "multitrack_video_configuration_url");
+
+		if (available) {
+			SaveCheckBox(ui->enableMultitrackVideo, "Stream1",
+				     "EnableMultitrackVideo");
+		} else {
+			config_remove_value(main->Config(), "Stream1",
+					    "EnableMultitrackVideo");
+		}
+	} else {
+		SaveCheckBox(ui->enableMultitrackVideo, "Stream1",
+			     "EnableMultitrackVideo");
+	}
+	SaveCheckBox(ui->multitrackVideoMaximumAggregateBitrateAuto, "Stream1",
+		     "MultitrackVideoMaximumAggregateBitrateAuto");
+	SaveSpinBox(ui->multitrackVideoMaximumAggregateBitrate, "Stream1",
+		    "MultitrackVideoMaximumAggregateBitrate");
+	SaveCheckBox(ui->multitrackVideoMaximumVideoTracksAuto, "Stream1",
+		     "MultitrackVideoMaximumVideoTracksAuto");
+	SaveSpinBox(ui->multitrackVideoMaximumVideoTracks, "Stream1",
+		    "MultitrackVideoMaximumVideoTracks");
+	SaveCheckBox(ui->multitrackVideoStreamDumpEnable, "Stream1",
+		     "MultitrackVideoStreamDumpEnabled");
+	SaveCheckBox(ui->multitrackVideoConfigOverrideEnable, "Stream1",
+		     "MultitrackVideoConfigOverrideEnabled");
+	SaveText(ui->multitrackVideoConfigOverride, "Stream1",
+		 "MultitrackVideoConfigOverride");
+
+	if (oldMultitrackVideoSetting != ui->enableMultitrackVideo->isChecked())
+		main->ResetOutputs();
+
 	SwapMultiTrack(QT_TO_UTF8(protocol));
 }
 
@@ -526,6 +647,7 @@ void OBSBasicSettings::on_service_currentIndexChanged(int idx)
 
 	protocol = FindProtocol();
 	UpdateAdvNetworkGroup();
+	UpdateMultitrackVideo();
 
 	if (ServiceSupportsCodecCheck() && UpdateResFPSLimits()) {
 		lastServiceIdx = idx;
@@ -547,6 +669,7 @@ void OBSBasicSettings::on_customServer_textChanged(const QString &)
 
 	protocol = FindProtocol();
 	UpdateAdvNetworkGroup();
+	UpdateMultitrackVideo();
 
 	if (ServiceSupportsCodecCheck())
 		lastCustomServer = ui->customServer->text();
@@ -567,6 +690,10 @@ void OBSBasicSettings::ServiceChanged(bool resetFields)
 
 	if (resetFields || lastService != service.c_str()) {
 		reset_service_ui_fields(ui.get(), service, loading);
+
+		ui->enableMultitrackVideo->setChecked(config_get_bool(
+			main->Config(), "Stream1", "EnableMultitrackVideo"));
+		UpdateMultitrackVideo();
 	}
 
 	ui->useAuth->setVisible(custom);
@@ -576,8 +703,8 @@ void OBSBasicSettings::ServiceChanged(bool resetFields)
 	ui->authPwWidget->setVisible(custom);
 
 	if (custom || whip) {
-		ui->streamkeyPageLayout->insertRow(1, ui->serverLabel,
-						   ui->serverStackedWidget);
+		ui->destinationLayout->insertRow(1, ui->serverLabel,
+						 ui->serverStackedWidget);
 
 		ui->serverStackedWidget->setCurrentIndex(1);
 		ui->serverStackedWidget->setVisible(true);
@@ -675,6 +802,12 @@ void OBSBasicSettings::UpdateServerList()
 		ui->server->addItem(name, server);
 	}
 
+	if (serviceName == "Twitch") {
+		ui->server->addItem(
+			QTStr("Basic.Settings.Stream.SpecifyCustomServer"),
+			CustomServerUUID());
+	}
+
 	obs_properties_destroy(props);
 }
 
@@ -887,6 +1020,19 @@ void OBSBasicSettings::on_useAuth_toggled()
 	ui->authPwWidget->setVisible(use_auth);
 }
 
+bool OBSBasicSettings::IsCustomServer()
+{
+	return ui->server->currentData() == QVariant{CustomServerUUID()};
+}
+
+void OBSBasicSettings::on_server_currentIndexChanged(int /*index*/)
+{
+	auto server_is_custom = IsCustomServer();
+
+	ui->serviceCustomServerLabel->setVisible(server_is_custom);
+	ui->serviceCustomServer->setVisible(server_is_custom);
+}
+
 void OBSBasicSettings::UpdateVodTrackSetting()
 {
 	bool enableForCustomServer = config_get_bool(

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

@@ -337,6 +337,7 @@ void RestrictResetBitrates(initializer_list<QComboBox *> boxes, int maxbitrate);
 #define GROUP_CHANGED   &QGroupBox::toggled
 #define SCROLL_CHANGED  &QSpinBox::valueChanged
 #define DSCROLL_CHANGED &QDoubleSpinBox::valueChanged
+#define TEXT_CHANGED    &QPlainTextEdit::textChanged
 
 #define GENERAL_CHANGED &OBSBasicSettings::GeneralChanged
 #define STREAM1_CHANGED &OBSBasicSettings::Stream1Changed
@@ -411,6 +412,7 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 	HookWidget(ui->service,              COMBO_CHANGED,  STREAM1_CHANGED);
 	HookWidget(ui->server,               COMBO_CHANGED,  STREAM1_CHANGED);
 	HookWidget(ui->customServer,         EDIT_CHANGED,   STREAM1_CHANGED);
+	HookWidget(ui->serviceCustomServer,  EDIT_CHANGED,   STREAM1_CHANGED);
 	HookWidget(ui->key,                  EDIT_CHANGED,   STREAM1_CHANGED);
 	HookWidget(ui->bandwidthTestEnable,  CHECK_CHANGED,  STREAM1_CHANGED);
 	HookWidget(ui->twitchAddonDropdown,  COMBO_CHANGED,  STREAM1_CHANGED);
@@ -418,6 +420,14 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 	HookWidget(ui->authUsername,         EDIT_CHANGED,   STREAM1_CHANGED);
 	HookWidget(ui->authPw,               EDIT_CHANGED,   STREAM1_CHANGED);
 	HookWidget(ui->ignoreRecommended,    CHECK_CHANGED,  STREAM1_CHANGED);
+	HookWidget(ui->enableMultitrackVideo,      CHECK_CHANGED,  STREAM1_CHANGED);
+	HookWidget(ui->multitrackVideoMaximumAggregateBitrateAuto, CHECK_CHANGED,  STREAM1_CHANGED);
+	HookWidget(ui->multitrackVideoMaximumAggregateBitrate,     SCROLL_CHANGED, STREAM1_CHANGED);
+	HookWidget(ui->multitrackVideoMaximumVideoTracksAuto, CHECK_CHANGED,  STREAM1_CHANGED);
+	HookWidget(ui->multitrackVideoMaximumVideoTracks,     SCROLL_CHANGED, STREAM1_CHANGED);
+	HookWidget(ui->multitrackVideoStreamDumpEnable,            CHECK_CHANGED,  STREAM1_CHANGED);
+	HookWidget(ui->multitrackVideoConfigOverrideEnable,        CHECK_CHANGED,  STREAM1_CHANGED);
+	HookWidget(ui->multitrackVideoConfigOverride,              TEXT_CHANGED,   STREAM1_CHANGED);
 	HookWidget(ui->outputMode,           COMBO_CHANGED,  OUTPUTS_CHANGED);
 	HookWidget(ui->simpleOutputPath,     EDIT_CHANGED,   OUTPUTS_CHANGED);
 	HookWidget(ui->simpleNoSpace,        CHECK_CHANGED,  OUTPUTS_CHANGED);
@@ -886,6 +896,8 @@ OBSBasicSettings::OBSBasicSettings(QWidget *parent)
 
 	obs_properties_destroy(ppts);
 
+	ui->multitrackVideoNoticeBox->setVisible(false);
+
 	InitStreamPage();
 	InitAppearancePage();
 	LoadSettings(false);
@@ -1067,6 +1079,27 @@ void OBSBasicSettings::SaveSpinBox(QSpinBox *widget, const char *section,
 		config_set_int(main->Config(), section, value, widget->value());
 }
 
+void OBSBasicSettings::SaveText(QPlainTextEdit *widget, const char *section,
+				const char *value)
+{
+	if (!WidgetChanged(widget))
+		return;
+
+	auto utf8 = widget->toPlainText().toUtf8();
+
+	OBSDataAutoRelease safe_text = obs_data_create();
+	obs_data_set_string(safe_text, "text", utf8.constData());
+
+	config_set_string(main->Config(), section, value,
+			  obs_data_get_json(safe_text));
+}
+
+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)
 {
@@ -4631,6 +4664,8 @@ void OBSBasicSettings::OutputsChanged()
 		outputsChanged = true;
 		sender()->setProperty("changed", QVariant(true));
 		EnableApplyButton(true);
+
+		UpdateMultitrackVideo();
 	}
 }
 
@@ -5389,6 +5424,7 @@ void OBSBasicSettings::SimpleRecordingQualityChanged()
 	ui->simpleOutRecFormat->setVisible(!losslessQuality);
 	ui->simpleOutRecFormatLabel->setVisible(!losslessQuality);
 
+	UpdateMultitrackVideo();
 	SimpleRecordingEncoderChanged();
 	SimpleReplayBufferChanged();
 }
@@ -6246,6 +6282,244 @@ void OBSBasicSettings::UpdateAdvNetworkGroup()
 #endif
 }
 
+extern bool MultitrackVideoDeveloperModeEnabled();
+
+void OBSBasicSettings::UpdateMultitrackVideo()
+{
+	// Technically, it should currently be safe to toggle multitrackVideo
+	// while not streaming (recording should be irrelevant), but practically
+	// output settings aren't currently being tracked with that degree of
+	// flexibility, so just disable everything while outputs are active.
+	auto toggle_available = !main->Active();
+
+	// FIXME: protocol is not updated properly for WHIP; what do?
+	auto available = protocol.startsWith("RTMP");
+
+	if (available && !IsCustomService()) {
+		OBSDataAutoRelease settings = obs_data_create();
+		obs_data_set_string(settings, "service",
+				    QT_TO_UTF8(ui->service->currentText()));
+		OBSServiceAutoRelease temp_service = obs_service_create_private(
+			"rtmp_common", "auto config query service", settings);
+		settings = obs_service_get_settings(temp_service);
+		available = obs_data_has_user_value(
+			settings, "multitrack_video_configuration_url");
+		if (!available && ui->enableMultitrackVideo->isChecked())
+			ui->enableMultitrackVideo->setChecked(false);
+	}
+
+	ui->multitrackVideoGroupBox->setVisible(available);
+
+	ui->enableMultitrackVideo->setEnabled(toggle_available);
+
+	ui->multitrackVideoMaximumAggregateBitrateLabel->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked());
+	ui->multitrackVideoMaximumAggregateBitrateAuto->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked());
+	ui->multitrackVideoMaximumAggregateBitrate->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked() &&
+		!ui->multitrackVideoMaximumAggregateBitrateAuto->isChecked());
+
+	ui->multitrackVideoMaximumVideoTracksLabel->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked());
+	ui->multitrackVideoMaximumVideoTracksAuto->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked());
+	ui->multitrackVideoMaximumVideoTracks->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked() &&
+		!ui->multitrackVideoMaximumVideoTracksAuto->isChecked());
+
+	ui->multitrackVideoStreamDumpEnable->setVisible(
+		available && MultitrackVideoDeveloperModeEnabled());
+	ui->multitrackVideoConfigOverrideEnable->setVisible(
+		available && MultitrackVideoDeveloperModeEnabled());
+	ui->multitrackVideoConfigOverrideLabel->setVisible(
+		available && MultitrackVideoDeveloperModeEnabled());
+	ui->multitrackVideoConfigOverride->setVisible(
+		available && MultitrackVideoDeveloperModeEnabled());
+
+	ui->multitrackVideoStreamDumpEnable->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked());
+	ui->multitrackVideoConfigOverrideEnable->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked());
+	ui->multitrackVideoConfigOverrideLabel->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked() &&
+		ui->multitrackVideoConfigOverrideEnable->isChecked());
+	ui->multitrackVideoConfigOverride->setEnabled(
+		toggle_available && ui->enableMultitrackVideo->isChecked() &&
+		ui->multitrackVideoConfigOverrideEnable->isChecked());
+
+	auto update_simple_output_settings = [&](bool mtv_enabled) {
+		auto recording_uses_stream_encoder =
+			ui->simpleOutRecQuality->currentData().toString() ==
+			"Stream";
+		mtv_enabled = mtv_enabled && !recording_uses_stream_encoder;
+
+		ui->simpleOutputVBitrateLabel->setDisabled(mtv_enabled);
+		ui->simpleOutputVBitrate->setDisabled(mtv_enabled);
+
+		ui->simpleOutputABitrateLabel->setDisabled(mtv_enabled);
+		ui->simpleOutputABitrate->setDisabled(mtv_enabled);
+
+		ui->simpleOutStrEncoderLabel->setDisabled(mtv_enabled);
+		ui->simpleOutStrEncoder->setDisabled(mtv_enabled);
+
+		ui->simpleOutPresetLabel->setDisabled(mtv_enabled);
+		ui->simpleOutPreset->setDisabled(mtv_enabled);
+
+		ui->simpleOutCustomLabel->setDisabled(mtv_enabled);
+		ui->simpleOutCustom->setDisabled(mtv_enabled);
+
+		ui->simpleOutStrAEncoderLabel->setDisabled(mtv_enabled);
+		ui->simpleOutStrAEncoder->setDisabled(mtv_enabled);
+	};
+
+	auto update_advanced_output_settings = [&](bool mtv_enabled) {
+		auto recording_uses_stream_video_encoder =
+			ui->advOutRecEncoder->currentText() ==
+			TEXT_USE_STREAM_ENC;
+		auto recording_uses_stream_audio_encoder =
+			ui->advOutRecAEncoder->currentData() == "none";
+		auto disable_video = mtv_enabled &&
+				     !recording_uses_stream_video_encoder;
+		auto disable_audio = mtv_enabled &&
+				     !recording_uses_stream_audio_encoder;
+
+		ui->advOutAEncLabel->setDisabled(disable_audio);
+		ui->advOutAEncoder->setDisabled(disable_audio);
+
+		ui->advOutEncLabel->setDisabled(disable_video);
+		ui->advOutEncoder->setDisabled(disable_video);
+
+		ui->advOutUseRescale->setDisabled(disable_video);
+		ui->advOutRescale->setDisabled(disable_video);
+		ui->advOutRescaleFilter->setDisabled(disable_video);
+
+		if (streamEncoderProps)
+			streamEncoderProps->SetDisabled(disable_video);
+	};
+
+	auto update_advanced_output_audio_tracks = [&](bool mtv_enabled) {
+		auto vod_track_enabled = vodTrackCheckbox &&
+					 vodTrackCheckbox->isChecked();
+
+		auto vod_track_idx_enabled = [&](size_t idx) {
+			return vod_track_enabled && vodTrack[idx] &&
+			       vodTrack[idx]->isChecked();
+		};
+
+		auto track1_warning_visible = mtv_enabled &&
+					      (ui->advOutTrack1->isChecked() ||
+					       vod_track_idx_enabled(1));
+		auto track1_disabled = track1_warning_visible &&
+				       !ui->advOutRecTrack1->isChecked();
+		ui->advOutTrack1BitrateLabel->setDisabled(track1_disabled);
+		ui->advOutTrack1Bitrate->setDisabled(track1_disabled);
+
+		auto track2_warning_visible = mtv_enabled &&
+					      (ui->advOutTrack2->isChecked() ||
+					       vod_track_idx_enabled(2));
+		auto track2_disabled = track2_warning_visible &&
+				       !ui->advOutRecTrack2->isChecked();
+		ui->advOutTrack2BitrateLabel->setDisabled(track2_disabled);
+		ui->advOutTrack2Bitrate->setDisabled(track2_disabled);
+
+		auto track3_warning_visible = mtv_enabled &&
+					      (ui->advOutTrack3->isChecked() ||
+					       vod_track_idx_enabled(3));
+		auto track3_disabled = track3_warning_visible &&
+				       !ui->advOutRecTrack3->isChecked();
+		ui->advOutTrack3BitrateLabel->setDisabled(track3_disabled);
+		ui->advOutTrack3Bitrate->setDisabled(track3_disabled);
+
+		auto track4_warning_visible = mtv_enabled &&
+					      (ui->advOutTrack4->isChecked() ||
+					       vod_track_idx_enabled(4));
+		auto track4_disabled = track4_warning_visible &&
+				       !ui->advOutRecTrack4->isChecked();
+		ui->advOutTrack4BitrateLabel->setDisabled(track4_disabled);
+		ui->advOutTrack4Bitrate->setDisabled(track4_disabled);
+
+		auto track5_warning_visible = mtv_enabled &&
+					      (ui->advOutTrack5->isChecked() ||
+					       vod_track_idx_enabled(5));
+		auto track5_disabled = track5_warning_visible &&
+				       !ui->advOutRecTrack5->isChecked();
+		ui->advOutTrack5BitrateLabel->setDisabled(track5_disabled);
+		ui->advOutTrack5Bitrate->setDisabled(track5_disabled);
+
+		auto track6_warning_visible = mtv_enabled &&
+					      (ui->advOutTrack6->isChecked() ||
+					       vod_track_idx_enabled(6));
+		auto track6_disabled = track6_warning_visible &&
+				       !ui->advOutRecTrack6->isChecked();
+		ui->advOutTrack6BitrateLabel->setDisabled(track6_disabled);
+		ui->advOutTrack6Bitrate->setDisabled(track6_disabled);
+	};
+
+	if (available) {
+		OBSDataAutoRelease settings;
+		{
+			auto service_name = ui->service->currentText();
+			auto custom_server = ui->customServer->text().trimmed();
+
+			obs_properties_t *props =
+				obs_get_service_properties("rtmp_common");
+			obs_property_t *service =
+				obs_properties_get(props, "service");
+
+			settings = obs_data_create();
+
+			obs_data_set_string(settings, "service",
+					    QT_TO_UTF8(service_name));
+			obs_property_modified(service, settings);
+
+			obs_properties_destroy(props);
+		}
+
+		auto multitrack_video_name =
+			QTStr("Basic.Settings.Stream.MultitrackVideoLabel");
+		if (obs_data_has_user_value(settings, "multitrack_video_name"))
+			multitrack_video_name = obs_data_get_string(
+				settings, "multitrack_video_name");
+
+		ui->enableMultitrackVideo->setText(
+			QTStr("Basic.Settings.Stream.EnableMultitrackVideo")
+				.arg(multitrack_video_name));
+
+		if (obs_data_has_user_value(settings,
+					    "multitrack_video_disclaimer")) {
+			ui->multitrackVideoInfo->setVisible(true);
+			ui->multitrackVideoInfo->setText(obs_data_get_string(
+				settings, "multitrack_video_disclaimer"));
+		} else {
+			ui->multitrackVideoInfo->setText(
+				QTStr("MultitrackVideo.Info")
+					.arg(multitrack_video_name,
+					     ui->service->currentText()));
+		}
+
+		auto disabled_text =
+			QTStr("Basic.Settings.MultitrackVideoDisabledSettings")
+				.arg(ui->service->currentText())
+				.arg(multitrack_video_name);
+
+		ui->multitrackVideoNotice->setText(disabled_text);
+
+		auto mtv_enabled = ui->enableMultitrackVideo->isChecked();
+		ui->multitrackVideoNoticeBox->setVisible(mtv_enabled);
+
+		update_simple_output_settings(mtv_enabled);
+		update_advanced_output_settings(mtv_enabled);
+		update_advanced_output_audio_tracks(mtv_enabled);
+	} else {
+		ui->multitrackVideoNoticeBox->setVisible(false);
+
+		update_simple_output_settings(false);
+		update_advanced_output_settings(false);
+		update_advanced_output_audio_tracks(false);
+	}
+}
+
 void OBSBasicSettings::SimpleStreamAudioEncoderChanged()
 {
 	PopulateSimpleBitrates(

+ 9 - 1
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
@@ -193,6 +195,8 @@ private:
 		      const char *value);
 	void SaveSpinBox(QSpinBox *widget, const char *section,
 			 const char *value);
+	void SaveText(QPlainTextEdit *widget, const char *section,
+		      const char *value);
 	void SaveFormat(QComboBox *combo);
 	void SaveEncoder(QComboBox *combo, const char *section,
 			 const char *value);
@@ -273,7 +277,7 @@ private:
 
 	/* stream */
 	void InitStreamPage();
-	inline bool IsCustomService() const;
+	bool IsCustomService() const;
 	inline bool IsWHIP() const;
 	void LoadServices(bool showAll);
 	void OnOAuthStreamKeyConnected();
@@ -296,7 +300,10 @@ private:
 	/* Appearance */
 	void InitAppearancePage();
 
+	bool IsCustomServer();
+
 private slots:
+	void UpdateMultitrackVideo();
 	void RecreateOutputResolutionWidget();
 	bool UpdateResFPSLimits();
 	void DisplayEnforceWarning(bool checked);
@@ -306,6 +313,7 @@ private slots:
 	void on_disconnectAccount_clicked();
 	void on_useStreamKey_clicked();
 	void on_useAuth_toggled();
+	void on_server_currentIndexChanged(int index);
 
 	void on_hotkeyFilterReset_clicked();
 	void on_hotkeyFilterSearch_textChanged(const QString text);