Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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