Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions js/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2323,6 +2323,7 @@ interface IAutoOptimizerMeasurementEvidence {
interface IAutoOptimizerMeasurement {
mode: AutoOptimizerMeasurementMode;
confidence: 'high' | 'medium' | 'low';
/** Optional explanation code for an estimate or measured result, including why higher-quality settings were rejected. */
reason?: string;
/** Provider measurements that contributed to the result. Detailed throughput and workload data remains internal to OSN. */
evidence?: IAutoOptimizerMeasurementEvidence[];
Expand Down
19 changes: 19 additions & 0 deletions obs-studio-client/tests/auto-optimizer-client-contract-test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,25 @@ 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 preserves measured Enhanced Broadcasting fallback explanations")
{
for (const auto reason :
{"enhanced_broadcasting_transport_fallback", "enhanced_broadcasting_workload_fallback", "enhanced_broadcasting_transport_and_workload_fallback"}) {
CAPTURE(reason);
auto fixture = enhancedBroadcastingFixture();
fixture.result["legs"][0]["measurement"]["reason"] = reason;
const auto result = contract::projectResult(fixture.result.dump(), "run", fixture.prepared.context);
REQUIRE(result.valid);
const json projected = json::parse(result.json);
const auto &output = projected["outputs"][0];
CHECK(output["measurement"]["reason"] == reason);
CHECK(output["measurement"]["mode"] == "active");
CHECK(output["measurement"]["confidence"] == "high");
CHECK(output["measurement"]["evidence"][0]["success"] == true);
CHECK_FALSE(output.contains("encoding"));
}
}

TEST_CASE("Auto Optimizer client requires exact Enhanced Broadcasting combined workload proof")
{
const auto fixture = enhancedBroadcastingFixture();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,35 @@ TEST_CASE("Composite Enhanced Broadcasting maps only local joint-load failures t
CHECK_FALSE(policy::isCompositeCandidateLoadFailure("enhanced_broadcasting_output_connect_failed"));
CHECK_FALSE(policy::isCompositeCandidateLoadFailure("enhanced_broadcasting_transport_pressure"));
}

TEST_CASE("Enhanced Broadcasting fallback reports retain earlier upload instability")
{
policy::CandidateFallbackEvidence evidence;
CHECK(evidence.reason().empty());
evidence.record("enhanced_broadcasting_ladder_below_candidate");
CHECK(evidence.reason().empty());
evidence.record("enhanced_broadcasting_transport_pressure");
CHECK(evidence.reason() == "enhanced_broadcasting_transport_fallback");
evidence.record("enhanced_broadcasting_ladder_below_candidate");
evidence.record("");
CHECK(evidence.reason() == "enhanced_broadcasting_transport_fallback");
CHECK(policy::CandidateFallbackEvidence{}.reason().empty());
}

TEST_CASE("Enhanced Broadcasting fallback reports distinguish workload and upload failures")
{
for (const auto error :
{"enhanced_broadcasting_encoder_underload", "enhanced_broadcasting_render_overload", "enhanced_broadcasting_companion_overload"}) {
CAPTURE(error);
policy::CandidateFallbackEvidence evidence;
evidence.record(error);
CHECK(evidence.reason() == "enhanced_broadcasting_workload_fallback");
evidence.record("enhanced_broadcasting_transport_pressure");
CHECK(evidence.reason() == "enhanced_broadcasting_transport_and_workload_fallback");

policy::CandidateFallbackEvidence reversed;
reversed.record("enhanced_broadcasting_transport_pressure");
reversed.record(error);
CHECK(reversed.reason() == evidence.reason());
}
}
29 changes: 20 additions & 9 deletions obs-studio-server/source/nodeobs_auto_optimizer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ constexpr int kYoutubeProbeTotalTimeoutMs = 100000;
constexpr int kYoutubeProbeBudgetSlackMs = 250;
constexpr int kYoutubeProbeMaximumConfirmationEpisodes = 2;
constexpr int kYoutubeProbeBudgetEstimatePercent = 115;
constexpr float kProbeCongestionHigh = 0.20f;
constexpr float kProbeCongestionSevere = 0.50f;
// All probes and recovery checks share this cutoff. Keep the sample counters
// used by the duration-based policies, but count both from the same threshold.
constexpr float kProbeCongestionThreshold = 0.50f;
constexpr int kTwitchProbeAudioBitrateKbps = 32;
constexpr int kYoutubeProbeAudioBitrateKbps = 128;
constexpr int kHardwareWarmupMs = 500;
Expand Down Expand Up @@ -1378,6 +1379,7 @@ struct ProbeResult {
uint64_t configuredAggregateBitrateKbps = 0;
bool pairedCadenceEvidence = false;
std::vector<CompanionWorkload> companionWorkloads;
enhancedBroadcastingPolicy::CandidateFallbackEvidence candidateFallback;
};

static bool silentAudioCallback(void *, uint64_t startTimestamp, uint64_t, uint64_t *outputTimestamp, uint32_t, struct audio_data_mixes_outputs *)
Expand Down Expand Up @@ -2737,10 +2739,10 @@ static bool runTwitchProbeSample(const std::shared_ptr<Session> &session, Scratc
sample.congestionSamples = (uint32_t)congestionValues.size();
for (float congestion : congestionValues) {
sample.maximumCongestion = std::max(sample.maximumCongestion, congestion);
if (congestion >= kProbeCongestionHigh)
if (congestion >= kProbeCongestionThreshold) {
sample.congestionHighSamples++;
if (congestion >= kProbeCongestionSevere)
sample.congestionSevereSamples++;
}
}
std::sort(congestionValues.begin(), congestionValues.end());
if (!congestionValues.empty()) {
Expand Down Expand Up @@ -2850,7 +2852,7 @@ static bool waitForYoutubeRecoveryDrain(const std::shared_ptr<Session> &session,
return false;
}

if (recoveryGate.observe(lastCongestion < kProbeCongestionHigh, droppedFramesUnchanged)) {
if (recoveryGate.observe(lastCongestion < kProbeCongestionThreshold, droppedFramesUnchanged)) {
recovered = true;
break;
}
Expand Down Expand Up @@ -3026,10 +3028,10 @@ static bool runYoutubeProbeSample(const std::shared_ptr<Session> &session, Scrat
sample.congestionSamples = (uint32_t)congestionValues.size();
for (float congestion : congestionValues) {
sample.maximumCongestion = std::max(sample.maximumCongestion, congestion);
if (congestion >= kProbeCongestionHigh)
if (congestion >= kProbeCongestionThreshold) {
sample.congestionHighSamples++;
if (congestion >= kProbeCongestionSevere)
sample.congestionSevereSamples++;
}
}
std::sort(congestionValues.begin(), congestionValues.end());
if (!congestionValues.empty()) {
Expand Down Expand Up @@ -3299,6 +3301,13 @@ static bool runEnhancedBroadcastingOutputAttempt(const std::shared_ptr<Session>
return false;
}

// Encoder groups synchronize startup to the renderer's clock, which the
// standalone synthetic input does not share. Release this probe-owned group
// before capture starts; the output retains the encoders and A/V pairing is
// unchanged. Texture probes keep the normal grouped startup.
if (!usePrivateTextureMix)
resources.multitrackVideoEncoderGroup.reset();
Comment thread
aleksandr-voitenko marked this conversation as resolved.

std::vector<bool> canvasInputsBound(candidates.size(), false);
for (size_t index = 0; index < config.encoder_configurations.size(); index++) {
obs_encoder_t *encoder = obs_output_get_video_encoder2(resources.output, index);
Expand Down Expand Up @@ -3593,7 +3602,7 @@ static bool runEnhancedBroadcastingOutputAttempt(const std::shared_ptr<Session>
const char *outputError = obs_output_get_last_error(resources.output);
const bool encoderFramesPassed = std::equal(attempt.encodedFrames.begin(), attempt.encodedFrames.end(), attempt.minimumEncodedFrames.begin(),
[](uint32_t encoded, uint32_t minimum) { return encoded >= minimum; });
const bool transportPassed = attempt.outputDroppedFrames == 0 && attempt.maximumCongestion < kProbeCongestionHigh;
const bool transportPassed = attempt.outputDroppedFrames == 0 && attempt.maximumCongestion < kProbeCongestionThreshold;
bool companionsPassed = true;
for (const CompanionSample &sample : companionSamples) {
const uint32_t encoded = obs_encoder_get_encoded_frames(sample.resources->videoEncoder) - sample.encodedStart;
Expand Down Expand Up @@ -3843,6 +3852,7 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr<Session> &
: textureAttempt.errorCode;
if (!enhancedBroadcastingPolicy::allowsCandidateDescent(result.errorCode))
return result;
result.candidateFallback.record(result.errorCode);
pushEvent(session, "progress", "bandwidth", candidateEnd, "enhanced_broadcasting_candidate_rejected", probe.legId, "active",
probe.probeId, probe.provider, 0, &eventVideo, 0, 0, eventAdditionalVideoPtr);
continue;
Expand All @@ -3866,6 +3876,7 @@ static ProbeResult runEnhancedBroadcastingProbe(const std::shared_ptr<Session> &
: exactAttempt.errorCode;
if (!enhancedBroadcastingPolicy::allowsCandidateDescent(result.errorCode))
return result;
result.candidateFallback.record(result.errorCode);
pushEvent(session, "progress", "bandwidth", candidateEnd, "enhanced_broadcasting_candidate_rejected", probe.legId, "active",
probe.probeId, probe.provider, 0, &eventVideo, 0, 0, eventAdditionalVideoPtr);
continue;
Expand Down Expand Up @@ -4828,7 +4839,7 @@ static void runSession(const std::shared_ptr<Session> &session)
const ProbeResult &tested = **enhancedResult;
recommendation.measurementMode = "active";
recommendation.confidence = tested.pairedCadenceEvidence ? "medium" : "high";
recommendation.reason.clear();
recommendation.reason = tested.candidateFallback.reason();
Comment thread
aleksandr-voitenko marked this conversation as resolved.
recommendation.value.width = (int)tested.testedWidth;
recommendation.value.height = (int)tested.testedHeight;
recommendation.value.fpsNum = (int)tested.testedFpsNum;
Expand Down
Loading
Loading