Skip to content

Commit ea8375d

Browse files
Fix Auto Optimizer custom-aspect recommendations and bounded fallbacks (#1800)
* Fix Auto Optimizer custom-aspect recommendations and bounded fallbacks * Match Auto Optimizer validation to Twitch vertical limits
1 parent 4a7e6f7 commit ea8375d

10 files changed

Lines changed: 312 additions & 62 deletions

‎js/module.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2160,17 +2160,18 @@ interface IAutoOptimizerLimits {
21602160
maxBitrateKbps?: number;
21612161
/**
21622162
* Maximum resolution OSN may test for this run without changing persistent
2163-
* video settings. Supply `maxWidth` and `maxHeight` together. OSN tests only
2164-
* the supported 2560x1440, 1920x1080, 1280x720, and 960x540 tiers, or their portrait
2165-
* equivalents, up to this limit. It promotes only 16:9 or 9:16 output; a
2166-
* custom aspect ratio keeps its current resolution and frame rate. The
2167-
* caller remains responsible for applying a recommended Base Canvas resize
2168-
* safely.
2163+
* video settings. Supply `maxWidth` and `maxHeight` together as a bounding
2164+
* box. Standard-aspect outputs use the 2560x1440, 1920x1080, 1280x720, and
2165+
* 960x540 tiers, or their portrait equivalents. Custom outputs, including
2166+
* ultrawide, preserve their configured output aspect, reduced to fit the
2167+
* limits with even-pixel rounding. The base canvas aspect is not substituted.
2168+
* Estimated results never increase saved dimensions or frame rate. The
2169+
* caller remains responsible for applying any Base Canvas resize safely.
21692170
* Horizontal 1440p testing is available only when every destination in the
21702171
* request is Twitch. Other streams are limited to 1080p. Vertical testing
21712172
* remains limited to 1080x1920, including Twitch Enhanced Broadcasting's
21722173
* paired 1440p horizontal / 1080p vertical workload. Ordinary Twitch 1440p
2173-
* requests use an experimental 8000 Kbps limit instead of the service's
2174+
* requests use an experimental 7500 Kbps limit instead of the service's
21742175
* 6000 Kbps default; the caller must apply that tested bitrate without
21752176
* reapplying the lower service bitrate limit.
21762177
*/

‎obs-studio-client/source/auto-optimizer-client-contract.cpp‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -664,19 +664,25 @@ bool parseRecommendation(const json &value, const OutputContext &output, const s
664664
const auto &additionalContext = *output.additionalVideo;
665665
if (!tupleWithinLimits(additional["width"], additional["height"], additional["fpsNum"], additional["fpsDen"], additionalContext.limits))
666666
return false;
667-
// Only measured results must match Twitch's tested pair. Estimates retain
668-
// the saved vertical settings, which may differ in resolution or cadence.
667+
// Only measured results must match Twitch's tested pair. Estimates may
668+
// reduce saved vertical settings to caller/platform limits, but not promote.
669669
if (measurementMode == "active") {
670+
// Mirror the server's vertical platform caps while preserving tighter caller limits.
670671
const auto paired = enhancedBroadcastingPolicy::pairedVerticalCandidate(
671-
{(uint32_t)result.width, (uint32_t)result.height, (uint32_t)result.fpsNum, (uint32_t)result.fpsDen});
672+
{(uint32_t)result.width, (uint32_t)result.height, (uint32_t)result.fpsNum, (uint32_t)result.fpsDen},
673+
{output.current.width, output.current.height, output.current.fpsNum, output.current.fpsDen},
674+
{additionalContext.current.width, additionalContext.current.height, additionalContext.current.fpsNum,
675+
additionalContext.current.fpsDen},
676+
std::min(additionalContext.limits.maxWidth.value_or(1080), 1080),
677+
std::min(additionalContext.limits.maxHeight.value_or(1920), 1920));
672678
if (additional["width"] != paired.width || additional["height"] != paired.height ||
673679
static_cast<int64_t>(additional["fpsNum"].get<int>()) * result.fpsDen !=
674680
static_cast<int64_t>(result.fpsNum) * additional["fpsDen"].get<int>())
675681
return false;
676682
}
677683
if (measurementMode == "estimated" &&
678-
(additional["width"] != additionalContext.current.width || additional["height"] != additionalContext.current.height ||
679-
static_cast<int64_t>(additional["fpsNum"].get<int>()) * additionalContext.current.fpsDen !=
684+
(additional["width"] > additionalContext.current.width || additional["height"] > additionalContext.current.height ||
685+
static_cast<int64_t>(additional["fpsNum"].get<int>()) * additionalContext.current.fpsDen >
680686
static_cast<int64_t>(additionalContext.current.fpsNum) * additional["fpsDen"].get<int>()))
681687
return false;
682688
}

‎obs-studio-client/tests/auto-optimizer-client-contract-test.cpp‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -642,17 +642,90 @@ TEST_CASE("Auto Optimizer client preserves saved Dual Format estimates without m
642642
CHECK(projected["videos"][1] == vertical);
643643
CHECK_FALSE(projected.contains("encoding"));
644644
checkInvalidResultMutations(
645-
fixture, {{"changed saved vertical size",
646-
[](json &value) {
647-
value["legs"][0]["recommendation"]["additionalVideo"]["width"] = 1080;
648-
value["legs"][0]["recommendation"]["additionalVideo"]["height"] = 1920;
649-
}},
645+
fixture, {{"unmeasured vertical promotion", [](json &value) { value["legs"][0]["recommendation"]["additionalVideo"]["width"] = 1442; }},
650646
{"changed saved vertical cadence", [](json &value) { value["legs"][0]["recommendation"]["additionalVideo"]["fpsNum"] = 60; }},
651647
{"unmeasured primary promotion", [](json &value) { value["legs"][0]["recommendation"]["fpsNum"] = 120; }}});
652648
auto limited = prepared.context;
653649
limited.outputs[0].additionalVideo->limits.maxWidth = 1080;
654650
limited.outputs[0].additionalVideo->limits.maxHeight = 1920;
655651
CHECK_FALSE(contract::projectResult(fixture.result.dump(), "run", limited).valid);
652+
fixture.result["legs"][0]["recommendation"]["additionalVideo"]["width"] = 1080;
653+
fixture.result["legs"][0]["recommendation"]["additionalVideo"]["height"] = 1920;
654+
CHECK(contract::projectResult(fixture.result.dump(), "run", limited).valid);
655+
}
656+
657+
TEST_CASE("Auto Optimizer client requires exact measured proof for independently shaped Dual Format outputs")
658+
{
659+
json output = enhancedBroadcastingOutput();
660+
output["current"] = current(3440, 1440, 6000, 1);
661+
output["limits"] = {{"maxWidth", 1920}, {"maxHeight", 1080}};
662+
const auto prepared = prepare({{"streamSetup", "enhanced-broadcasting"}, {"outputs", json::array({output})}});
663+
json returned = returnedEnhancedBroadcastingOutput();
664+
returned["recommendation"]["height"] = 802;
665+
returned["measurement"]["probes"][0]["testedHeight"] = 802;
666+
ResultFixture fixture{prepared, {{"schemaVersion", 1}, {"sessionId", "run"}, {"status", "complete"}, {"legs", json::array({returned})}}};
667+
REQUIRE(contract::projectResult(fixture.result.dump(), "run", prepared.context).valid);
668+
checkInvalidResultMutations(
669+
fixture, {
670+
{"unproven custom primary", [](json &value) { value["legs"][0]["measurement"]["probes"][0]["testedHeight"] = 1080; }},
671+
{"rotated ultrawide must not replace vertical output",
672+
[](json &value) {
673+
auto &leg = value["legs"][0];
674+
leg["recommendation"]["additionalVideo"]["width"] = 802;
675+
leg["measurement"]["probes"][0]["testedAdditionalVideo"] = leg["recommendation"]["additionalVideo"];
676+
}},
677+
});
678+
}
679+
680+
TEST_CASE("Auto Optimizer client caps custom Dual Format validation to Twitch limits")
681+
{
682+
struct Example {
683+
int width, height, maxWidth, maxHeight, expectedWidth, expectedHeight;
684+
};
685+
const auto example = GENERATE(Example{1200, 1600, 1200, 1600, 1080, 1440}, Example{1000, 2400, 1000, 2400, 800, 1920},
686+
Example{1200, 1600, 720, 1280, 720, 960}, Example{1200, 1600, 1080, 1920, 1080, 1440},
687+
Example{1200, 1600, 0, 0, 1080, 1440});
688+
CAPTURE(example.width, example.height, example.maxWidth, example.maxHeight);
689+
json output = enhancedBroadcastingOutput();
690+
output["additionalVideo"]["current"] = current(example.width, example.height, 6000, 2);
691+
if (example.maxWidth)
692+
output["additionalVideo"]["limits"] = {{"maxWidth", example.maxWidth}, {"maxHeight", example.maxHeight}};
693+
const auto prepared = prepare({{"streamSetup", "enhanced-broadcasting"}, {"outputs", json::array({output})}});
694+
json returned = returnedEnhancedBroadcastingOutput();
695+
auto &additional = returned["recommendation"]["additionalVideo"];
696+
additional["width"] = example.expectedWidth;
697+
additional["height"] = example.expectedHeight;
698+
returned["measurement"]["probes"][0]["testedAdditionalVideo"] = additional;
699+
ResultFixture fixture{prepared, {{"schemaVersion", 1}, {"sessionId", "run"}, {"status", "complete"}, {"legs", json::array({returned})}}};
700+
const auto projected = contract::projectResult(fixture.result.dump(), "run", prepared.context);
701+
REQUIRE(projected.valid);
702+
CHECK(json::parse(projected.json)["outputs"][0]["videos"][1] == additional);
703+
checkInvalidResultMutations(fixture, {{"mismatched measured vertical dimensions",
704+
[](json &value) { value["legs"][0]["measurement"]["probes"][0]["testedAdditionalVideo"]["width"] = 2; }},
705+
{"uncapped vertical result despite matching proof", [&](json &value) {
706+
auto &leg = value["legs"][0];
707+
auto &video = leg["recommendation"]["additionalVideo"];
708+
video["width"] = example.width;
709+
video["height"] = example.height;
710+
leg["measurement"]["probes"][0]["testedAdditionalVideo"] = video;
711+
}}});
712+
}
713+
714+
TEST_CASE("Auto Optimizer client accepts bounded ultrawide results but rejects oversized active and estimated tuples")
715+
{
716+
const int width = GENERATE(3340, 3440);
717+
const bool measured = GENERATE(false, true);
718+
json output = standardOutput("primary", "horizontal", "twitch", twitchProbe());
719+
output["current"] = current(width, 1440, 6000);
720+
const auto prepared = prepare({{"streamSetup", "direct-single"}, {"outputs", json::array({output})}});
721+
json returned = returnedStandardOutput("primary", "horizontal", "twitch", "twitch", "twitch-bandwidth-test", 6000, 6000);
722+
returned["recommendation"] = recommendation(1920, width == 3440 ? 802 : 826, 6000);
723+
returned["measurement"]["mode"] = measured ? "active" : "estimated";
724+
returned["measurement"]["probes"][0]["success"] = measured;
725+
json result = {{"schemaVersion", 1}, {"sessionId", "run"}, {"status", "complete"}, {"legs", json::array({returned})}};
726+
CHECK(contract::projectResult(result.dump(), "run", prepared.context).valid);
727+
result["legs"][0]["recommendation"] = recommendation(width, 1440, 6000);
728+
CHECK_FALSE(contract::projectResult(result.dump(), "run", prepared.context).valid);
656729
}
657730

658731
TEST_CASE("Auto Optimizer client preserves measured Enhanced Broadcasting fallback explanations")

‎obs-studio-client/tests/auto-optimizer-enhanced-broadcasting-policy-test.cpp‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,36 @@
44

55
namespace policy = autoOptimizer::enhancedBroadcastingPolicy;
66

7+
TEST_CASE("Enhanced Broadcasting tests the configured custom output aspect, not a 16:9 replacement")
8+
{
9+
for (const auto current :
10+
std::vector<autoOptimizer::qualityPolicy::VideoTuple>{{3440, 1440, 60, 1}, {3340, 1440, 30, 1}, {2560, 1600, 30, 1}, {1600, 1200, 30, 1}}) {
11+
const auto expected = autoOptimizer::qualityPolicy::boundCurrentToSupportedTier(current, 1920, 1080);
12+
const auto result = policy::candidatesForOutput(current, 1920, 1080, 60, 1);
13+
REQUIRE(result.size() == 1);
14+
CHECK(result[0].width == expected.width);
15+
CHECK(result[0].height == expected.height);
16+
CHECK(result[0].fpsNum == current.fpsNum);
17+
}
18+
const auto capped = policy::candidatesForOutput({3440, 1440, 60, 1}, 1920, 1080, 30, 1);
19+
CHECK(capped[0].fpsNum == 30);
20+
CHECK(policy::candidatesForOutput({1920, 1080, 30, 1}, 2560, 1440, 60, 1).size() == 7);
21+
}
22+
23+
TEST_CASE("Enhanced Broadcasting keeps independently configured custom Dual Format aspects")
24+
{
25+
const auto vertical = policy::pairedVerticalCandidate({1920, 802, 60, 1}, {3440, 1440, 60, 1}, {1080, 1920, 60, 1}, 1080, 1920);
26+
CHECK(vertical.width == 1080);
27+
CHECK(vertical.height == 1920);
28+
const auto customVertical = policy::pairedVerticalCandidate({1920, 1080, 60, 1}, {1920, 1080, 60, 1}, {1200, 1600, 60, 1}, 1080, 1920);
29+
CHECK(customVertical.width == 1080);
30+
CHECK(customVertical.height == 1440);
31+
CHECK(customVertical.fpsNum == 60);
32+
const auto canonical = policy::pairedVerticalCandidate({2560, 1440, 60, 1}, {1920, 1080, 60, 1}, {720, 1280, 60, 1}, 1080, 1920);
33+
CHECK(canonical.width == 1080);
34+
CHECK(canonical.height == 1920);
35+
}
36+
737
TEST_CASE("Enhanced Broadcasting probes preserve discovered H.264 and HEVC independently of resolution")
838
{
939
std::unordered_set<std::string> codecs{"h264", "h265", "av1", "vp9"};

‎obs-studio-client/tests/auto-optimizer-quality-policy-test.cpp‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include <catch2/catch_test_macros.hpp>
2+
#include <catch2/generators/catch_generators.hpp>
23

34
#include <initializer_list>
45
#include <limits>
@@ -265,15 +266,15 @@ TEST_CASE("Auto Optimizer hardware tests high frame rates in product-priority or
265266
CHECK(result[1].fpsNum == 30);
266267
}
267268

268-
TEST_CASE("Auto Optimizer permits Twitch-only 1440p with an experimental measured 8 Mbps budget")
269+
TEST_CASE("Auto Optimizer permits Twitch-only 1440p with an experimental measured 7.5 Mbps budget")
269270
{
270271
CHECK(policy::isTwitchOnlyStream({"twitch", "twitch"}));
271272
CHECK_FALSE(policy::isTwitchOnlyStream({"twitch", "youtube"}));
272273
CHECK_FALSE(policy::isTwitchOnlyStream({"twitch", "custom"}));
273274
CHECK_FALSE(policy::isTwitchOnlyStream({}));
274275
const policy::VideoTuple ceiling{2560, 1440, 60, 1};
275-
CHECK(policy::select(ceiling, 8000, "nvenc", policy::QualityProfile::Twitch).video.width == 2560);
276-
CHECK(policy::select(ceiling, 7950, "nvenc", policy::QualityProfile::Twitch).video.width == 1920);
276+
CHECK(policy::select(ceiling, 7500, "nvenc", policy::QualityProfile::Twitch).video.width == 2560);
277+
CHECK(policy::select(ceiling, 7499, "nvenc", policy::QualityProfile::Twitch).video.width == 1920);
277278
CHECK(policy::select(ceiling, 6000, "nvenc", policy::QualityProfile::Twitch).video.width == 1920);
278279
CHECK(policy::benchmarkCeiling({1280, 720, 30, 1}, 2560, 1440, 60, 1).width == 2560);
279280
}
@@ -525,16 +526,41 @@ TEST_CASE("Auto Optimizer benchmark ceiling preserves portrait orientation")
525526
CHECK(policy::select(promoted, 6000, "obs_nvenc_h264_tex").video.height == 1920);
526527
}
527528

528-
TEST_CASE("Auto Optimizer promotes only supported 16:9 and 9:16 aspect ratios")
529+
TEST_CASE("Auto Optimizer preserves ultrawide outputs within active and estimated limits")
530+
{
531+
const int width = GENERATE(3340, 3440);
532+
const policy::VideoTuple current{width, 1440, 60, 1};
533+
for (const auto maximum : {policy::VideoTuple{2560, 1440, 60, 1}, policy::VideoTuple{1920, 1080, 60, 1}}) {
534+
const policy::VideoTuple expected{maximum.width, (1440 * maximum.width / width / 2) * 2, 60, 1};
535+
const auto bounded = policy::boundCurrentToSupportedTier(current, maximum.width, maximum.height);
536+
CHECK(policy::sameVideo(bounded, expected));
537+
const auto tested = policy::benchmarkCeiling(current, maximum.width, maximum.height, 60, 1);
538+
CHECK(policy::sameVideo(tested, expected));
539+
CHECK(policy::sameVideo(policy::select(tested, maximum.height == 1440 ? 7500 : 6000, "nvenc", policy::QualityProfile::Twitch).video, expected));
540+
for (const auto &candidate : policy::candidates(tested)) {
541+
CHECK(candidate.width <= maximum.width);
542+
CHECK(candidate.height <= maximum.height);
543+
CHECK(policy::sameVideo(candidate, expected));
544+
}
545+
CHECK(policy::sameVideo(policy::recommendationCeiling(tested, bounded, false), expected));
546+
}
547+
const auto preScaled = policy::boundCurrentToSupportedTier({1920, 802, 30, 1}, 2560, 1440);
548+
CHECK(policy::sameVideo(preScaled, {1920, 802, 30, 1}));
549+
CHECK(policy::sameVideo(policy::benchmarkCeiling({1920, 802, 30, 1}, 2560, 1440, 60, 1), preScaled));
550+
}
551+
552+
TEST_CASE("Auto Optimizer preserves other custom aspects within caller limits")
529553
{
530554
const std::vector<policy::VideoTuple> custom = {
531555
{1600, 1000, 30, 1}, // 16:10
532556
{1024, 768, 30, 1}, // 4:3
533-
{2560, 1080, 30, 1}, // ultrawide
534557
};
535558
for (const auto &current : custom) {
536559
CAPTURE(current.width, current.height);
537-
CHECK(policy::sameVideo(policy::boundCurrentToSupportedTier(current, 640, 360), current));
560+
const auto bounded = policy::boundCurrentToSupportedTier(current, 640, 360);
561+
CHECK(bounded.width <= 640);
562+
CHECK(bounded.height <= 360);
563+
CHECK(bounded.fpsNum == current.fpsNum);
538564
const auto ceiling = policy::benchmarkCeiling(current, 1920, 1080, 60, 1);
539565
CHECK(policy::sameVideo(ceiling, current));
540566
const auto options = policy::candidates(ceiling);

0 commit comments

Comments
 (0)