From 3def4550f29ecbdec3221bff64cdf75f90fefc7d Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Tue, 6 Oct 2026 13:56:12 +1300 Subject: [PATCH 1/5] Support Twitch-only 1440p optimization and improve Enhanced Broadcasting compatibility --- js/module.d.ts | 1 + js/module.ts | 26 +++++- .../source/auto-optimizer-client-contract.cpp | 11 ++- .../auto-optimizer-client-contract-test.cpp | 43 +++++++++- ...izer-enhanced-broadcasting-policy-test.cpp | 52 +++++++++++- .../auto-optimizer-quality-policy-test.cpp | 27 +++++- .../source/nodeobs_auto_optimizer.cpp | 84 +++++++++++++++---- ...hanced-broadcasting-advanced-streaming.cpp | 1 + ...enhanced-broadcasting-simple-streaming.cpp | 1 + ...optimizer-enhanced-broadcasting-policy.hpp | 21 ++++- source/auto-optimizer-quality-policy.hpp | 26 +++++- .../osn-tests/src/test_osn_auto_optimizer.ts | 42 ++++++++-- .../src/test_osn_get_available_encoders.ts | 50 +++++++++++ 13 files changed, 353 insertions(+), 32 deletions(-) diff --git a/js/module.d.ts b/js/module.d.ts index ecd1b9c73..595d36a01 100644 --- a/js/module.d.ts +++ b/js/module.d.ts @@ -1131,6 +1131,7 @@ interface IAutoOptimizerOutputResult { videos: IAutoOptimizerVideoRecommendation[]; encoding?: IAutoOptimizerEncodingRecommendation; measurement: IAutoOptimizerMeasurement; + warnings?: string[]; } type AutoOptimizerFatalErrorCode = 'cancelled' | 'hardware_no_usable_encoder' | 'hardware_benchmark_overloaded' | 'hardware_benchmark_timeout' | 'hardware_benchmark_unavailable' | 'auto_optimizer_worker_failed' | 'auto_optimizer_worker_launch_failed'; interface IAutoOptimizerError { diff --git a/js/module.ts b/js/module.ts index d385e72ac..abe326ee1 100644 --- a/js/module.ts +++ b/js/module.ts @@ -1836,6 +1836,13 @@ export interface IStreaming { network: INetwork, video: IVideo, signalHandler: (signal: EOutputSignal) => void, + /** + * Returns encoder options and mode-specific metadata, filtered by the assigned service. + * Works for ordinary and Enhanced Broadcasting outputs without starting a stream or + * requesting a Twitch configuration; this is not the active Enhanced Broadcasting ladder. + * The instance must remain alive. A failed query throws a JavaScript Error without + * changing the output or its encoders. + */ getAvailableEncoders(): IEncoderOption[], start(): void, // throws stop(force?: boolean): void, @@ -2146,16 +2153,26 @@ interface IAutoOptimizerLimits { * settings are managed by Desktop. A provider probe may test above this * value to verify stability or shared upload capacity, but the * recommendation never exceeds it. + * When multiple distinct platforms are selected across the request, OSN + * additionally caps each ordinary output at 6000 Kbps. Twitch Enhanced + * Broadcasting retains its platform-managed bitrate ladder. */ maxBitrateKbps?: number; /** * Maximum resolution OSN may test for this run without changing persistent * video settings. Supply `maxWidth` and `maxHeight` together. OSN tests only - * the supported 1920x1080, 1280x720, and 960x540 tiers, or their portrait + * the supported 2560x1440, 1920x1080, 1280x720, and 960x540 tiers, or their portrait * equivalents, up to this limit. It promotes only 16:9 or 9:16 output; a * custom aspect ratio keeps its current resolution and frame rate. The * caller remains responsible for applying a recommended Base Canvas resize * safely. + * Horizontal 1440p testing is available only when every destination in the + * request is Twitch. Other streams are limited to 1080p. Vertical output + * remains limited to 1080x1920, including Twitch Enhanced Broadcasting's + * paired 1440p horizontal / 1080p vertical workload. Ordinary Twitch 1440p + * requests use an experimental 8000 Kbps limit instead of the service's + * 6000 Kbps default; the caller must apply that tested bitrate without + * reapplying the lower service bitrate limit. */ maxWidth?: number; maxHeight?: number; @@ -2393,6 +2410,13 @@ interface IAutoOptimizerOutputResult { /** Omitted for Twitch Enhanced Broadcasting because Twitch supplies its encoding ladder. */ encoding?: IAutoOptimizerEncodingRecommendation; measurement: IAutoOptimizerMeasurement; + /** + * Non-fatal warning codes for the recommended settings, independent of + * measurement confidence. `enhanced_broadcasting_configuration_warning` + * means Twitch returned a warning for the selected configuration even + * though its probe passed. Twitch's HTML message is not exposed. + */ + warnings?: string[]; } type AutoOptimizerFatalErrorCode = diff --git a/obs-studio-client/source/auto-optimizer-client-contract.cpp b/obs-studio-client/source/auto-optimizer-client-contract.cpp index 8959fbe6e..172134309 100644 --- a/obs-studio-client/source/auto-optimizer-client-contract.cpp +++ b/obs-studio-client/source/auto-optimizer-client-contract.cpp @@ -1,6 +1,7 @@ #include "auto-optimizer-client-contract.hpp" #include "auto-optimizer-quality-policy.hpp" +#include "auto-optimizer-enhanced-broadcasting-policy.hpp" #include "auto-optimizer-probe-policy.hpp" #include "nlohmann/json.hpp" @@ -661,7 +662,9 @@ bool parseRecommendation(const json &value, const OutputContext &output, const s if (result.additionalVideo) { const auto &additional = *result.additionalVideo; const auto &additionalContext = *output.additionalVideo; - if (additional["width"] != result.height || additional["height"] != result.width || + const auto paired = enhancedBroadcastingPolicy::pairedVerticalCandidate( + {(uint32_t)result.width, (uint32_t)result.height, (uint32_t)result.fpsNum, (uint32_t)result.fpsDen}); + if (additional["width"] != paired.width || additional["height"] != paired.height || (measurementMode == "active" && static_cast(additional["fpsNum"].get()) * result.fpsDen != static_cast(result.fpsNum) * additional["fpsDen"].get()) || !tupleWithinLimits(additional["width"], additional["height"], additional["fpsNum"], additional["fpsDen"], additionalContext.limits)) @@ -764,6 +767,10 @@ bool parseOutputResult(const json &value, const OutputContext &expected, ParsedO return false; const auto &measurement = value["measurement"]; + if (measurement.contains("configurationWarning") && + (!measurement["configurationWarning"].is_boolean() || expected.outputKind != "twitch-enhanced-broadcasting" || + (measurement["configurationWarning"].get() && measurement.value("mode", "") != "active"))) + return false; result.measurementMode = measurement.value("mode", ""); result.confidence = measurement.value("confidence", ""); if ((result.measurementMode != "active" && result.measurementMode != "estimated") || @@ -806,6 +813,8 @@ bool parseOutputResult(const json &value, const OutputContext &expected, ParsedO sharedUploadEstimate)) return false; result.projected.update(std::move(recommendationProjection)); + if (measurement.value("configurationWarning", false)) + result.projected["warnings"] = {"enhanced_broadcasting_configuration_warning"}; result.projected["measurement"] = {{"mode", result.measurementMode}, {"confidence", result.confidence}}; if (result.reason) result.projected["measurement"]["reason"] = *result.reason; diff --git a/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp b/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp index 3a3e5c31c..9c0118aaa 100644 --- a/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp +++ b/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp @@ -585,10 +585,30 @@ TEST_CASE("Auto Optimizer client requires the exact active Dual Output aggregate {"divergent preset", [](json &value) { value["legs"][1]["recommendation"]["preset"] = "veryfast"; }}}); } +TEST_CASE("Auto Optimizer client requires exact proof for a 1440p horizontal and 1080p vertical pair") +{ + json output = enhancedBroadcastingOutput(); + output["limits"] = {{"maxWidth", 2560}, {"maxHeight", 1440}}; + const auto prepared = prepare({{"streamSetup", "enhanced-broadcasting"}, {"outputs", json::array({output})}}); + json returned = returnedEnhancedBroadcastingOutput(); + returned["recommendation"]["width"] = 2560; + returned["recommendation"]["height"] = 1440; + returned["measurement"]["probes"][0]["testedWidth"] = 2560; + returned["measurement"]["probes"][0]["testedHeight"] = 1440; + ResultFixture fixture{prepared, {{"schemaVersion", 1}, {"sessionId", "run"}, {"status", "complete"}, {"legs", json::array({returned})}}}; + REQUIRE(contract::projectResult(fixture.result.dump(), "run", prepared.context).valid); + checkInvalidResultMutations( + fixture, { + {"unproven vertical size", [](json &value) { value["legs"][0]["recommendation"]["additionalVideo"]["width"] = 720; }}, + {"unproven primary size", [](json &value) { value["legs"][0]["measurement"]["probes"][0]["testedWidth"] = 1920; }}, + }); +} + TEST_CASE("Auto Optimizer client preserves measured Enhanced Broadcasting fallback explanations") { for (const auto reason : - {"enhanced_broadcasting_transport_fallback", "enhanced_broadcasting_workload_fallback", "enhanced_broadcasting_transport_and_workload_fallback"}) { + {"enhanced_broadcasting_transport_fallback", "enhanced_broadcasting_workload_fallback", "enhanced_broadcasting_transport_and_workload_fallback", + "enhanced_broadcasting_configuration_fallback"}) { CAPTURE(reason); auto fixture = enhancedBroadcastingFixture(); fixture.result["legs"][0]["measurement"]["reason"] = reason; @@ -604,6 +624,27 @@ TEST_CASE("Auto Optimizer client preserves measured Enhanced Broadcasting fallba } } +TEST_CASE("Auto Optimizer projects Twitch configuration warnings separately from measurement confidence") +{ + auto fixture = enhancedBroadcastingFixture(); + auto &measurement = fixture.result["legs"][0]["measurement"]; + measurement["configurationWarning"] = true; + measurement["reason"] = "enhanced_broadcasting_configuration_fallback"; + const auto result = contract::projectResult(fixture.result.dump(), "run", fixture.prepared.context); + REQUIRE(result.valid); + const auto output = json::parse(result.json)["outputs"][0]; + CHECK(output["warnings"] == json::array({"enhanced_broadcasting_configuration_warning"})); + CHECK(output["measurement"]["reason"] == measurement["reason"]); + CHECK(output["measurement"]["confidence"] == "high"); + CHECK_FALSE(output["measurement"].contains("configurationWarning")); + measurement["configurationWarning"] = false; + const auto noWarning = contract::projectResult(fixture.result.dump(), "run", fixture.prepared.context); + REQUIRE(noWarning.valid); + CHECK_FALSE(json::parse(noWarning.json)["outputs"][0].contains("warnings")); + measurement["configurationWarning"] = "untrusted platform text"; + CHECK_FALSE(contract::projectResult(fixture.result.dump(), "run", fixture.prepared.context).valid); +} + TEST_CASE("Auto Optimizer client requires exact Enhanced Broadcasting combined workload proof") { const auto fixture = enhancedBroadcastingFixture(); diff --git a/obs-studio-client/tests/auto-optimizer-enhanced-broadcasting-policy-test.cpp b/obs-studio-client/tests/auto-optimizer-enhanced-broadcasting-policy-test.cpp index b16874c2e..93c89d3bd 100644 --- a/obs-studio-client/tests/auto-optimizer-enhanced-broadcasting-policy-test.cpp +++ b/obs-studio-client/tests/auto-optimizer-enhanced-broadcasting-policy-test.cpp @@ -4,6 +4,29 @@ namespace policy = autoOptimizer::enhancedBroadcastingPolicy; +TEST_CASE("Enhanced Broadcasting probes preserve discovered H.264 and HEVC independently of resolution") +{ + std::unordered_set codecs{"h264", "h265", "av1", "vp9"}; + policy::filterProbeCodecs(codecs); + CHECK(codecs == std::unordered_set{"h264", "h265"}); +} + +TEST_CASE("Enhanced Broadcasting probes do not add undiscovered codecs") +{ + for (const auto &available : std::vector>{{"h264"}, {"h265"}, {}}) { + auto filtered = available; + policy::filterProbeCodecs(filtered); + CHECK(filtered == available); + } +} + +TEST_CASE("Enhanced Broadcasting probes exclude AV1 even when it is the only discovered codec") +{ + std::unordered_set codecs{"av1"}; + policy::filterProbeCodecs(codecs); + CHECK(codecs.empty()); +} + TEST_CASE("Enhanced Broadcasting candidates are ordered and capped at 1080p") { const auto result = policy::candidates(1920, 1080, 60, 1); @@ -144,7 +167,9 @@ TEST_CASE("Enhanced Broadcasting fallback reports retain earlier upload instabil policy::CandidateFallbackEvidence evidence; CHECK(evidence.reason().empty()); evidence.record("enhanced_broadcasting_ladder_below_candidate"); - CHECK(evidence.reason().empty()); + CHECK(evidence.reason() == "enhanced_broadcasting_configuration_fallback"); + CHECK_FALSE(evidence.workloadPressure); + CHECK_FALSE(evidence.transportPressure); evidence.record("enhanced_broadcasting_transport_pressure"); CHECK(evidence.reason() == "enhanced_broadcasting_transport_fallback"); evidence.record("enhanced_broadcasting_ladder_below_candidate"); @@ -153,6 +178,19 @@ TEST_CASE("Enhanced Broadcasting fallback reports retain earlier upload instabil CHECK(policy::CandidateFallbackEvidence{}.reason().empty()); } +TEST_CASE("Enhanced Broadcasting configuration fallback does not imply measured overload") +{ + for (const auto error : {"enhanced_broadcasting_ladder_below_candidate", "enhanced_broadcasting_config_request_failed"}) { + policy::CandidateFallbackEvidence evidence; + evidence.record(error); + CHECK(evidence.reason() == "enhanced_broadcasting_configuration_fallback"); + CHECK_FALSE(evidence.workloadPressure); + CHECK_FALSE(evidence.transportPressure); + evidence.record("enhanced_broadcasting_render_overload"); + CHECK(evidence.reason() == "enhanced_broadcasting_workload_fallback"); + } +} + TEST_CASE("Enhanced Broadcasting fallback reports distinguish workload and upload failures") { for (const auto error : @@ -170,3 +208,15 @@ TEST_CASE("Enhanced Broadcasting fallback reports distinguish workload and uploa CHECK(reversed.reason() == evidence.reason()); } } +TEST_CASE("Enhanced Broadcasting 1440p pairs with a 1080p vertical workload") +{ + const auto candidates = autoOptimizer::enhancedBroadcastingPolicy::candidates(2560, 1440, 60, 1); + REQUIRE(candidates.size() == 7); + CHECK(candidates.front().width == 2560); + CHECK(candidates.front().height == 1440); + const auto vertical = autoOptimizer::enhancedBroadcastingPolicy::pairedVerticalCandidate(candidates.front()); + CHECK(vertical.width == 1080); + CHECK(vertical.height == 1920); + CHECK(vertical.fpsNum == 60); + CHECK(autoOptimizer::enhancedBroadcastingPolicy::candidates(1920, 1080, 60, 1).size() == 5); +} diff --git a/obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp b/obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp index f8a7548a1..8f6d40af6 100644 --- a/obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp +++ b/obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp @@ -8,6 +8,16 @@ namespace policy = autoOptimizer::qualityPolicy; +TEST_CASE("Multistream bitrate policy counts platforms rather than canvases") +{ + CHECK_FALSE(policy::isMultiplatformStream({})); + CHECK_FALSE(policy::isMultiplatformStream({"twitch", "twitch"})); + CHECK_FALSE(policy::isMultiplatformStream({"youtube", "youtube"})); + CHECK(policy::isMultiplatformStream({"twitch", "youtube"})); + CHECK(policy::isMultiplatformStream({"youtube", "kick"})); + CHECK(policy::kMultistreamVideoBitrateLimitKbps == 6000); +} + TEST_CASE("Auto Optimizer accepts only effective frame rates from 1 through 240 FPS") { CHECK_FALSE(policy::isValidFrameRate(0, 1)); @@ -234,6 +244,19 @@ TEST_CASE("Auto Optimizer hardware tests high frame rates in product-priority or CHECK(result[1].fpsNum == 30); } +TEST_CASE("Auto Optimizer permits Twitch-only 1440p with an experimental measured 8 Mbps budget") +{ + CHECK(policy::isTwitchOnlyStream({"twitch", "twitch"})); + CHECK_FALSE(policy::isTwitchOnlyStream({"twitch", "youtube"})); + CHECK_FALSE(policy::isTwitchOnlyStream({"twitch", "custom"})); + CHECK_FALSE(policy::isTwitchOnlyStream({})); + const policy::VideoTuple ceiling{2560, 1440, 60, 1}; + CHECK(policy::select(ceiling, 8000, "nvenc", policy::QualityProfile::Twitch).video.width == 2560); + CHECK(policy::select(ceiling, 7950, "nvenc", policy::QualityProfile::Twitch).video.width == 1920); + CHECK(policy::select(ceiling, 6000, "nvenc", policy::QualityProfile::Twitch).video.width == 1920); + CHECK(policy::benchmarkCeiling({1280, 720, 30, 1}, 2560, 1440, 60, 1).width == 2560); +} + TEST_CASE("Auto Optimizer hardware timeout scales with work and remains bounded") { const int oneAttempt = policy::hardwarePhaseTimeoutMs(1, 500, 1500, 3000); @@ -441,8 +464,8 @@ TEST_CASE("Auto Optimizer benchmark ceiling explicitly permits isolated promotio CHECK(cappedNtsc.fpsDen == 1001); const auto productBound = policy::benchmarkCeiling(current, 3840, 2160); - CHECK(productBound.width == 1920); - CHECK(productBound.height == 1080); + CHECK(productBound.width == 2560); + CHECK(productBound.height == 1440); const auto lowerProductBound = policy::benchmarkCeiling({1920, 1080, 30, 1}, 1280, 720); CHECK(lowerProductBound.width == 1280); diff --git a/obs-studio-server/source/nodeobs_auto_optimizer.cpp b/obs-studio-server/source/nodeobs_auto_optimizer.cpp index 6f7fffb11..a20d0eff9 100644 --- a/obs-studio-server/source/nodeobs_auto_optimizer.cpp +++ b/obs-studio-server/source/nodeobs_auto_optimizer.cpp @@ -221,6 +221,7 @@ struct LegRequest { Limits limits; std::optional additionalVideo; std::string estimateReason; + bool experimentalTwitch1440p = false; }; struct ProbeRequest { @@ -260,6 +261,7 @@ struct Recommendation { std::string measurementMode = "estimated"; std::string confidence = "medium"; std::string reason; + bool configurationWarning = false; CurrentSettings value; std::optional additionalVideo; std::vector probes; @@ -1065,12 +1067,27 @@ static int offlinePlatformCapKbps(const std::string &platform) return value > 0 && value < probeValue ? value : 0; } -static LegRequest withOfflinePlatformCaps(const LegRequest &input) +static LegRequest withOfflinePlatformCaps(const LegRequest &input, bool twitchOnly, bool multiplatform) { LegRequest leg = input; - int strictest = 0; + const bool portrait = leg.current.width < leg.current.height; + const int maxWidth = portrait ? 1080 : (twitchOnly ? 2560 : 1920); + const int maxHeight = portrait ? 1920 : (twitchOnly ? 1440 : 1080); + leg.experimentalTwitch1440p = twitchOnly && !portrait && leg.outputKind == "standard" && leg.limits.maxWidth >= 2560 && leg.limits.maxHeight >= 1440; + // Destination policy is session-wide: a Twitch canvas does not get a higher + // ceiling when a different platform is sent from the other canvas. + leg.limits.maxWidth = leg.limits.maxWidth > 0 ? std::min(leg.limits.maxWidth, maxWidth) : std::min(leg.current.width, maxWidth); + leg.limits.maxHeight = leg.limits.maxHeight > 0 ? std::min(leg.limits.maxHeight, maxHeight) : std::min(leg.current.height, maxHeight); + if (leg.additionalVideo) { + auto &limits = leg.additionalVideo->limits; + limits.maxWidth = limits.maxWidth > 0 ? std::min(limits.maxWidth, 1080) : std::min(leg.additionalVideo->current.width, 1080); + limits.maxHeight = limits.maxHeight > 0 ? std::min(limits.maxHeight, 1920) : std::min(leg.additionalVideo->current.height, 1920); + } + // Cap each ordinary output, not the bandwidth probe's combined upload + // target or the multi-track encoding ladder selected by Twitch. + int strictest = multiplatform && leg.outputKind == "standard" ? qualityPolicy::kMultistreamVideoBitrateLimitKbps : 0; for (const auto &destination : leg.destinations) { - const int cap = offlinePlatformCapKbps(destination.platform); + const int cap = leg.experimentalTwitch1440p ? qualityPolicy::kExperimentalTwitch1440pBitrateKbps : offlinePlatformCapKbps(destination.platform); if (cap > 0 && (strictest == 0 || cap < strictest)) strictest = cap; } @@ -1158,6 +1175,8 @@ static CurrentSettings estimateRecommendation(const LegRequest &leg, const Hardw static CurrentSettings benchmarkCeiling(const LegRequest &leg) { CurrentSettings value = baseRecommendation(leg); + if (leg.experimentalTwitch1440p) + value.bitrateKbps = leg.limits.maxBitrateKbps; const auto ceiling = qualityPolicy::benchmarkCeiling({value.width, value.height, value.fpsNum, value.fpsDen}, leg.limits.maxWidth, leg.limits.maxHeight, leg.limits.maxFpsNum, leg.limits.maxFpsDen); value.width = ceiling.width; @@ -1270,6 +1289,8 @@ static std::string serializeResult(const Session &session, const char *status, c obs_data_set_string(measurement, "confidence", recommendation.confidence.c_str()); if (!recommendation.reason.empty()) obs_data_set_string(measurement, "reason", recommendation.reason.c_str()); + if (recommendation.configurationWarning) + obs_data_set_bool(measurement, "configurationWarning", true); if (!recommendation.probes.empty()) { obs_data_array_t *probes = obs_data_array_create(); for (const auto &provenance : recommendation.probes) { @@ -1379,6 +1400,7 @@ struct ProbeResult { bool pairedCadenceEvidence = false; std::vector companionWorkloads; enhancedBroadcastingPolicy::CandidateFallbackEvidence candidateFallback; + bool configurationWarning = false; }; static bool silentAudioCallback(void *, uint64_t startTimestamp, uint64_t, uint64_t *outputTimestamp, uint32_t, struct audio_data_mixes_outputs *) @@ -3128,7 +3150,8 @@ static bool validateEnhancedBroadcastingConfig(const osn::Config &config, const std::vector candidateCovered(candidates.size(), false); for (const auto &video : config.encoder_configurations) { const char *codec = obs_get_encoder_codec(video.type.c_str()); - if (!enhancedBroadcastingPolicy::canvasIndexIsValid(video.canvas_index, candidates.size()) || !codec || asciiLowerCopy(codec) != "h264") { + if (!enhancedBroadcastingPolicy::canvasIndexIsValid(video.canvas_index, candidates.size()) || !codec || + (asciiLowerCopy(codec) != "h264" && asciiLowerCopy(codec) != "hevc")) { errorCode = "enhanced_broadcasting_unsupported_video_ladder"; return false; } @@ -3710,8 +3733,8 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr & result.errorCode = "enhanced_broadcasting_video_unavailable"; return result; } - const uint32_t maxWidth = leg.limits.maxWidth > 0 ? (uint32_t)std::min(1920, leg.limits.maxWidth) : 1920U; - const uint32_t maxHeight = leg.limits.maxHeight > 0 ? (uint32_t)std::min(1080, leg.limits.maxHeight) : 1080U; + const uint32_t maxWidth = leg.limits.maxWidth > 0 ? (uint32_t)std::min(2560, leg.limits.maxWidth) : 1920U; + const uint32_t maxHeight = leg.limits.maxHeight > 0 ? (uint32_t)std::min(1440, leg.limits.maxHeight) : 1080U; const uint32_t maxFpsNum = leg.limits.maxFpsNum > 0 ? (uint32_t)leg.limits.maxFpsNum : 0U; const uint32_t maxFpsDen = leg.limits.maxFpsNum > 0 ? (uint32_t)std::max(1, leg.limits.maxFpsDen) : 0U; const bool fractionalCadenceFamily = @@ -3752,9 +3775,10 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr & CurrentSettings eventAdditionalVideo; const CurrentSettings *eventAdditionalVideoPtr = nullptr; if (leg.additionalVideo) { + const auto vertical = enhancedBroadcastingPolicy::pairedVerticalCandidate(candidate); eventAdditionalVideo = leg.additionalVideo->current; - eventAdditionalVideo.width = (int)candidate.height; - eventAdditionalVideo.height = (int)candidate.width; + eventAdditionalVideo.width = (int)vertical.width; + eventAdditionalVideo.height = (int)vertical.height; eventAdditionalVideo.fpsNum = (int)candidate.fpsNum; eventAdditionalVideo.fpsDen = (int)candidate.fpsDen; eventAdditionalVideoPtr = &eventAdditionalVideo; @@ -3800,19 +3824,31 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr & osn::Config config; try { auto post = osn::constructGoLivePost(requestCanvasPointers, normalizedKey, std::nullopt, std::nullopt, false); - post.client.supported_codecs.clear(); - post.client.supported_codecs.emplace("h264"); + enhancedBroadcastingPolicy::filterProbeCodecs(post.client.supported_codecs); config = osn::DownloadGoLiveConfig(goLiveConfigUrl, post); } catch (const std::exception &exception) { blog(LOG_WARNING, "[Auto Optimizer][Enhanced Broadcasting] Ladder request failed for %ux%u %u/%u FPS: %s", candidate.width, candidate.height, candidate.fpsNum, candidate.fpsDen, boundedLogValue(exception.what()).c_str()); result.errorCode = "enhanced_broadcasting_config_request_failed"; + // A channel may not have a 2K ladder available. Give the existing + // 1080p path one chance instead of making 1440p support mandatory. + if (candidate.width > 1920) { + result.candidateFallback.record(result.errorCode); + pushEvent(session, "progress", "bandwidth", candidateEnd, "enhanced_broadcasting_candidate_unavailable", probe.legId, "active", + probe.probeId, probe.provider, 0, &eventVideo, 0, 0, eventAdditionalVideoPtr); + while (index + 1 < candidates.size() && candidates[index + 1].width > 1920) + ++index; + continue; + } return result; } if (!validateEnhancedBroadcastingConfig(config, candidateCanvases, result.errorCode)) { if (!enhancedBroadcastingPolicy::allowsCandidateDescent(result.errorCode)) return result; - pushEvent(session, "progress", "bandwidth", candidateEnd, "enhanced_broadcasting_candidate_rejected", probe.legId, "active", + // Twitch did not supply the requested rendition. No workload has run, + // so do not report this as measured encoder or network overload. + result.candidateFallback.record(result.errorCode); + pushEvent(session, "progress", "bandwidth", candidateEnd, "enhanced_broadcasting_candidate_unavailable", probe.legId, "active", probe.probeId, probe.provider, 0, &eventVideo, 0, 0, eventAdditionalVideoPtr); continue; } @@ -3884,6 +3920,9 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr & result.success = true; result.errorCode.clear(); + // Keep warnings for the selected configuration, not discarded candidates. + // The UI receives a localized warning code, never Twitch's HTML response. + result.configurationWarning = config.status && config.status->result == osn::StatusResult::Warning; result.testedWidth = candidate.width; result.testedHeight = candidate.height; result.testedFpsNum = candidate.fpsNum; @@ -3943,12 +3982,16 @@ static ProbeResult runRtmpProbe(const std::shared_ptr &session, ProbeRe const int maximumBitrate = probe.provider == "youtube" ? probePolicy::youtubeProbeMaximumBitrateKbps(session->standardDualOutputWorkload) : kProbeMaximumBitrateKbps; - const int requested = probe.provider == "youtube" ? kYoutubeProbeInitialBitrateKbps - : std::clamp(std::max(leg.current.bitrateKbps, 6000), 500, maximumBitrate); + const int requested = + probe.provider == "youtube" + ? kYoutubeProbeInitialBitrateKbps + : std::clamp(std::max(leg.current.bitrateKbps, leg.experimentalTwitch1440p ? qualityPolicy::kExperimentalTwitch1440pBitrateKbps : 6000), + 500, maximumBitrate); obs_data_t *platformProbe = obs_data_create(); obs_data_set_int(platformProbe, "bitrate", maximumBitrate); obs_service_apply_encoder_settings(resources.service, platformProbe, nullptr); - const int platformReturned = (int)obs_data_get_int(platformProbe, "bitrate"); + const int platformReturned = leg.experimentalTwitch1440p ? qualityPolicy::kExperimentalTwitch1440pBitrateKbps + : (int)obs_data_get_int(platformProbe, "bitrate"); if (platformReturned > 0 && platformReturned < maximumBitrate) result.platformCapKbps = platformReturned; obs_data_release(platformProbe); @@ -3964,6 +4007,10 @@ static ProbeResult runRtmpProbe(const std::shared_ptr &session, ProbeRe obs_data_set_string(encoderSettings, "preset", "veryfast"); obs_data_set_int(encoderSettings, "keyint_sec", 2); obs_service_apply_encoder_settings(resources.service, encoderSettings, nullptr); + // Override only the bitrate for the opt-in Twitch-only 1440p experiment; + // retain the service's keyframe and other encoder constraints. + if (leg.experimentalTwitch1440p) + obs_data_set_int(encoderSettings, "bitrate", initialBitrate); initialBitrate = (int)obs_data_get_int(encoderSettings, "bitrate"); const uint32_t width = probe.provider == "youtube" ? 640 : 128; @@ -4592,9 +4639,15 @@ static void runSession(const std::shared_ptr &session) } std::vector preparedLegs; + std::vector platforms; + for (const auto &leg : session->legs) + for (const auto &destination : leg.destinations) + platforms.push_back(destination.platform); + const bool twitchOnly = qualityPolicy::isTwitchOnlyStream(platforms); + const bool multiplatform = qualityPolicy::isMultiplatformStream(platforms); preparedLegs.reserve(session->legs.size()); for (size_t index = 0; index < session->legs.size(); index++) { - preparedLegs.push_back(withOfflinePlatformCaps(session->legs[index])); + preparedLegs.push_back(withOfflinePlatformCaps(session->legs[index], twitchOnly, multiplatform)); } std::vector hardwareAssessments(preparedLegs.size()); std::vector automaticLegs; @@ -4925,6 +4978,7 @@ static void runSession(const std::shared_ptr &session) recommendation.measurementMode = "active"; recommendation.confidence = tested.pairedCadenceEvidence ? "medium" : "high"; recommendation.reason = tested.candidateFallback.reason(); + recommendation.configurationWarning = tested.configurationWarning; recommendation.value.width = (int)tested.testedWidth; recommendation.value.height = (int)tested.testedHeight; recommendation.value.fpsNum = (int)tested.testedFpsNum; diff --git a/obs-studio-server/source/osn-enhanced-broadcasting-advanced-streaming.cpp b/obs-studio-server/source/osn-enhanced-broadcasting-advanced-streaming.cpp index 9fce6ef36..b177c5d22 100644 --- a/obs-studio-server/source/osn-enhanced-broadcasting-advanced-streaming.cpp +++ b/obs-studio-server/source/osn-enhanced-broadcasting-advanced-streaming.cpp @@ -66,6 +66,7 @@ void osn::IEnhancedBroadcastingAdvancedStreaming::Register(ipc::server &srv) cls->register_function(std::make_shared("Query", std::vector{ipc::type::UInt64}, Query)); cls->register_function(std::make_shared("GetLegacySettings", std::vector{}, GetLegacySettings)); cls->register_function(std::make_shared("SetLegacySettings", std::vector{ipc::type::UInt64}, SetLegacySettings)); + cls->register_function(std::make_shared("GetAvailableEncoders", std::vector{ipc::type::UInt64}, GetAvailableEncoders)); cls->register_function(std::make_shared("GetDroppedFrames", std::vector{ipc::type::UInt64}, GetDroppedFrames)); cls->register_function(std::make_shared("GetTotalFrames", std::vector{ipc::type::UInt64}, GetTotalFrames)); cls->register_function(std::make_shared("GetKBitsPerSec", std::vector{ipc::type::UInt64}, GetKBitsPerSec)); diff --git a/obs-studio-server/source/osn-enhanced-broadcasting-simple-streaming.cpp b/obs-studio-server/source/osn-enhanced-broadcasting-simple-streaming.cpp index cc6f3a202..768216c16 100644 --- a/obs-studio-server/source/osn-enhanced-broadcasting-simple-streaming.cpp +++ b/obs-studio-server/source/osn-enhanced-broadcasting-simple-streaming.cpp @@ -63,6 +63,7 @@ void osn::IEnhancedBroadcastingSimpleStreaming::Register(ipc::server &srv) cls->register_function(std::make_shared("Query", std::vector{ipc::type::UInt64}, Query)); cls->register_function(std::make_shared("GetLegacySettings", std::vector{}, GetLegacySettings)); cls->register_function(std::make_shared("SetLegacySettings", std::vector{ipc::type::UInt64}, SetLegacySettings)); + cls->register_function(std::make_shared("GetAvailableEncoders", std::vector{ipc::type::UInt64}, GetAvailableEncoders)); cls->register_function(std::make_shared("GetDroppedFrames", std::vector{ipc::type::UInt64}, GetDroppedFrames)); cls->register_function(std::make_shared("GetTotalFrames", std::vector{ipc::type::UInt64}, GetTotalFrames)); cls->register_function(std::make_shared("GetKBitsPerSec", std::vector{ipc::type::UInt64}, GetKBitsPerSec)); diff --git a/source/auto-optimizer-enhanced-broadcasting-policy.hpp b/source/auto-optimizer-enhanced-broadcasting-policy.hpp index c3174397c..1ac8bc5a0 100644 --- a/source/auto-optimizer-enhanced-broadcasting-policy.hpp +++ b/source/auto-optimizer-enhanced-broadcasting-policy.hpp @@ -3,13 +3,23 @@ #include #include #include +#include #include +#include #include #include "osn-common.hpp" namespace autoOptimizer::enhancedBroadcastingPolicy { +inline void filterProbeCodecs(std::unordered_set &codecs) +{ + // Preserve discovered HEVC support at every resolution: Twitch can select it + // for 1080p too, especially for Dual Format's vertical rendition. Twitch names + // HEVC "h265"; AV1 remains excluded from probes. + std::erase_if(codecs, [](const auto &codec) { return codec != "h264" && codec != "h265"; }); +} + struct VideoCandidate { uint32_t width; uint32_t height; @@ -27,6 +37,9 @@ inline bool fpsAtMost(const VideoCandidate &candidate, uint32_t maxNum, uint32_t inline VideoCandidate pairedVerticalCandidate(const VideoCandidate &primary) { + // Twitch's 2K Dual Format ladder pairs 1440p horizontal with 1080p vertical. + if (primary.width == 2560 && primary.height == 1440) + return {1080, 1920, primary.fpsNum, primary.fpsDen}; return {primary.height, primary.width, primary.fpsNum, primary.fpsDen}; } @@ -87,8 +100,8 @@ inline std::vector candidates(uint32_t maxWidth, uint32_t maxHei const uint32_t thirty = fractionalCadence ? 30000U : 30U; const uint32_t denominator = fractionalCadence ? 1001U : 1U; const VideoCandidate ordered[] = { - {1920, 1080, sixty, denominator}, {1920, 1080, thirty, denominator}, {1280, 720, sixty, denominator}, - {1280, 720, thirty, denominator}, {960, 540, thirty, denominator}, + {2560, 1440, sixty, denominator}, {2560, 1440, thirty, denominator}, {1920, 1080, sixty, denominator}, {1920, 1080, thirty, denominator}, + {1280, 720, sixty, denominator}, {1280, 720, thirty, denominator}, {960, 540, thirty, denominator}, }; std::vector result; @@ -157,12 +170,14 @@ inline bool allowsCandidateDescent(std::string_view errorCode) struct CandidateFallbackEvidence { bool transportPressure = false; bool workloadPressure = false; + bool configurationLimited = false; void record(std::string_view errorCode) { transportPressure |= errorCode == "enhanced_broadcasting_transport_pressure"; workloadPressure |= errorCode == "enhanced_broadcasting_encoder_underload" || errorCode == "enhanced_broadcasting_render_overload" || errorCode == "enhanced_broadcasting_companion_overload"; + configurationLimited |= errorCode == "enhanced_broadcasting_ladder_below_candidate" || errorCode == "enhanced_broadcasting_config_request_failed"; } std::string_view reason() const @@ -173,6 +188,8 @@ struct CandidateFallbackEvidence { return "enhanced_broadcasting_transport_fallback"; if (workloadPressure) return "enhanced_broadcasting_workload_fallback"; + if (configurationLimited) + return "enhanced_broadcasting_configuration_fallback"; return {}; } }; diff --git a/source/auto-optimizer-quality-policy.hpp b/source/auto-optimizer-quality-policy.hpp index 9085c46fe..0302ab81e 100644 --- a/source/auto-optimizer-quality-policy.hpp +++ b/source/auto-optimizer-quality-policy.hpp @@ -24,6 +24,18 @@ inline constexpr size_t kMaximumUploadLegs = 2; inline constexpr size_t kMaximumEnhancedBroadcastingDualOutputLegs = 3; inline constexpr int kDefaultEstimatedBitrateKbps = 2500; inline constexpr int kMaximumRecommendedBitrateKbps = 8000; +inline constexpr int kExperimentalTwitch1440pBitrateKbps = 8000; +inline constexpr int kMultistreamVideoBitrateLimitKbps = 6000; + +inline bool isTwitchOnlyStream(const std::vector &platforms) +{ + return !platforms.empty() && std::all_of(platforms.begin(), platforms.end(), [](const auto &platform) { return platform == "twitch"; }); +} + +inline bool isMultiplatformStream(const std::vector &platforms) +{ + return !platforms.empty() && std::any_of(platforms.begin(), platforms.end(), [&](const auto &platform) { return platform != platforms.front(); }); +} /** Accept only effective frame rates from 1 through 240 FPS. */ inline bool isValidFrameRate(int64_t numerator, int64_t denominator) @@ -247,9 +259,11 @@ inline void applySharedMinimumCadence(VideoTuple &first, VideoTuple &second) inline const std::vector &hardwareTiers() { static const std::vector tiers = { + {2560, 1440, false}, // 1440 high FPS (only when permitted by the request) {1920, 1080, false}, // 1080 high FPS {1280, 720, false}, // 720 high FPS {1920, 1080, true}, // 1080 low FPS + {2560, 1440, true}, // 1440 low FPS {960, 540, false}, // 540 high FPS {1280, 720, true}, // 720 low FPS {960, 540, true}, // 540 low FPS @@ -311,7 +325,7 @@ inline bool hasSupportedTierAspectRatio(const VideoTuple &value) inline VideoTuple fitTier(const VideoTuple &ceiling, int longEdge, int shortEdge, bool lowerFps) { VideoTuple result = ceiling; - // The optimizer supports only the 1920x1080, 1280x720, and 960x540 tiers, + // The optimizer supports 2560x1440, 1920x1080, 1280x720, and 960x540 tiers, // and their portrait equivalents. Preserve custom-aspect geometry and frame // rate instead of constructing a resolution Desktop cannot apply as a // supported tier. @@ -358,7 +372,7 @@ inline VideoTuple boundCurrentToSupportedTier(const VideoTuple ¤t, int max if (current.width <= maxWidth && current.height <= maxHeight) return current; - const int tiers[][2] = {{1920, 1080}, {1280, 720}, {960, 540}}; + const int tiers[][2] = {{2560, 1440}, {1920, 1080}, {1280, 720}, {960, 540}}; for (const auto &tier : tiers) { VideoTuple candidate = fitTier(current, tier[0], tier[1], false); if (candidate.width <= maxWidth && candidate.height <= maxHeight) { @@ -386,7 +400,7 @@ inline VideoTuple benchmarkCeiling(const VideoTuple ¤t, int maxWidth, int VideoTuple result = bounded; const bool landscape = current.width >= current.height; if (sameVideo(bounded, current)) { - const int tiers[][2] = {{1920, 1080}, {1280, 720}, {960, 540}}; + const int tiers[][2] = {{2560, 1440}, {1920, 1080}, {1280, 720}, {960, 540}}; for (const auto &tier : tiers) { const int width = landscape ? tier[0] : tier[1]; const int height = landscape ? tier[1] : tier[0]; @@ -446,7 +460,7 @@ inline bool isQualityPromotion(const VideoTuple ¤t, const VideoTuple &sele inline std::vector candidates(const VideoTuple &ceiling) { std::vector result; - const int tiers[][2] = {{1920, 1080}, {1280, 720}, {960, 540}}; + const int tiers[][2] = {{2560, 1440}, {1920, 1080}, {1280, 720}, {960, 540}}; for (const auto &tier : tiers) { for (bool lowerFps : {false, true}) { VideoTuple candidate = fitTier(ceiling, tier[0], tier[1], lowerFps); @@ -475,6 +489,10 @@ inline int twitchMinimumBitrateKbps(const VideoTuple &video) const int longEdge = std::max(video.width, video.height); const int shortEdge = std::min(video.width, video.height); const bool highFps = fpsGreaterThan(video, 30); + // Experimental ordinary-Twitch 1440p policy, gated by Twitch-only request + // limits and a successful 8 Mbps probe. Do not raise multistream bitrates. + if (longEdge == 2560 && shortEdge == 1440) + return kExperimentalTwitch1440pBitrateKbps; if (longEdge == 1920 && shortEdge == 1080) return highFps ? 5500 : 5000; if (longEdge == 1280 && shortEdge == 720) diff --git a/tests/osn-tests/src/test_osn_auto_optimizer.ts b/tests/osn-tests/src/test_osn_auto_optimizer.ts index 599ae426f..3662a3e01 100644 --- a/tests/osn-tests/src/test_osn_auto_optimizer.ts +++ b/tests/osn-tests/src/test_osn_auto_optimizer.ts @@ -114,12 +114,15 @@ describe(testName, function() { }; } - async function startSessionAtHardwareAttempt(): Promise { + async function startSessionAtHardwareAttempt(request: IAutoOptimizerRequest = { + streamSetup: 'custom-rtmp', outputs: [output()], + }, observe?: (event: IAutoOptimizerEvent) => void): Promise { let nativeRun: AutoOptimizerRun | null = null; let timeout: ReturnType; const hardwareAttemptStarted = new Promise((resolve, reject) => { timeout = setTimeout(() => reject(new Error('Timed out waiting for the Auto Optimizer benchmark workload')), 15000); const onEvent = (event: IAutoOptimizerEvent) => { + if (observe) observe(event); if (event.code === 'hardware_testing_encoder' || event.code === 'hardware_testing_encoder_surfaces' || event.code === 'hardware_testing_x264') { clearTimeout(timeout); @@ -131,10 +134,7 @@ describe(testName, function() { }; nativeRun = autoOptimizer.run( - { - streamSetup: 'custom-rtmp', - outputs: [output()], - } as IAutoOptimizerRequest, + request, onEvent, ); }); @@ -189,6 +189,38 @@ describe(testName, function() { } } + it('caps ordinary multistream bitrate without reducing a single-platform allowance', async function() { + const selections: AutoOptimizerOutputRequest['destinations'][] = [['youtube', 'kick'], ['youtube']]; + for (const destinations of selections) { + const { result } = await run({ + streamSetup: destinations.length > 1 ? 'cloud-multistream' : 'direct-single', + outputs: [output({ + destinations, + current: { ...output().current, bitrateKbps: 8000 }, + limits: { maxBitrateKbps: 8000 }, + })], + }); + expect(result.status).to.equal('complete'); + expect(result.outputs[0].encoding!.bitrateKbps).to.equal(destinations.length > 1 ? 6000 : 8000); + } + }); + + it('tests 1440p only for Twitch-only requests, even when another platform shares its canvas', async function() { + for (const destinations of [['twitch'], ['twitch', 'kick'], ['youtube']]) { + const events: IAutoOptimizerEvent[] = []; + const nativeRun = await startSessionAtHardwareAttempt({ + streamSetup: destinations.length > 1 ? 'cloud-multistream' : 'direct-single', + outputs: [output({ destinations: destinations as AutoOptimizerOutputRequest['destinations'], + limits: { maxWidth: 2560, maxHeight: 1440, maxBitrateKbps: 8000 } })], + }, event => events.push(event)); + await nativeRun.cancel(); + expect((await nativeRun.result).status).to.equal('cancelled'); + const attempted = events.filter(event => event.width && event.height); + expect(attempted.length).to.be.greaterThan(0); + expect(Math.max(...attempted.map(event => event.width!))).to.equal(destinations.length === 1 && destinations[0] === 'twitch' ? 2560 : 1920); + } + }); + it('exposes only the run-based Auto Optimizer API', function() { expect(autoOptimizer.run).to.be.a('function'); expect(Object.keys(autoOptimizer)).to.deep.equal(['run']); diff --git a/tests/osn-tests/src/test_osn_get_available_encoders.ts b/tests/osn-tests/src/test_osn_get_available_encoders.ts index 4f5915872..03e2fcac4 100644 --- a/tests/osn-tests/src/test_osn_get_available_encoders.ts +++ b/tests/osn-tests/src/test_osn_get_available_encoders.ts @@ -93,6 +93,56 @@ function expectEncoderMetadata(encoder: any) { "Encoder codec should not be empty"); } +describe(`${testName} (Enhanced Broadcasting local)`, function () { + let obs: OBSHandler; + + before(function () { + deleteConfigFiles(); + obs = new OBSHandler(`${testName}-enhanced-local`); + }); + + after(function () { + if (obs) obs.shutdown(); + deleteConfigFiles(); + }); + + function testEnhancedEncoders( + mode: string, + factory: { create(): T, destroy(stream: T): void }, + preset: string, + ) { + it(`Queries ${mode} Enhanced Broadcasting encoder metadata without starting a stream`, function () { + const stream = factory.create(); + let service: osn.IService; + try { + // Exercise the IPC contract both before and after assigning Twitch. + // No provider account, configuration request or output start is needed. + for (const withService of [false, true]) { + if (withService) { + service = osn.ServiceFactory.create('rtmp_common', 'encoder-query-local', { + service: 'Twitch', server: 'rtmp://127.0.0.1/live', key: 'unused', + }); + stream.service = service; + } + const encoders = stream.getAvailableEncoders(); + expect(encoders).to.be.an('array').and.not.be.empty; + encoders.forEach(expectEncoderMetadata); + const x264 = encoders.find(encoder => encoder.id === 'obs_x264'); + expect(x264, 'Software encoding metadata must be available').to.include({ + id: 'obs_x264', family: 'x264', codec: 'h264', preset, streaming: true, + }); + } + } finally { + factory.destroy(stream); + if (service) osn.ServiceFactory.destroy(service); + } + }); + } + + testEnhancedEncoders('Simple', osn.EnhancedBroadcastingSimpleStreamingFactory, 'Preset'); + testEnhancedEncoders('Advanced', osn.EnhancedBroadcastingAdvancedStreamingFactory, 'preset'); +}); + describe(testName, () => { let obs: OBSHandler; let hasTestFailed: boolean = false; From 33d611667c102a27005c809e67756096bab6a04b Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Tue, 6 Oct 2026 14:03:47 +1300 Subject: [PATCH 2/5] Fix clang-format for Twitch 1440p Auto Optimizer changes --- .../tests/auto-optimizer-client-contract-test.cpp | 5 ++--- source/auto-optimizer-enhanced-broadcasting-policy.hpp | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp b/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp index 9c0118aaa..85ba37685 100644 --- a/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp +++ b/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp @@ -606,9 +606,8 @@ TEST_CASE("Auto Optimizer client requires exact proof for a 1440p horizontal and TEST_CASE("Auto Optimizer client preserves measured Enhanced Broadcasting fallback explanations") { - for (const auto reason : - {"enhanced_broadcasting_transport_fallback", "enhanced_broadcasting_workload_fallback", "enhanced_broadcasting_transport_and_workload_fallback", - "enhanced_broadcasting_configuration_fallback"}) { + for (const auto reason : {"enhanced_broadcasting_transport_fallback", "enhanced_broadcasting_workload_fallback", + "enhanced_broadcasting_transport_and_workload_fallback", "enhanced_broadcasting_configuration_fallback"}) { CAPTURE(reason); auto fixture = enhancedBroadcastingFixture(); fixture.result["legs"][0]["measurement"]["reason"] = reason; diff --git a/source/auto-optimizer-enhanced-broadcasting-policy.hpp b/source/auto-optimizer-enhanced-broadcasting-policy.hpp index 1ac8bc5a0..abc253fc5 100644 --- a/source/auto-optimizer-enhanced-broadcasting-policy.hpp +++ b/source/auto-optimizer-enhanced-broadcasting-policy.hpp @@ -177,7 +177,8 @@ struct CandidateFallbackEvidence { transportPressure |= errorCode == "enhanced_broadcasting_transport_pressure"; workloadPressure |= errorCode == "enhanced_broadcasting_encoder_underload" || errorCode == "enhanced_broadcasting_render_overload" || errorCode == "enhanced_broadcasting_companion_overload"; - configurationLimited |= errorCode == "enhanced_broadcasting_ladder_below_candidate" || errorCode == "enhanced_broadcasting_config_request_failed"; + configurationLimited |= errorCode == "enhanced_broadcasting_ladder_below_candidate" || + errorCode == "enhanced_broadcasting_config_request_failed"; } std::string_view reason() const From 185a9ae5311720ac59463863ef4162a56f65d91f Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Tue, 6 Oct 2026 15:10:48 +1300 Subject: [PATCH 3/5] Make Auto Optimizer bitrate policy tests hardware-independent Extract the ordinary multistream bitrate cap into a shared policy helper and test it without requiring a real hardware benchmark to succeed. Preserve stricter limits, single-platform allowances, and Twitch-managed ladders, and retain the existing startup and cancellation integration coverage. --- .../auto-optimizer-quality-policy-test.cpp | 21 +++++++++++++++++++ .../source/nodeobs_auto_optimizer.cpp | 5 ++--- source/auto-optimizer-quality-policy.hpp | 9 ++++++++ .../osn-tests/src/test_osn_auto_optimizer.ts | 16 -------------- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp b/obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp index 8f6d40af6..daeea4519 100644 --- a/obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp +++ b/obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp @@ -18,6 +18,27 @@ TEST_CASE("Multistream bitrate policy counts platforms rather than canvases") CHECK(policy::kMultistreamVideoBitrateLimitKbps == 6000); } +TEST_CASE("Multistream bitrate limits preserve single-platform allowances and Twitch-managed ladders") +{ + // This is a policy assertion, not a requirement that the CI machine pass a + // real encoder benchmark. Startup and cancellation remain integration-tested. + for (const auto &platforms : std::vector>{{"youtube", "kick"}, {"twitch", "youtube"}, {"youtube"}, {"twitch", "twitch"}}) { + CAPTURE(platforms); + const bool multiplatform = policy::isMultiplatformStream(platforms); + const int limit = policy::applyMultistreamBitrateLimitKbps(8000, multiplatform, true); + CHECK(limit == (multiplatform ? 6000 : 8000)); + CHECK(policy::composeEstimatedBitrateKbps(8000, limit) == limit); + CHECK(policy::applyMultistreamBitrateLimitKbps(0, multiplatform, true) == (multiplatform ? 6000 : 0)); + CHECK(policy::applyMultistreamBitrateLimitKbps(8000, multiplatform, false) == 8000); + CHECK(policy::applyMultistreamBitrateLimitKbps(0, multiplatform, false) == 0); + for (int stricterLimit : {2500, 4500, 6000}) { + CAPTURE(stricterLimit); + CHECK(policy::applyMultistreamBitrateLimitKbps(stricterLimit, multiplatform, true) == stricterLimit); + CHECK(policy::applyMultistreamBitrateLimitKbps(stricterLimit, multiplatform, false) == stricterLimit); + } + } +} + TEST_CASE("Auto Optimizer accepts only effective frame rates from 1 through 240 FPS") { CHECK_FALSE(policy::isValidFrameRate(0, 1)); diff --git a/obs-studio-server/source/nodeobs_auto_optimizer.cpp b/obs-studio-server/source/nodeobs_auto_optimizer.cpp index a20d0eff9..ba8073a83 100644 --- a/obs-studio-server/source/nodeobs_auto_optimizer.cpp +++ b/obs-studio-server/source/nodeobs_auto_optimizer.cpp @@ -1083,9 +1083,7 @@ static LegRequest withOfflinePlatformCaps(const LegRequest &input, bool twitchOn limits.maxWidth = limits.maxWidth > 0 ? std::min(limits.maxWidth, 1080) : std::min(leg.additionalVideo->current.width, 1080); limits.maxHeight = limits.maxHeight > 0 ? std::min(limits.maxHeight, 1920) : std::min(leg.additionalVideo->current.height, 1920); } - // Cap each ordinary output, not the bandwidth probe's combined upload - // target or the multi-track encoding ladder selected by Twitch. - int strictest = multiplatform && leg.outputKind == "standard" ? qualityPolicy::kMultistreamVideoBitrateLimitKbps : 0; + int strictest = 0; for (const auto &destination : leg.destinations) { const int cap = leg.experimentalTwitch1440p ? qualityPolicy::kExperimentalTwitch1440pBitrateKbps : offlinePlatformCapKbps(destination.platform); if (cap > 0 && (strictest == 0 || cap < strictest)) @@ -1093,6 +1091,7 @@ static LegRequest withOfflinePlatformCaps(const LegRequest &input, bool twitchOn } if (strictest > 0 && (leg.limits.maxBitrateKbps == 0 || strictest < leg.limits.maxBitrateKbps)) leg.limits.maxBitrateKbps = strictest; + leg.limits.maxBitrateKbps = qualityPolicy::applyMultistreamBitrateLimitKbps(leg.limits.maxBitrateKbps, multiplatform, leg.outputKind == "standard"); return leg; } diff --git a/source/auto-optimizer-quality-policy.hpp b/source/auto-optimizer-quality-policy.hpp index 0302ab81e..be14c5783 100644 --- a/source/auto-optimizer-quality-policy.hpp +++ b/source/auto-optimizer-quality-policy.hpp @@ -37,6 +37,15 @@ inline bool isMultiplatformStream(const std::vector &platforms) return !platforms.empty() && std::any_of(platforms.begin(), platforms.end(), [&](const auto &platform) { return platform != platforms.front(); }); } +// Limit each ordinary output, not aggregate upload probes or Twitch's managed +// ladder. Zero means no limit was supplied; preserve any stricter limit. +inline int applyMultistreamBitrateLimitKbps(int maximumBitrateKbps, bool multiplatform, bool standardOutput) +{ + if (multiplatform && standardOutput && (maximumBitrateKbps == 0 || maximumBitrateKbps > kMultistreamVideoBitrateLimitKbps)) + return kMultistreamVideoBitrateLimitKbps; + return maximumBitrateKbps; +} + /** Accept only effective frame rates from 1 through 240 FPS. */ inline bool isValidFrameRate(int64_t numerator, int64_t denominator) { diff --git a/tests/osn-tests/src/test_osn_auto_optimizer.ts b/tests/osn-tests/src/test_osn_auto_optimizer.ts index 3662a3e01..92e3497ed 100644 --- a/tests/osn-tests/src/test_osn_auto_optimizer.ts +++ b/tests/osn-tests/src/test_osn_auto_optimizer.ts @@ -189,22 +189,6 @@ describe(testName, function() { } } - it('caps ordinary multistream bitrate without reducing a single-platform allowance', async function() { - const selections: AutoOptimizerOutputRequest['destinations'][] = [['youtube', 'kick'], ['youtube']]; - for (const destinations of selections) { - const { result } = await run({ - streamSetup: destinations.length > 1 ? 'cloud-multistream' : 'direct-single', - outputs: [output({ - destinations, - current: { ...output().current, bitrateKbps: 8000 }, - limits: { maxBitrateKbps: 8000 }, - })], - }); - expect(result.status).to.equal('complete'); - expect(result.outputs[0].encoding!.bitrateKbps).to.equal(destinations.length > 1 ? 6000 : 8000); - } - }); - it('tests 1440p only for Twitch-only requests, even when another platform shares its canvas', async function() { for (const destinations of [['twitch'], ['twitch', 'kick'], ['youtube']]) { const events: IAutoOptimizerEvent[] = []; From b7c0cb14e51dc236351a6a168bfd154f3a35a2d5 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Tue, 6 Oct 2026 16:33:40 +1300 Subject: [PATCH 4/5] Preserve saved Dual Format settings in Auto Optimizer estimates Apply Twitch's paired-resolution rule only to measured recommendations, while preserving saved vertical tuples and request limits for estimates. Cover absent and failed probes, retain measured-pair validation tests, and exercise 1440p fallback through the public API. Clarify the estimate contract in the API comments. --- js/module.ts | 5 +- .../source/auto-optimizer-client-contract.cpp | 17 ++-- .../auto-optimizer-client-contract-test.cpp | 51 ++++++++++++ .../osn-tests/src/test_osn_auto_optimizer.ts | 83 +++++++++++-------- 4 files changed, 116 insertions(+), 40 deletions(-) diff --git a/js/module.ts b/js/module.ts index abe326ee1..224aed917 100644 --- a/js/module.ts +++ b/js/module.ts @@ -2167,7 +2167,7 @@ interface IAutoOptimizerLimits { * caller remains responsible for applying a recommended Base Canvas resize * safely. * Horizontal 1440p testing is available only when every destination in the - * request is Twitch. Other streams are limited to 1080p. Vertical output + * request is Twitch. Other streams are limited to 1080p. Vertical testing * remains limited to 1080x1920, including Twitch Enhanced Broadcasting's * paired 1440p horizontal / 1080p vertical workload. Ordinary Twitch 1440p * requests use an experimental 8000 Kbps limit instead of the service's @@ -2209,6 +2209,9 @@ interface IAutoOptimizerOutputRequest { * Broadcasting upload as `current`. Valid only with `display: 'both'`. OSN * validates the paired ladder only for one Twitch destination, one Enhanced * Broadcasting probe, and two distinct registered canvas IDs. + * Without a successful paired probe, an estimated result preserves this + * video's current resolution and frame rate; it does not validate that + * the saved pair can be streamed. */ additionalVideo?: IAutoOptimizerAdditionalVideoRequest; estimateReason?: AutoOptimizerEstimateReason; diff --git a/obs-studio-client/source/auto-optimizer-client-contract.cpp b/obs-studio-client/source/auto-optimizer-client-contract.cpp index 172134309..6d0975d67 100644 --- a/obs-studio-client/source/auto-optimizer-client-contract.cpp +++ b/obs-studio-client/source/auto-optimizer-client-contract.cpp @@ -662,13 +662,18 @@ bool parseRecommendation(const json &value, const OutputContext &output, const s if (result.additionalVideo) { const auto &additional = *result.additionalVideo; const auto &additionalContext = *output.additionalVideo; - const auto paired = enhancedBroadcastingPolicy::pairedVerticalCandidate( - {(uint32_t)result.width, (uint32_t)result.height, (uint32_t)result.fpsNum, (uint32_t)result.fpsDen}); - if (additional["width"] != paired.width || additional["height"] != paired.height || - (measurementMode == "active" && static_cast(additional["fpsNum"].get()) * result.fpsDen != - static_cast(result.fpsNum) * additional["fpsDen"].get()) || - !tupleWithinLimits(additional["width"], additional["height"], additional["fpsNum"], additional["fpsDen"], additionalContext.limits)) + if (!tupleWithinLimits(additional["width"], additional["height"], additional["fpsNum"], additional["fpsDen"], additionalContext.limits)) return false; + // Only measured results must match Twitch's tested pair. Estimates retain + // the saved vertical settings, which may differ in resolution or cadence. + if (measurementMode == "active") { + const auto paired = enhancedBroadcastingPolicy::pairedVerticalCandidate( + {(uint32_t)result.width, (uint32_t)result.height, (uint32_t)result.fpsNum, (uint32_t)result.fpsDen}); + if (additional["width"] != paired.width || additional["height"] != paired.height || + static_cast(additional["fpsNum"].get()) * result.fpsDen != + static_cast(result.fpsNum) * additional["fpsDen"].get()) + return false; + } if (measurementMode == "estimated" && (additional["width"] != additionalContext.current.width || additional["height"] != additionalContext.current.height || static_cast(additional["fpsNum"].get()) * additionalContext.current.fpsDen != diff --git a/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp b/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp index 85ba37685..f9d4c8fba 100644 --- a/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp +++ b/obs-studio-client/tests/auto-optimizer-client-contract-test.cpp @@ -601,9 +601,60 @@ TEST_CASE("Auto Optimizer client requires exact proof for a 1440p horizontal and fixture, { {"unproven vertical size", [](json &value) { value["legs"][0]["recommendation"]["additionalVideo"]["width"] = 720; }}, {"unproven primary size", [](json &value) { value["legs"][0]["measurement"]["probes"][0]["testedWidth"] = 1920; }}, + {"measured pair outside Twitch policy", + [](json &value) { + auto &output = value["legs"][0]; + auto &vertical = output["recommendation"]["additionalVideo"]; + vertical["width"] = 1440; + vertical["height"] = 2560; + output["measurement"]["probes"][0]["testedAdditionalVideo"] = vertical; + }}, }); } +TEST_CASE("Auto Optimizer client preserves saved Dual Format estimates without measured pairing requirements") +{ + const bool failedProbe = GENERATE(false, true); + CAPTURE(failedProbe); + json output = enhancedBroadcastingOutput(); + output["current"] = current(2560, 1440, 6000, 1); + output["additionalVideo"]["current"] = current(1440, 2560, 6000, 2); + output["additionalVideo"]["current"]["fpsNum"] = 30; + if (!failedProbe) + output.erase("probes"); + const auto prepared = prepare({{"streamSetup", "enhanced-broadcasting"}, {"outputs", json::array({output})}}); + json returned = returnedEnhancedBroadcastingOutput(); + returned["recommendation"]["width"] = 2560; + returned["recommendation"]["height"] = 1440; + const json vertical = {{"display", "vertical"}, {"width", 1440}, {"height", 2560}, {"fpsNum", 30}, {"fpsDen", 1}}; + returned["recommendation"]["additionalVideo"] = vertical; + returned["measurement"]["mode"] = "estimated"; + returned["measurement"]["confidence"] = "medium"; + if (failedProbe) + returned["measurement"]["probes"][0]["success"] = false; + else + returned["measurement"].erase("probes"); + ResultFixture fixture{prepared, {{"schemaVersion", 1}, {"sessionId", "run"}, {"status", "complete"}, {"legs", json::array({returned})}}}; + const auto result = contract::projectResult(fixture.result.dump(), "run", prepared.context); + REQUIRE(result.valid); + const auto projected = json::parse(result.json)["outputs"][0]; + CHECK(projected["measurement"]["mode"] == "estimated"); + CHECK(projected["videos"][1] == vertical); + CHECK_FALSE(projected.contains("encoding")); + checkInvalidResultMutations( + fixture, {{"changed saved vertical size", + [](json &value) { + value["legs"][0]["recommendation"]["additionalVideo"]["width"] = 1080; + value["legs"][0]["recommendation"]["additionalVideo"]["height"] = 1920; + }}, + {"changed saved vertical cadence", [](json &value) { value["legs"][0]["recommendation"]["additionalVideo"]["fpsNum"] = 60; }}, + {"unmeasured primary promotion", [](json &value) { value["legs"][0]["recommendation"]["fpsNum"] = 120; }}}); + auto limited = prepared.context; + limited.outputs[0].additionalVideo->limits.maxWidth = 1080; + limited.outputs[0].additionalVideo->limits.maxHeight = 1920; + CHECK_FALSE(contract::projectResult(fixture.result.dump(), "run", limited).valid); +} + TEST_CASE("Auto Optimizer client preserves measured Enhanced Broadcasting fallback explanations") { for (const auto reason : {"enhanced_broadcasting_transport_fallback", "enhanced_broadcasting_workload_fallback", diff --git a/tests/osn-tests/src/test_osn_auto_optimizer.ts b/tests/osn-tests/src/test_osn_auto_optimizer.ts index 92e3497ed..7ca60f83d 100644 --- a/tests/osn-tests/src/test_osn_auto_optimizer.ts +++ b/tests/osn-tests/src/test_osn_auto_optimizer.ts @@ -388,42 +388,59 @@ describe(testName, function() { expect(response.result.outputs[0].videos.map(video => video.display)).to.deep.equal(['horizontal', 'vertical']); }); - it('preserves a paired vertical recommendation when Enhanced Broadcasting is estimate-only', async function() { - const response = await run({ - streamSetup: 'enhanced-broadcasting', - outputs: [output({ - display: 'both', - outputKind: 'twitch-enhanced-broadcasting', - destinations: ['twitch'], - estimateReason: 'enhanced_broadcasting', - additionalVideo: { - display: 'vertical', - current: { - ...output().current, - width: 720, - height: 1280, - fpsNum: 60, - }, - limits: { - maxWidth: 1080, - maxHeight: 1920, - maxFpsNum: 60, - maxFpsDen: 1, + for (const [width, height, verticalWidth, verticalHeight] of [ + [1280, 720, 720, 1280], + [2560, 1440, 1440, 2560], + [2560, 1440, 1080, 1920], + ]) { + it(`preserves saved Dual Format settings when estimate-only (${width}x${height} / ${verticalWidth}x${verticalHeight})`, async function() { + const response = await run({ + streamSetup: 'enhanced-broadcasting', + outputs: [output({ + display: 'both', + outputKind: 'twitch-enhanced-broadcasting', + destinations: ['twitch'], + estimateReason: 'enhanced_broadcasting', + current: { ...output().current, width, height }, + additionalVideo: { + display: 'vertical', + current: { + ...output().current, + width: verticalWidth, + height: verticalHeight, + fpsNum: 60, + }, + limits: { + maxWidth: Math.max(1080, verticalWidth), + maxHeight: Math.max(1920, verticalHeight), + maxFpsNum: 60, + maxFpsDen: 1, + }, }, - }, - })], - }); + })], + }); - expect(response.result.outputs[0].measurement.mode).to.equal('estimated'); - expect(response.result.outputs[0].videos.find(video => video.display === 'vertical')).to.deep.equal({ - display: 'vertical', - width: 720, - height: 1280, - fpsNum: 60, - fpsDen: 1, + expect(response.result.status).to.equal('complete'); + expect(response.result.outputs[0].measurement.mode).to.equal('estimated'); + expect(response.result.outputs[0].videos).to.deep.equal([ + { + display: 'horizontal', + width, + height, + fpsNum: 30, + fpsDen: 1, + }, + { + display: 'vertical', + width: verticalWidth, + height: verticalHeight, + fpsNum: 60, + fpsDen: 1, + }, + ]); + expect(response.events.some(event => event.code === 'recommendation_provider_managed')).to.equal(true); }); - expect(response.events.some(event => event.code === 'recommendation_provider_managed')).to.equal(true); - }); + } it('cancels a newly started run and makes cleanup observable before returning', async function() { const nativeRun = autoOptimizer.run({ From 076a4654751df3beadab699c50efa3204a0c9e90 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Tue, 6 Oct 2026 17:30:38 +1300 Subject: [PATCH 5/5] Honor cancellation before Enhanced Broadcasting configuration retries Stop candidate descent after cancellation, including when a blocking Twitch configuration request fails or succeeds. Add local-only integration coverage for stalled 1440p and 1080p requests, session reuse, and the normal 1440p-to-1080p fallback. --- .../source/nodeobs_auto_optimizer.cpp | 14 ++ .../test_osn_auto_optimizer_configuration.ts | 154 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 tests/osn-tests/src/test_osn_auto_optimizer_configuration.ts diff --git a/obs-studio-server/source/nodeobs_auto_optimizer.cpp b/obs-studio-server/source/nodeobs_auto_optimizer.cpp index ba8073a83..0c326c53d 100644 --- a/obs-studio-server/source/nodeobs_auto_optimizer.cpp +++ b/obs-studio-server/source/nodeobs_auto_optimizer.cpp @@ -3765,6 +3765,10 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr & } for (size_t index = 0; index < candidates.size(); index++) { + if (session->cancelRequested.load()) { + result.cancelled = true; + return result; + } const auto &candidate = candidates[index]; CurrentSettings eventVideo = leg.current; eventVideo.width = (int)candidate.width; @@ -3826,6 +3830,12 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr & enhancedBroadcastingPolicy::filterProbeCodecs(post.client.supported_codecs); config = osn::DownloadGoLiveConfig(goLiveConfigUrl, post); } catch (const std::exception &exception) { + // The synchronous request can finish after cancellation. Do not start + // another five-second request and exceed the cleanup deadline. + if (session->cancelRequested.load()) { + result.cancelled = true; + return result; + } blog(LOG_WARNING, "[Auto Optimizer][Enhanced Broadcasting] Ladder request failed for %ux%u %u/%u FPS: %s", candidate.width, candidate.height, candidate.fpsNum, candidate.fpsDen, boundedLogValue(exception.what()).c_str()); result.errorCode = "enhanced_broadcasting_config_request_failed"; @@ -3841,6 +3851,10 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr & } return result; } + if (session->cancelRequested.load()) { + result.cancelled = true; + return result; + } if (!validateEnhancedBroadcastingConfig(config, candidateCanvases, result.errorCode)) { if (!enhancedBroadcastingPolicy::allowsCandidateDescent(result.errorCode)) return result; diff --git a/tests/osn-tests/src/test_osn_auto_optimizer_configuration.ts b/tests/osn-tests/src/test_osn_auto_optimizer_configuration.ts new file mode 100644 index 000000000..489004be8 --- /dev/null +++ b/tests/osn-tests/src/test_osn_auto_optimizer_configuration.ts @@ -0,0 +1,154 @@ +import 'mocha'; +import { expect } from 'chai'; +import { createServer, Server } from 'http'; +import { AddressInfo, Socket } from 'net'; +import * as osn from '../osn'; +import type { IAutoOptimizerEvent, IAutoOptimizerRequest } from '../../../js/module'; +import { OBSHandler } from '../util/obs_handler'; +import { deleteConfigFiles } from '../util/general'; + +describe('osn-auto-optimizer-configuration', function() { + this.timeout(30000); + + let obs: OBSHandler; + let proxy: Server; + const sockets = new Set(); + let requests = 0; + let stall = true; + let configurationAuthority = ''; + let onRequest: (() => void) | undefined; + + before(async function() { + // Intercept HTTPS CONNECT locally: never forward a request or credentials + // to Twitch. A stalled tunnel exercises the real configuration timeout. + proxy = createServer((_, response) => response.writeHead(502).end()); + proxy.on('connection', socket => { + sockets.add(socket); + socket.on('error', () => undefined); // libcurl may abort a timed-out connection. + socket.on('close', () => sockets.delete(socket)); + }); + proxy.on('connect', (incoming, socket) => { + // Background service updates must not count as configuration attempts. + if (incoming.url !== configurationAuthority) { + socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'); + return; + } + requests++; + if (onRequest) onRequest(); + if (!stall) socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'); + }); + await new Promise((resolve, reject) => { + proxy.once('error', reject); + proxy.listen(0, '127.0.0.1', resolve); + }); + const proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`; + const proxyVariables = ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', + 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy']; + const savedEnvironment = proxyVariables.map(name => [name, process.env[name]] as const); + try { + for (const name of proxyVariables) + process.env[name] = name.toLowerCase() === 'no_proxy' ? '' : proxyUrl; + deleteConfigFiles(); + // Only the child OSN process keeps this environment. Restore the test + // runner's proxy settings immediately after starting that process. + obs = new OBSHandler('osn-auto-optimizer-configuration'); + } finally { + for (const [name, value] of savedEnvironment) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + const service = osn.ServiceFactory.create('rtmp_common', 'configuration-test', { service: 'Twitch', server: 'auto' }); + try { + const url = new URL(service.settings.multitrack_video_configuration_url as string); + configurationAuthority = `${url.hostname}:${url.port || '443'}`; + } finally { + osn.ServiceFactory.destroy(service); + } + }); + + after(async function() { + try { + if (obs) obs.shutdown(); + } finally { + sockets.forEach(socket => socket.destroy()); + if (proxy?.listening) await new Promise(resolve => proxy.close(() => resolve())); + deleteConfigFiles(); + } + }); + + beforeEach(function() { + requests = 0; + stall = true; + onRequest = undefined; + }); + + function request(width: number, height: number): IAutoOptimizerRequest { + return { + streamSetup: 'enhanced-broadcasting', + outputs: [{ + outputId: 'primary', display: 'horizontal', + outputKind: 'twitch-enhanced-broadcasting', destinations: ['twitch'], + current: { + canvasId: obs.defaultVideoContext.canvasId, + width: 1920, height: 1080, fpsNum: 30, fpsDen: 1, + bitrateKbps: 6000, encoderId: 'obs_x264', preset: 'veryfast', + }, + limits: { maxWidth: width, maxHeight: height }, + probes: [{ id: 'twitch', kind: 'twitch-enhanced-broadcasting', streamKey: 'local-test-key' }], + }], + }; + } + + for (const [width, height] of [[2560, 1440], [1920, 1080]]) { + it(`cancels a stalled ${width}x${height} configuration request without retrying`, async function() { + let timer: ReturnType; + const run = osn.NodeObs.AutoOptimizer.run(request(width, height), () => undefined); + // Attach immediately so the pre-fix cleanup timeout is reported as a + // test assertion failure, not an unhandled rejection while cancelling. + run.result.catch(() => undefined); + try { + await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('No configuration request reached the local proxy')), 15000); + onRequest = () => { clearTimeout(timer); resolve(); }; + }); + await run.cancel(); + // A cleanup taking over the native eight-second deadline rejects + // this promise instead of returning a cancelled result. + const result = await run.result; + expect(result.status).to.equal('cancelled'); + expect(result.error.code).to.equal('cancelled'); + expect(requests).to.equal(1); + + const nextRequest = request(1920, 1080); + nextRequest.outputs[0].probes = []; + const nextRun = osn.NodeObs.AutoOptimizer.run(nextRequest, () => undefined); + try { + expect((await nextRun.result).status).to.equal('complete'); + } finally { + await nextRun.cancel(); + } + } finally { + clearTimeout(timer!); + onRequest = undefined; + await run.cancel(); + } + }); + } + + it('still retries a failed 1440p configuration at 1080p when not cancelled', async function() { + stall = false; + const events: IAutoOptimizerEvent[] = []; + const run = osn.NodeObs.AutoOptimizer.run(request(2560, 1440), event => events.push(event)); + try { + const result = await run.result; + expect(result.status).to.equal('complete'); + expect(result.outputs[0].measurement.mode).to.equal('estimated'); + expect(requests).to.equal(2); + expect(events.filter(event => event.code === 'enhanced_broadcasting_requesting_ladder' && event.width) + .map(event => [event.width, event.height])).to.deep.equal([[2560, 1440], [1920, 1080]]); + } finally { + await run.cancel(); + } + }); +});